Skip to content

feat(rule): add security rules for dangerous capabilities and host namespaces - #78

Open
nozaq wants to merge 35 commits into
mainfrom
claude/rule-expansion-proposal-1401z4
Open

feat(rule): add security rules for dangerous capabilities and host namespaces#78
nozaq wants to merge 35 commits into
mainfrom
claude/rule-expansion-proposal-1401z4

Conversation

@nozaq

@nozaq nozaq commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds four new security-focused linting rules to decolint that detect container configuration patterns that compromise isolation or grant excessive privileges:

  • no-dangerous-cap-add: Flags individual Linux capabilities that allow processes to reach beyond the container (e.g., SYS_ADMIN, SYS_MODULE, NET_ADMIN)
  • no-host-namespace: Detects when containers are placed in the host's namespaces via --network=host, --pid=host, etc.
  • no-apparmor-unconfined: Reports when AppArmor confinement is disabled via apparmor=unconfined
  • no-initialize-command: Warns about initializeCommand which runs on the host outside container isolation

Key Changes

  • New rule implementations (no_dangerous_cap_add.go, no_host_namespace.go, no_apparmor_unconfined.go, no_initialize_command.go):

    • Each rule includes detailed descriptions explaining the security implications
    • Rules check both direct properties (capAdd, securityOpt, initializeCommand) and runArgs entries where applicable
    • Comprehensive test coverage for each rule with edge cases
  • Utility function enhancement (util.go):

    • Refactored runArgsFlagValues() to return all matching flag values instead of just the first match
    • This enables rules to report every violation rather than stopping at the first one
    • Maintained backward compatibility with runArgsFindFlagValue() which now uses the new function
  • Rule registration (rules.go):

    • Added all four new rules to the builtin rule list
  • Documentation (README.md):

    • Updated rule count for the security category (now 17 rules)

Implementation Details

  • The no-dangerous-cap-add rule maintains a curated list of truly dangerous capabilities (those that reach the host), deliberately excluding capabilities like SYS_PTRACE that stay within the container's process namespace
  • Capability names are normalized to handle Docker's case-insensitive matching and optional CAP_ prefix
  • Rules properly distinguish between devcontainer.json (which supports runArgs) and devcontainer-feature.json (which does not)
  • All rules follow the existing pattern of checking both direct properties and runArgs entries where semantically appropriate

https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ

claude added 3 commits July 28, 2026 10:32
Add four security rules covering privilege grants the existing rules
leave uncovered:

- no-apparmor-unconfined: the AppArmor counterpart of
  no-seccomp-unconfined, for "apparmor=unconfined" (and the ":"
  separator Docker also accepts) in "securityOpt" or "runArgs".
- no-host-namespace: a "host" value for --network/--net, --pid, --ipc,
  --uts, --userns, or --cgroupns in "runArgs".
- no-dangerous-cap-add: individual capabilities that let a process act
  on the host, where no-cap-add-all only covered "ALL". Capability
  names are matched the way Docker matches them, case-insensitively
  and with or without the "CAP_" prefix. SYS_PTRACE is excluded: it is
  the documented way to run a debugger in a dev container and is what
  three other rules recommend instead of a broader grant.
- no-initialize-command: "initializeCommand" is the one lifecycle
  command that runs on the host rather than in the container.

Extract runArgsFlagValues from runArgsFindFlagValue so a rule can
report every entry setting a flag, not just the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
no-host-namespace matches the CIS Docker Benchmark's per-namespace
controls (5.10 network, 5.16 process, 5.17 IPC, 5.21 UTS, 5.31 user)
and the Kubernetes Pod Security Standards' Host Namespaces control;
cite both. Record why the cgroup namespace has no matching control.

no-dangerous-cap-add has no published denylist behind it — the
capabilities it reports are a subset of what the Pod Security
Standards' baseline policy disallows adding, narrowed to those that
reach the host. Say so, and cite the policy the subset is taken from,
so a later change to the list has a stated criterion to argue against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
It is a Kubernetes policy, not anything the Dev Container
specification or Docker defines, so it does not belong among the
references a reader of these rules is pointed at.

