Skip to content

test(journeys): waive each harness subprocess site, then enforce the reason - #430

Merged
Yambr merged 7 commits into
docs/demo-walkthroughfrom
fix/journeys-subprocess-waivers
Aug 11, 2026
Merged

test(journeys): waive each harness subprocess site, then enforce the reason#430
Yambr merged 7 commits into
docs/demo-walkthroughfrom
fix/journeys-subprocess-waivers

Conversation

@Yambr

@Yambr Yambr commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Closes the semgrep half of #346.

What

Thirteen dangerous-subprocess-use-tainted-env-args findings in the journeys harness are all one shape: a stand-config value from the operator's own env reaches a list argv. Each of the nine call sites now carries a per-site # nosemgrep stating the reason that holds there — not a path exclusion, not a rule disable.

The limactl shell $FLEET_LIMA_INSTANCE -- argv leg (conftest.py) is called out explicitly rather than waved through: it rides ssh semantics, which join argv into a command line the VM's shell re-parses, so "list argv, never a shell" is not true end to end there.

Why the waivers are not just an assertion of good faith

Every waiver says "list argv, no shell" — an assumption about code nobody re-reads. test_z_meta_guard.py turns it into an enforced property: an AST scan over the whole journeys tree (including conftest.py and backends/, not only test_*.py) reds on shell=True, os.system/os.popen, or a command built by f-string / % / .format / concatenation.

Interpolation inside one argv element stays legal — f"name={cname}" reaches the program as a single argument, with no shell to re-parse it. The planted-violation test pins both sides of that boundary, so the guard can neither go vacuous nor force the waivers off.

Measured, not read

  • Each marker suppresses exactly one finding. Deleting any one of the nine resurrects a finding; none is dead weight. Tree: 13 findings → 0.
  • A new call fires fresh. Planting an unwaived subprocess.run([os.environ["NEW_THING"], "-x"]) produces a finding — the waivers are per-site, not a blanket exemption.
  • The guard catches real hazards. Planting each of shell=True, f"docker rm {path}", os.system("echo " + path), "cat %s" % path into a live harness file reds the suite; all four caught.
  • The guard does not red on the harness as it stands. 32 passed, 1 skipped.

The first cut of the detector flagged 13 live sites — it conflated an f-string inside one argv element with a built command line. Narrowed to the latter, then re-mutated to confirm the narrowing did not blunt it.

…reason

The semgrep tainted-env-args findings in the journeys harness are all the same
shape: a stand-config value from the operator's own env reaches a list argv.
Each site now carries a `# nosemgrep` with the reason that holds THERE, rather
than a path exclusion — a subprocess call added later fires fresh instead of
being silently pre-exempted.

The `limactl shell $FLEET_LIMA_INSTANCE -- argv` leg is called out explicitly:
it rides ssh semantics, which join argv into a command line the VM's shell
re-parses, so "list argv, never a shell" is not true end to end there.

Every waiver states "list argv, no shell", which is an assumption about code
nobody re-reads. test_z_meta_guard now makes it a property: an AST scan over
the whole journeys tree reds on `shell=True`, `os.system`/`os.popen`, or a
command built by f-string / `%` / `.format` / concatenation. Interpolation
inside ONE argv element stays legal — `f"name={cname}"` reaches the program as
a single argument — and the planted-violation test pins both sides of that
boundary so the guard can neither go vacuous nor force the waivers off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • next/v1

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c2d88645-5d29-4147-b607-a4bc2e2df795

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

widemoat-ai and others added 3 commits August 11, 2026 11:49
…lling

The first cut of the guard matched a syntactic silhouette, so nine genuine
host-side hazards walked past it. An adversarial pass planted each one and the
detector returned nothing:

  subprocess.getoutput("docker rm " + x)      # /bin/sh by construction
  subprocess.getstatusoutput(f"...")          # likewise; semgrep misses it too
  from subprocess import run; run(f"...")      # callee not spelled subprocess.run
  cmd = f"..."; subprocess.run(cmd)            # command hoisted into a local
  from os import system; system("..." + x)
  subprocess.run(a, shell=sh)                  # non-literal shell=
  subprocess.run(a, shell=1)
  subprocess.run(a, shell=bool(os.getenv(..)))
  sh = subprocess.run; sh("docker rm " + x)    # alias

getoutput/getstatusoutput matter most: they take a command string and hand it
to /bin/sh, no list-argv form exists, and semgrep's python bundle does not flag
them either — this guard is their only backstop.

The detector now resolves the callee (from-imports and simple aliases) instead
of matching its spelling, tracks locals holding a built command string, treats
any non-False `shell=` as a hit, and carries a shell-by-construction callee set.
Interpolation inside ONE argv element stays legal, and the clean half of the
planted test pins that so the guard cannot force the waivers off.

The planted test previously exercised only the shapes the detector already
caught, which made it green by construction; it now plants every evasion above.

Two waiver reasons overstated their case and are corrected: test_k_admin's
`body` carries env-derived credentials via `_login` rather than literals, and
`_psql` takes one f-string (interpolating `int(value)` and a module constant).
Both remain safe because each value is one argv element, not because the values
are constants — which is the reason the comment should have given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e form

A second adversarial pass found a false positive, which is worse than any of
the misses it also found: `string_locals` was a flat, file-global map, so a
local holding a LIST argv was flagged whenever another function in the same
file bound the same name to a built string.

    def list_probe(name):
        cmd = ["docker", "ps", "--filter", f"name={name}"]
        return subprocess.run(cmd)          # flagged, and clean

    def other(x):
        cmd = f"echo {x}"
        return subprocess.getoutput(cmd)    # the actual hazard

`cmd`, `args` and `argv` are what this harness names its list argv, so the next
helper added to an already-waived file would have reddened the safe form — and
a guard that reds on the safe form forces the waivers off instead of keeping
them honest, the exact failure the negative test claims to prevent. The tree is
clean today, so this was latent rather than visible.

String locals are now indexed per enclosing function (module level included),
and the lookup consults the scope of the call being inspected.

Two genuine hazards the pass also found are closed: `**{"shell": True}`, whose
keyword carries `arg is None` and so was never examined by the shell= loop, and
`import os as o; o.system(...)`, which `ast.Import` never bound (only
`ImportFrom` and attribute assignments were tracked).

The planted test pins all three, including the scope case as a line-number
assertion so a regression names the clean call it wrongly reds. 11/11 evasions
caught, harness green, semgrep tree still 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-function scoping did not close the false positive; it moved it one level
down. `_index_scope` skipped a nested function with `ast.walk` + `continue`,
which prunes that one NODE and still descends into its body, so an inner
helper's built string landed in its parent's locals and reddened the parent's
clean list argv:

    def outer(name):
        cmd = ["docker", "ps"]
        subprocess.run(cmd)        # flagged, and clean
        def inner(x):
            cmd = f"docker rm {x}"
            subprocess.run(cmd)    # the actual hazard

Not hypothetical: conftest.py and test_f_agentic_load.py already nest helpers
inside waiver-bearing functions, so the first inner `cmd`/`args` holding a
string would have reddened the outer call.

Indexing now descends through direct children and stops at each nested scope,
carrying a nested definition's decorators and argument defaults with the
enclosing scope, where they actually evaluate.

Comprehensions and lambdas get their own scope for the same reason — a loop
target or a lambda argument binds inside and shadows the enclosing name — and
inherit the enclosing scope minus what they bind, so a comprehension that
merely READS an outer built string is still caught.

The planted test pins both as line-number assertions, so a regression names
the clean call it wrongly reds rather than just going red somewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
widemoat-ai and others added 3 commits August 11, 2026 12:18
…first one

A comprehension inside a function resolved its names through the MODULE, so a
command string built in the function was invisible:

    def t(x):
        cmd = f"rm {x}"
        return [subprocess.run(cmd) for _ in y]   # missed

Two causes, both fixed. The parent map recorded whichever scope a walk reached
first and refused to overwrite it, which put every nested comprehension under
the module; it now records the nearest enclosing scope by descending from each
scope to its own children. And inheritance was precomputed in walk order, so a
comprehension could inherit before its enclosing function had been indexed; the
chain is now resolved at lookup, walking outward and stopping at any name the
scope binds itself.

A walrus binds in the enclosing scope rather than the comprehension (PEP 572),
so its assignment is collected there.

10/10 scope cases correct, including a comprehension shadowing its own loop
target, a lambda reading a function local, and a two-level nested def. All 12
evasions still caught, semgrep tree still 0.

The planted test pins the three shapes by line number. Its assertion sorts the
hits: they are not emitted in line order, and asserting the unsorted list would
have made the test depend on traversal order rather than on the finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…inary ones

The inheritance lookup popped `args.args` alone, so a lambda whose own
positional-only, keyword-only, `*args` or `**kwargs` parameter shadowed an
enclosing built-string local still inherited that string, and the lambda's
clean call reddened:

    cmd = f"echo {X}"
    posonly = lambda cmd, /: subprocess.run(cmd)   # flagged, and clean

All five parameter kinds now shadow. A lambda that READS an enclosing built
string is still caught, so the fix narrows nothing.

Two gaps stay open and are now written down in the detector rather than left
implied: a name rebound through `global`/`nonlocal` in another scope, and a
closure reading an enclosing function's local. Both need cross-scope name
resolution, and neither is a shell hazard — `subprocess.run("<str>")` without
`shell=` is a program-name lookup that fails, not a command line. The shell
surface is `shell=` and the always-shell callees, which resolve regardless of
scope.

Mutation-checked: reverting the pop to `args.args` reds the planted test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yambr
Yambr merged commit 4fb8334 into docs/demo-walkthrough Aug 11, 2026
11 checks passed
@Yambr
Yambr deleted the fix/journeys-subprocess-waivers branch August 11, 2026 09:26
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