no-dangerous-cap-add states its own criterion instead: a capability is
reported when granting it lets a process reach the host, which is what
leaves "SYS_PTRACE" off. no-host-namespace keeps the CIS Docker
Benchmark, which has a control per namespace it reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
@nozaq nozaq changed the title Add security rules for dangerous capabilities and host namespaces feat(rule): add security rules for dangerous capabilities and host namespaces Jul 28, 2026
claude added 12 commits July 28, 2026 13:28
no-cap-add-all compared "ALL" case-sensitively while
no-dangerous-cap-add normalizes case but leaves "ALL" to it, so
"capAdd": ["all"] and "--cap-add=all" were reported by neither rule
even though Docker matches the name case-insensitively and grants
every capability. Match it case-insensitively where it belongs.

Drop NET_ADMIN from no-dangerous-cap-add: the kernel confines it to
the container's own network namespace, so calling it "reconfiguring
the host's network stack" was wrong, and keeping it contradicted the
criterion that leaves SYS_PTRACE off. State that criterion as
"reaches a kernel subsystem that is not namespaced" and name both
capabilities as the standing examples of what it excludes.

no-initialize-command declares no Platforms, so it also applies to
Codespaces, where the command runs on the ephemeral VM rather than on
a developer's own machine. Describe the machine creating the container
instead of assuming which one it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Four gaps where a rule added here diverged from the one it pairs with:

- require-cap-drop-all compared "ALL" case-sensitively, so
  "--cap-drop=all" was reported as not dropping capabilities even
  though Docker drops every one. It now shares capabilityIsAll with
  no-cap-add-all, which is why that helper moves to util.go.
- no-seccomp-unconfined and no-seccomp-override matched only the "="
  separator, so "seccomp:unconfined" was reported by nothing while the
  new AppArmor rule handled both forms. Extract securityOptValue and
  route all three rules through it.
- no-initialize-command's "names no command" guard covered null and
  the empty string but not an empty argv array or an empty object of
  parallel commands.
- no-cap-add-all quoted "ALL" in its message whatever the entry said,
  so a lower-case entry produced a message naming text absent from the
  file. Echo the entry as written, as the rules added here do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
no-cap-add-all and no-seccomp-unconfined returned on the first
offending "runArgs" entry, so a second one went unreported — while the
same two rules report every offending "capAdd" or "securityOpt"
element, and no-seccomp-override reports every entry. Accumulate the
findings instead of returning the first.

no-privileged-container matched the entry "--privileged" exactly.
"--privileged" is a boolean flag, so docker run also accepts
"--privileged=true" (and the devcontainers CLI passes "runArgs"
through verbatim), which went unreported. Parse the value the way Go
parses a bool, leaving "--privileged=false" alone, and echo the entry
as written in the message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
securityOptIsNoNewPrivileges accepted only the literal "true", so
"no-new-privileges=1" and "=True" were reported as leaving the option
unset even though Docker parses the value with strconv.ParseBool —
the same parsing no-privileged-container just gained for
"--privileged=<bool>". Route it through securityOptValue for the
separators and ParseBool for the value, which also corrects a doc
comment that described Docker as accepting only "true" and "false".

Move no-seccomp-unconfined's securityOpt colon case back into the
securityOpt group of its test table; it had been inserted under the
runArgs comment, between two runArgs cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
The recurring defect in this PR's review was rules interpreting
"runArgs" and "securityOpt" entries slightly differently from each
other: case-sensitive capability names, only the "=" separator, only
the bare form of a boolean option, only the first matching entry.
Each fix landed in one rule and left its siblings behind.

Gather the conventions in rules/dockerargs.go, with the list of them
stated once at the top: flag value forms, "securityOpt" separators,
boolean option values, and capability name normalization. Move
runArgsFlagValues, runArgsFindFlagValue, runArgsApplicable,
securityOptValue, capabilityIsAll, and normalizeCapability there from
util.go and from the rule files, and add optionValue/optionEnabled so
the bool parsing behind "--privileged=<bool>" and
"no-new-privileges=<bool>" is one implementation rather than two.

No behavior change; util.go keeps the HuJSON accessors and the mount
and reference parsing, which are not Docker argument syntax.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Checked each convention in rules/dockerargs.go against docker/cli and
moby rather than against the flag documentation, which turned up one
form that goes unreported and one that is reported but does not exist.

opts.NetworkOpt.Set reads a "--network" value as a field list once any
field carries a value, taking the network from the "name" field and
lower-casing each field, so "--network=name=host" and
"--net=NAME=HOST" select host networking and went unreported. Add
networkModeTarget for it. Only "--network" parses a field list, so
every other namespace flag keeps naming its namespace directly and
"--pid=name=host" stays unreported, as Docker leaves it.

parseSecurityOpts splits an entry on ":" only when it holds no "=", so
"seccomp:profile=custom.json" names the key "seccomp:profile" and is
not a seccomp override; matching the separators by prefix reported it
as one. Split the entry the way Docker does instead.

Also split optionEnabled into securityOptEnabled and flagEnabled: a
boolean flag takes no value from the following entry, which the shared
separator argument obscured.

Confirmed unchanged against the same sources: capability names are
upper-cased with "CAP_" added if absent and "ALL" recognized before
the prefix; "--cap-add" and "--security-opt" are ListOpts and do not
split on commas; a namespace value is compared to "host" exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
opts.NetworkOpt.Set assigns the network from every "name" field it
reads, so a repeated one leaves the last, while networkModeTarget
returned the first. "--network=name=devnet,name=host" runs in the host
network namespace and went unreported, and "name=host,name=devnet"
was reported although Docker uses devnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
networkModeTarget matched a field's key by prefix, but docker/cli
trims the space around a field's key and value, so
"--network=alias=web, name=host" joins the host network and went
unreported while "--network=name=host, alias=web" did not — whether
the rule fired turned on where the space fell. Cut each field and trim
both halves, and treat a field carrying no value as leaving the value
naming no network, as Docker's error does.

no-seccomp-override judged each "runArgs" entry on its own rather than
scanning the array for "--security-opt", so any entry that reads like
a security option was flagged wherever it sat. The ":" separator this
PR added widened that: "-v", "seccomp:/data" was reported as
overriding the seccomp profile. Take the rule's "runArgs" path to the
whole array and read the values through runArgsFlagValues, as the
sibling rules do, so a value counts only where it follows
"--security-opt".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Four subagents derived edge cases from docker/cli, moby and pflag
without sight of this code; running the 47 they produced against the
binary failed 12, nine of them one defect: every rule judged a
"runArgs" entry by its shape alone, so an entry set a flag wherever it
sat. Docker parses positionally, and these all went unreported by it
while decolint reported them:

  ["-e", "--cap-add=SYS_ADMIN"]        value of the preceding flag
  ["--name", "--cap-add", "SYS_ADMIN"] swallowed by a preceding flag
  ["myimage", "--cap-add=SYS_ADMIN"]   after the image name
  ["--", "--cap-add=SYS_ADMIN"]        after the terminator
  ["--security-opt", "--privileged"]   value of the preceding flag

Add runArgsFlags, which walks the array left to right as pflag does:
a boolean flag never takes the following entry, every other flag
always does whatever it looks like, a shorthand cluster ends at the
first letter that takes a value, and "--" or the first positional ends
flag parsing along with everything after it. runArgsFlagValues now
filters that walk, so the five rules already reading it are fixed
without changing them; no-privileged-container moves off "/runArgs/*"
to join them.

Two tests asserted the old behavior for a non-string entry before a
flag. Docker takes a non-string as the image name, so nothing after it
is a flag; they now expect no finding.

Three of the twelve remain, all repeated-option precedence, and all
reported where Docker would use the other value:
  securityOpt ["seccomp=unconfined","seccomp=builtin"]  last wins
  runArgs ["--network","devnet","--net","host"]         first wins
  runArgs ["--pid","host","--pid","container:x"]        last wins
Each needs the rule to pick an effective value rather than report
every occurrence, which is a change of contract rather than a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
The 47 cases the subagents derived from docker/cli, moby and pflag now
all match. Three were left in the previous commit as a change of
contract rather than a fix; they are repeated-option precedence, which
decides which occurrence the container actually runs with:

  securityOpt ["seccomp=unconfined","seccomp=builtin"]  last wins
  runArgs ["--network","devnet","--net","host"]         first wins
  runArgs ["--pid","host","--pid","container:x"]        last wins

A repeat of these replaces the earlier value instead of adding to it,
so reporting every occurrence names a value that never takes effect.
Add runArgsEffectiveFlag and securityOptEffective, and have the rules
report only the occurrence that decides the setting. "--network" and
its "--net" alias share one list whose first entry becomes the network
mode; every other namespace flag and every "securityOpt" key is
overwritten, so the last wins. The accumulating flags are unaffected:
"--cap-add" and "--security-opt" still report every entry, because
Docker applies every one.

no-docker-socket-mount had the positional defect the previous commit
fixed elsewhere: it read "/runArgs/*" and judged each entry alone, so
["--name", "-v", "/var/run/docker.sock:..."] was reported although the
"-v" there is --name's value. Move it onto runArgsFlags, which now
also resolves shorthand flags so "-v" is read where Docker reads it.

flagEnabled goes with no-privileged-container's move to the array
walk in the previous commit; nothing calls it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
runArgsFlags stopped at a non-string entry, on the reasoning that
"docker run" would take it as the image name and parse no flag after
it. That reasoning does not hold: "runArgs" is an array of strings, so
a non-string is invalid rather than positional, and it never reaches
"docker run" — the devcontainer CLI spreads the array into an argv
that cp.spawn rejects outright. Nothing specifies what happens, and
the parse rule was invented rather than derived.

Undefined input is where a linter should fail loud, not quiet:
["runArgs": [123, "--privileged"]] left the "--privileged" unreported
because of a typo elsewhere in the array. Skip the entry and keep
reading instead, and likewise where a non-string sits in a flag's
value position — the flag took it, but the entries after it are still
configuration someone wrote.

The four cases asserting the old behavior now expect the finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
"docker run [OPTIONS] IMAGE [COMMAND] [ARG...]" is documented, and
flags.SetInterspersed(false) is how docker/cli enforces it, so a bare
entry in "runArgs" really does end flag parsing. Stopping there is
still the wrong thing for a linter to do.

The devcontainer CLI appends "runArgs" ahead of the image and ahead of
the flags it derives from the configuration (singleContainer.ts: the
array is spread before featureArgs, entrypoint and imageName). A bare
entry therefore takes the place of the image and demotes the rest —
including the "--privileged", "--cap-add" and "--security-opt" the CLI
generates from the configuration's own properties — to words of the
container command. The file is broken, not benign.

decolint already reports "privileged", "capAdd" and "securityOpt"
whether or not such an entry voids them, so stopping the runArgs walk
there was inconsistent as well as quiet: ["myimage", "--privileged"]
reported nothing. Read on instead. The "--" terminator still stops the
walk, since that one is written to mean exactly that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
claude and others added 13 commits July 29, 2026 03:23
The list was assembled from a summary rather than from docker/cli's
flag registration, and missed one: --use-api-socket is registered with
flags.BoolVarP in both run.go and create.go. A boolean flag left off
the list is the costly direction — the walk takes the entry after it
as its value and skips whatever that entry really was, so
["--use-api-socket", "--privileged"] reported nothing.

Re-derived both lists from every Bool and *VarP registration across
cli/command/container's opts.go, run.go and create.go. The 15 boolean
flags are now complete, and the value-taking shorthands were already
right: of the 14 shorthands docker run registers, -d, -i, -q and -t
are boolean and the other ten take a value ("c" is --cpu-shares).
Record where the lists come from so the next reader can re-check them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Reading "runArgs" positionally needed a table of which flags consume
the entry after them, and a boolean flag missing from it made the walk
skip that entry — a finding lost, silently. --use-api-socket was
missing (cb39acf), which is the failure mode the design invites rather
than a one-off: the table has to track docker/cli's flag set forever,
and every drift costs a finding.

What it bought was small. Of the 47 derived cases only four turn on
arity, and the one that matters — ["-v", "seccomp:/data"] read as a
seccomp override — is caught by asking whether the entry before a
value is the flag being looked for, which needs no table at all.

So read every entry, and where one can be read in more than one way
return each reading: a bare "--flag" is both a boolean set to true and
a flag taking the entry after it, and "-vX" is both "-vX" and "-v" set
to "X". A rule looks up the flag it cares about, so a reading naming a
different flag costs nothing. The "--" terminator and the image-name
position go with the table; both only arise in configurations Docker
would not run as written, and 677cb10 already decided to read past the
latter.

The cost is a finding on a value spelled exactly like a flag —
["--security-opt", "--privileged"] and ["--", "--cap-add=SYS_ADMIN"]
are now reported, and require-cap-drop-all counts a "--cap-drop=ALL"
in a value position as set. Seven corpus cases record that, with the
reason. No maintenance, and no way to lose a finding to a stale list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
The rule reported "initializeCommand" being used at all, on the
grounds that it is the one lifecycle command running outside the
container. Two things undercut that.

It cannot arrive unseen. The specification's merge-logic table lists
every lifecycle command a Feature or image metadata may contribute,
and "initializeCommand" is not among them; the Feature schema has no
such property either. Unlike a "securityOpt" inherited from a base
image, it is always written in the file the reader is already looking
at, and its name says what it does.

And it offered no fix. Every other rule in the category names a
narrower way to get the same thing — "capAdd" for a capability, a
user-defined network instead of the host's. The legitimate use of
"initializeCommand" is host-side work before the container exists,
which by definition cannot move into it, so the finding left those
users with nothing to do but silence the rule. Reporting a
specification-sanctioned property for being used is a preference, not
a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Five cases in no-host-namespace's table exercised shorthand entries —
"-v" alone, "-e" taking the next entry, the "-it" cluster, "-eFOO=bar"
— none of which the rule looks up. They were written against the walk
that tracked which flag consumes which entry, to reach its cluster and
value-skipping branches. 7e22ddb removed that walk, so they now assert
only that an unrelated entry does not stop "--pid=host" from being
found, which holds for any entry at all.

The one shorthand form that still matters is a value carried in the
same entry, and it matters to no-docker-socket-mount, which does look
up "-v"; add "-v/var/run/docker.sock:..." there. What is left in
no-host-namespace's table is about that rule: a bare flag reads as a
boolean and so names no namespace, and a non-string entry is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
dockerargs.go carries decolint's reading of Docker's own argument
handling, derived from docker/cli, moby and pflag rather than from any
specification, and until now it was only exercised through the rules
that call it. That left the reading stated nowhere executable, made a
failure ambiguous between rule and parser, and made coverage
incidental — ca13d3c removed five cases from an unrelated rule's table
and had to relocate one of them to keep a branch covered at all.

Add rules/dockerargs_test.go, an internal test as docs_test.go
already is, stating the contract per function: which readings an entry
yields and why more than one, how a "securityOpt" entry splits and
which separator wins, how a boolean option's value is read, which
occurrence of a repeated flag decides it, how a "--network" field list
resolves, and how a capability name normalizes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
"Read the value with [optionBool]" told the caller to do something
without saying why, and "read" could as easily have meant "access".
What matters is the contract: Value is the flag's value as written and
the bare form reads as "true", so a comparison against "true" gets
"--privileged=1" wrong in one direction and "--privileged=yes" wrong
in the other. Say that, and that the reading taking a value from the
following entry is left out because a boolean flag is never set that
way.

The file header still described a boolean flag as taking only the
combined form, which is Docker's convention but no longer what this
file encodes: 7e22ddb stopped choosing between the readings of an
entry. Point at runArgsFlags for why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
The AppArmor rule reported every "apparmor=unconfined" entry while
no-seccomp-unconfined, which its own doc calls its counterpart,
reported only the one Docker keeps. Two ways to fix that; this takes
the one that matches how the rest of the file already decides.

runArgsEffectiveFlag and securityOptEffective encoded which repeat of
an option Docker keeps — "--network" and its alias keep the first,
every other namespace flag and every "securityOpt" key the last. That
is another table of docker/cli internals whose drift loses a finding
silently: read "--network" as last-wins after Docker changed to it and
["--network=devnet", "--net=host"] stops being reported at all. It is
the failure mode 7e22ddb removed the arity table for.

What the table bought was silence on a repeated option, and that
silence is wrong for the same reason a positional entry no longer
stops the walk (677cb10): the entry it hides is dead only where it
stands, and live again the moment the order changes. A
"seccomp=unconfined" left above a later "seccomp=builtin" is worth
deleting, not worth hiding.

So report every entry, as the AppArmor rule already did. Three corpus
cases record the divergence from Docker, with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
parseMountString matched the literal "source", but docker/cli's mount
parser lower-cases each field's key and the "type" value and takes
"src" as an alias of "source" (opts/mount.go, MountOpt.Set). So
"type=bind,src=/var/run/docker.sock,..." named the socket to Docker
and nothing to decolint, while the "source=" spelling of the same
mount was reported — in both "runArgs" and the "mounts" string
shorthand, which is a "--mount" value.

The source path itself is left as written; it is a path.

Also correct runArgsFlag.Raw. It is documented as the entry the flag
was written in, but the reading that takes a value from the following
entry set it to that entry's text. Nothing reads it there today —
runArgsBoolFlag filters those readings out — so this is a trap rather
than a bug: set Raw to the flag's own entry and say that it and Node
then point at different entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
runArgsFlags returns every way an entry could be read, so one entry
can reach a rule twice. no-docker-socket-mount reported the same
element twice for ["--mount", "--mount=type=bind,source=..."]: once as
the previous flag's value, whose text still carries the "--mount="
prefix, and once as its own combined flag. parseMountString scans for
"key=value" fields and tolerates the leftover prefix as one more
field, so both readings matched. The sibling rules escape it only
because their matchers want the whole value to have one exact shape.

Fixing it per rule would leave the next one to find it again, so drop
the duplicate where every rule's findings meet: two alike in path,
position, rule and message say nothing the first does not. A finding
differing in any field is not a duplicate and is kept, so a rule can
still report two distinct problems at one position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
TestLintDocument_DropsDuplicateFindings, added in c6f1ad2, asserted an
order sort.Slice does not promise: it is not stable, so two findings
sharing a line, column and rule came out in whichever order the sort
left them. The test passed here and failed elsewhere.

The test is the symptom; the ordering is the defect. A rule may report
several findings at one position — missing-required-props reports one
per absent property, all at the document root — and their order in the
output varied between runs, which a diff of two CI runs or two SARIF
uploads would show. Break the tie on the message.

Give the test's third finding a column of its own as well, so it rests
on the position order rather than on the new tiebreak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
354a0f7 lower-cased a mount entry's type where the entry is a string,
matching what docker/cli's mount parser does, and left the object form
comparing it as written. So {"type": "Bind"} escaped no-bind-mount
while "type=Bind,..." — the same mount, the other spelling — was
reported.

The devcontainer.json schema declares the object form's type as a
lower-case enum, so this input is invalid there, but it is not inert:
the tooling turns the object into a "--mount" value, and Docker
lower-cases the type in it, so the container really does get a bind
mount. Lower-case it in both, and test both spellings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
An entry Docker consumes as the value of the flag before it was read as
a flag of its own, which inverted two rules: `["--label",
"--cap-drop=ALL"]` silenced require-cap-drop-all even though the entry
is the label's value and no capability is dropped, and the same shape
silenced require-no-new-privileges. The reverse cost was a false
positive wherever a value happened to be spelled like a flag.

Deciding which entry a flag consumes needs every flag's arity, and a
hand-kept table of that loses findings whenever it falls behind Docker.
cmd/dockerflagsgen takes the table from the flags docker/cli registers
instead, so it can disagree with Docker only by being older, and a flag
missing from it falls back to the previous reading — the direction that
offers a rule a value it never asks for rather than skipping an entry.

The generator is a module of its own so docker/cli stays out of
decolint's dependencies, and CI regenerates the table and fails on a
diff, which turns a Docker release that moves one of these flags into a
reviewable change rather than a finding that is quietly wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reading had grown two vocabularies that sat next to each other:
dockerargs.go for the whole of it, and dockerRunFlag/dockerflags_gen.go
for the "docker run" flag table inside it. Neither name was wrong — the
file covers "securityOpt" and capability names too, which the table does
not — but the pair reads as drift.

Hoisting "docker" to a package name settles it: dockerargs.Flags,
dockerargs.SecurityOptEnabled and dockerargs.NormalizeCapability say
whose conventions these are, so nothing inside has to repeat it and the
generated table is simply runflags_gen.go.

runArgsApplicable stays in rules: it answers which file type "runArgs"
is meaningful in, which is devcontainer knowledge rather than Docker's,
and keeping it out leaves the new package depending on hujson alone. The
mount helpers in util.go stay too — parseMountString is Docker's syntax
but parseMountObject is the "mounts" property's, and splitting the three
would scatter one mount's reading across two packages.

Behavior is unchanged. BoolFlag, FindFlagValue and NamespaceValue were
reached only through the rules' tests, which no longer count toward the
package holding them, so they gain direct tests; total coverage is
unchanged at 94.7%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nozaq
nozaq force-pushed the claude/rule-expansion-proposal-1401z4 branch from 7e47a58 to 6933938 Compare July 29, 2026 19:49
claude added 7 commits July 29, 2026 21:58
…401z4' into claude/rule-expansion-proposal-1401z4
"runArgs": ["-itv", "/var/run/docker.sock:/var/run/docker.sock"] really
does bind-mount the Docker socket, but Flags read only the first letter of
a cluster, so no-docker-socket-mount reported nothing — the silent false
negative the package exists to avoid.

Read a "-x..." entry the way pflag's parseShortArg does: a letter at a
time, each a flag of its own until one takes a value, which is the rest of
the entry or the entry after it when nothing is left. A letter the flag
table does not hold is still read both ways.

This also drops two readings that named flags Docker has none of:
"-v/src:/dst" no longer reads as a bare flag of that name, and "-eFOO=bar"
no longer reads as "-eFOO" set to "bar".

Reword no-host-namespace's message while here. It asserted that the
container shares the namespace, which is untrue of an entry a repeat of
the same option supersedes: Docker keeps the first "--network" value and
the last of every other one. Every occurrence is still reported, since
which one is live is a property of the order alone, but the message now
says what the entry asks for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
pflag gives a value written past a "=" before it gives a bare flag its
default, so "-i=0" sets "-i" to "0" rather than reading as bare. Nothing
stated that, or the reading of a letter that takes a value mid-cluster.

The flag table records only whether a flag may be written bare, and
Flags reads a bare occurrence as "true" — which is what pflag sets every
such "docker run" flag to today, but not what it would set one with an
optional value of its own to. Stop the generator there rather than let
the table reach decolint with that reading silently wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Every rule reading "runArgs" opened with the same ten lines: cast the
node to an array, check the file type, ask dockerargs for one flag's
values, loop. Eight rules carried it, no-host-namespace ran the whole
argv parse seven times over for one document, and a rule that got any of
it wrong would be wrong on its own.

Give the engine a second traversal of that one array. Its elements are
positional to JSON but a "docker run" argv to the tooling, in which a
flag and its value may be one element or two and one element may name
several flags, so a pattern names the flag — "/runArgs/--cap-add",
"/runArgs/-v" — and the rule is handed each occurrence on Node.Flag,
with Node.Value the element the value is written in.

The array is now parsed once per document rather than once per rule per
flag. The traversal runs only for a devcontainer.json, where "runArgs"
means anything, so runArgsApplicable is gone from the rules and from
util.go. Every rule test passes unchanged.

dockerargs keeps what a value means; what it no longer needs to export
is unexported: FlagValues and FlagValue fold into FindFlagValue, which
the two rules reporting a flag's absence still ask, and BoolFlag becomes
Flag.Bool now that the engine does the selecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
A pattern matched Flag.Name as written, so "/runArgs/--volume" was not
handed "-v" and a rule had to spell both. no-docker-socket-mount did;
the next rule to name a value-taking flag would have had to remember,
and its own tests would have passed either way. That is the silent miss
the flag traversal was meant to remove, not one to leave in it.

Canonicalize both sides through the flag table: a pattern's flag segment
and the flag a reading is visited under both become the long form, so
either spelling names the flag and one occurrence still visits a rule
once. "--net" is a flag Docker registers in its own right rather than a
shorthand of "--network", so it keeps its own name, and no-host-namespace
covering both spellings is still covering two flags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Both traversals descended into the array, so its elements had two paths
each — their index and the flag they set — and "/runArgs/*" matched
both. A rule naming it was handed one element twice under two readings:
three entries produced five visits, and the entry a value-taking flag
consumes, which is a value rather than a flag, arrived as a flag anyway.

Let walk stop at the array and leave its elements to walkRunArgs. Under
"runArgs" a segment now names a flag and nothing else, so "/runArgs/*"
is every flag the array sets. The array itself is still visited, so
"/runArgs" and a root "/*" reach it, and a "runArgs" nested elsewhere in
the document stays an ordinary array.

No rule declared an index or a wildcard there, so nothing reported
changes; the corpus and every rule test are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnTsBGF2nTtG8AS1osuHvJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants