feat(security): diff-scope assertion and credential withholding for delegates - #19
Conversation
Extend the Job record with the delegation security-posture fields for slice S4, keeping schema_version at 3 (additive optional fields; v1/v2 records still load with them absent, D7): - scope_result: Optional[ScopeResultDict] — diff-scope assertion outcome. None means no allowlist was declared (enforcement off), distinct from a declared scope that passed. status is ok/violated/undetermined. - withheld_env: Optional[list[str]] — NAMES (never values) of credential env vars withheld from the delegate. None means the scrub did not run. delegation_verdict folds a scope violation OR an undetermined scope into the existing 'failed' state (fail closed) rather than adding a fifth verdict, so the four-state contract and its tests are unchanged; an absent scope skips the gate entirely. runtime_status now surfaces both fields.
A delegate is partially untrusted (research finding [10]); it must not receive the caller's ambient secrets, or a prompt-injected delegate with a cloud key in its environment is a direct exfiltration path. credentials.scrub_env withholds every variable whose NAME matches a curated credential pattern (SECRET/TOKEN/PASSWORD/API_KEY/ACCESS_KEY/...) by default; withheld_names reports the withheld names for the audit trail. Both share one predicate so the launch env and the record can never disagree. Only names are ever recorded — a name is not a secret, its value is.
After a delegate finishes, assert every path it modified lies inside the caller-declared allowlist. Modified paths are determined from git (argument list, shell=False — matching check.py), diffed against a baseline captured BEFORE the delegate ran so a pre-existing dirty tree is not misattributed; paths dirty both before and after are content-hashed so a further edit or a revert is still attributed. Paths are compared by resolved real location so symlink and .. traversal cannot smuggle a write outside the allowlist. Fails closed: a non-git cwd, unavailable git, or a repo-identity shift yields 'undetermined' — a distinct, visible outcome, never a silent pass. assert_scope never raises, so the security check can neither crash the worker nor degrade into a pass. Documented blind spots: .gitignore'd paths, create-then-delete.
…(S4) Wire the S4 posture into the delegation path: - start gains --allow-path (repeatable; declares the write allowlist, None when omitted) and --pass-env (opt-in credential passthrough), persisted to command.json. - The worker captures the scope baseline before the delegate runs, builds the advisor env through credentials.scrub_env (lineage set after the scrub so it is never stripped), runs the diff-scope assertion after the delegate + check, and forwards scope_result + withheld_env onto the SAME terminal transition — the only writer of terminal state. Audit events 'scope' (status + offending paths) and 'env_scrub' (withheld names only) are appended. End-to-end tests drive real jobs through worker_main and reload from disk: an out-of-scope write is a failed delegation with the path listed; declared-only edits pass; a non-git cwd is undetermined (failed), not a pass; a secret env var never reaches the child nor appears in any persisted artifact; --pass-env opts one back in; a v2 record still loads.
…rdict (S5) Add VerifyResultDict + Job.verify_result (schema v3, None = not requested, distinct from a ran-and-failed verify per D7) and refactor delegation_verdict into a gate combiner: any failed gate blocks the green path, a passing gate greens it, an inconclusive/absent gate is neutral. A structured verify 'fail' therefore blocks green even when the shell check passed (D6); a prose-only or errored verifier degrades to inconclusive, never a pass (D4). Surface verify_result in runtime_status.
…dation (S5) Spawn a FRESH peer session that grades the delegate's artifact supplied as user-turn input (D6) — build_verifier_command adds no resume/fork/name flag and never reads the session registry, removing the implicit-authorship channel that weakens self-grading. Prefer a machine-checkable contract where the advisor exposes one (Claude --json-schema -> structured_output; new Advisor.json_schema_flag), else parse a JSON verdict out of the answer; free prose -> unverified, never a pass. The verifier is a delegate too: credentials scrubbed and CROSSAGENT_* lineage stripped. Never raises — a broken verifier degrades to error/unverified. Scope-honest: removes the implicit-authorship channel; does NOT claim to eliminate self-preference bias (a separate documented effect), and the source paper did not study many-turn agentic settings — crossagent's own setting.
…ace children (S5) On a failed delegation (failing check, scope violation, or failing verification -> delegation_verdict == failed), re-dispatch the task to the next larger peer. Recorded as option (a): a same-trace child job with parent_job_id set and the trace_id preserved, resolved through resolve_lineage — exactly what the shipped analytics.py escalation-rate definition counts, so no analytics change is needed. Ladder = ordered advisor[:model] rungs; each hop hands the remainder down, and the check/verify/scope/pass_env posture is propagated so the larger peer is held to the same write boundary and credential withholding. Bounded twice over: the rung list shrinks each hop, and MAX_NESTING_DEPTH is enforced (a LineageError is caught and audited as a skipped escalation, never a crash or a runaway loop).
…d the worker (S5) Add --verify-with/--verify-model/--escalate-to to 'start', persist them in command.json, and load them in the worker. The worker runs the fresh verification pass after the check/scope gates and persists verify_result on the SAME terminal transition (the only writer of terminal state); after that transition it re-dispatches up the escalation ladder when the delegation failed. Refine the result-command verdict line to name the actual failing gate. E2E worker tests drive real jobs through worker_main and reload from disk: a structured verify 'fail' blocks green while the delegate exited 0; a pass yields verified; the verifier argv carries no session flag and the artifact is its final user-turn arg; a prose verifier stays unverified; a failed delegation spawns a same-trace child with parent_job_id set at depth+1.
datj9
left a comment
There was a problem hiding this comment.
Security review — verdict: FAIL (2 HIGH, exploitable now)
Reviewed as a security control, i.e. assuming an adversary is actively trying to defeat it, since the threat model here is a partially-untrusted delegate with write access (Aider #5058: repo prompt-injection → committed backdoor).
Findings below were reproduced empirically against scope.assert_scope in a throwaway git repo with declared = ("allowed",), not inferred from reading.
HIGH — a delegate can write .git/hooks/* and the scope check reports ok
git status --porcelain does not report changes inside .git/, so _dirty_paths never enumerates them and the assertion passes.
Reproduction:
declared = ("allowed",)
baseline = capture_baseline(cwd)
# delegate writes .git/hooks/pre-commit, chmod 0755
assert_scope(baseline, declared, cwd)
→ status="ok", violating_paths=()
Exploitation. A delegate (or repo content steering it) writes .git/hooks/pre-commit, post-checkout, or post-merge. crossagent reports the delegation as in-scope and delegation_verdict() can return verified. The hook then executes arbitrary code as the user at their next git operation — persistent code execution, surviving the job entirely. This is the exact threat class the PR exists to defend against, and it passes the gate with a green verdict.
Remediation. Hash a small fixed set of .git attack surfaces at baseline and compare after: .git/hooks/ contents, .git/config, and .git/info/exclude. These are few and small, so the cost objection that rules out hashing all ignored files doesn't apply. A change there should be violated (they can never legitimately be in a declared allowlist), or at minimum undetermined.
HIGH — writes outside the repository root are entirely undetected
git status only covers the worktree, so anything written outside it is invisible to the control.
Reproduction:
# delegate writes ../outside.txt
assert_scope(baseline, declared, cwd)
→ status="ok", violating_paths=()
Exploitation. A delegate writes ~/.zshrc, ~/.ssh/authorized_keys, ~/.claude/settings.json, or a sibling repo. All report ok. Note ~/.claude/settings.json is particularly sharp here — it can grant tool permissions to future agent sessions, so one unscoped delegation can widen the blast radius of every subsequent one.
Remediation. In-process detection can't enumerate the whole filesystem, so this is a boundary that must be either enforced at the OS level (sandbox / restricted child, which is the real fix) or stated precisely as out of scope. What it must not be is implied away — see the MEDIUM below.
MEDIUM — the module docstring overstates coverage
src/crossagent/scope.py:22: "so neither traversal nor a symlink can smuggle a write outside the allowlist."
That's accurate about the matcher and inaccurate about the control. The limiting factor isn't matching, it's enumeration: out-of-repo and .git/ writes are never enumerated, so no matching is attempted. The "Blind spots (documented, never hidden)" list at :24 is genuinely good practice, and precisely because it establishes that convention, its omissions read as "checked and safe." Add both findings above to that list, and reword :22 to scope the claim to declared-root matching of in-worktree paths.
Confirmed sound (attempted and closed — stated with evidence)
..traversal and symlink escape: both sidesresolve()d before comparison (_resolve,_is_in_scope), so matching is on real locations. Symlink loops fall back to lexical normalization rather than raising.- Prefix collision: uses
Path.relative_to, which is component-wise —/a/bcdoes not match declared root/a/b. This is the classicstartswithbug and it is not present. - Pre-existing dirty tree: baseline captured before the run, with content hashes, so an already-dirty file isn't attributed to the delegate. Verified by control case: an out-of-scope tracked write is caught (
violated,('src/main.py',)). - Fail-closed on indeterminacy: non-repo cwd,
git statusfailure, and repo identity changing mid-run all yieldundetermined, neverok— anddelegation_verdict()maps any non-okscope status tofailed. The mid-run identity check is a bypass I did not anticipate and it is closed. - Subprocess safety: argument lists with
shell=False, timeouts present, matchingcheck.py. - Credential name matching is well-calibrated:
ACCESS_KEYmatched soAWS_ACCESS_KEY_IDis caught, bareKEYdeliberately excluded to avoidKEYBOARD_LAYOUT. Over-matching would silently strip benign env and cause baffling failures; that tradeoff was reasoned about rather than stumbled into. - Zero runtime dependencies preserved — stdlib only, no
pyproject.tomldependency additions.
Not tested (declaring rather than implying coverage)
Hardlinks, case-insensitive filesystem collisions (relevant on default macOS), Unicode NFD/NFC path normalization, submodules, and TOCTOU between baseline capture and assertion. The two HIGH findings above were reproducible without needing any of these, so I stopped there.
Design question, not a defect: enforcement is opt-in
With no allowlist declared, scope_result is None and the gate is skipped. That's defensible — an allowlist nobody wrote can't be enforced, and default-deny would break every existing invocation — and absent stays distinguishable from satisfied on the record. But combined with the two HIGH findings, the honest summary of this control's current guarantee is: "when a scope is declared, in-worktree tracked writes outside it are caught." That's a genuinely useful property and much narrower than "the delegate is constrained." A warning when a write-capable delegation runs unscoped would close the largest usability gap in the posture.
Recommendation: the architecture is right — fail-closed design, one verdict authority, honest blind-spot documentation, sound matcher. The two HIGH findings are gaps in enumeration, not flaws in the design, and the .git/hooks one is cheap to fix. I'd fix .git/ enumeration before merge, and land the out-of-repo boundary as an explicit documented limitation plus a follow-up for OS-level containment.
Reviewed by Claude Code. Findings reproduced against a scratch repo; the two HIGH cases are runnable as regression tests.
datj9
left a comment
There was a problem hiding this comment.
Code review — verdict: NEEDS FIXES (3 HIGH)
Complements the security review above. All three findings independently confirmed in code, not taken on report.
HIGH — escalate.py:118 escalates a cancelled job
maybe_escalate gates on delegation_verdict(failed_job) != "failed", but delegation_verdict returns "failed" for any terminal status other than SUCCEEDED — including CANCELLED and TIMED_OUT. The module docstring scopes escalation to "a failing check, a scope violation, or a failing independent verification"; the code doesn't enforce that.
Impact: crossagent cancel <job_id> on a job with an escalation ladder silently re-dispatches the same task to a larger, costlier peer — the exact opposite of cancelling. cli.py's _failed_reason already makes this distinction (if job.status != JobState.SUCCEEDED: return "delegate did not finish cleanly"), so the concept exists in the codebase but wasn't carried into escalate.py. No test uses CANCELLED/TIMED_OUT as the parent status, so there's no regression coverage.
Fix: only escalate when the parent reached SUCCEEDED (i.e. a declared gate failed). If process-level failures should also escalate, exclude CANCELLED explicitly — it is explicit user intent — and decide TIMED_OUT deliberately. Add a test asserting a cancelled job is never escalated.
HIGH — verify.py tempfile.mkstemp() is outside its own try, breaking the "never raises" contract
Both the module and function docstrings promise a broken verifier never crashes the worker. But schema_fd, schema_path = tempfile.mkstemp(...) runs before the try: block.
Impact: a read-only /tmp, full disk, or restricted TMPDIR (sandbox/CI container) raises OSError out of run_verification, through worker.py's caller (no guard there either), into worker_main — after the check and scope assertion but before transition_to(job, final_state, ...) persists the terminal record. The job is left non-terminal forever and the delegate's actual, possibly successful, work is never surfaced. check.py and scope.py's assert_scope both guard this correctly; verify.py misses this one spot.
Fix: move mkstemp inside the try (or wrap it to degrade to non-structured mode), and add a test simulating mkstemp failure. Extracting the schema tempfile into a small context manager fixes this and the function-length note below at once.
HIGH — credential scrubbing covers the job path but not the synchronous dispatch path
credentials.py is wired into worker.py:build_advisor_env, but cli.py:115 _run_advisor calls runner_mod.run(cmd, cwd=cwd, consumer=parser, max_runtime_seconds=None) with no env=, so that subprocess inherits the full unscrubbed os.environ. The --pass-env opt-out is only registered under if subcommand == "start":, so this path has no escape hatch either — and no test covers it.
Impact: crossagent --agent codex --prompt ... — the original, default invocation mode — passes every credential-bearing variable to the advisor. The PR title says "credential withholding for delegates", not "for delegated jobs", so the security claim is currently broader than the implementation.
Fix: scrub in _run_advisor/_dispatch with the same --pass-env escape hatch, or narrow the PR/doc claim to job-based delegation only.
MEDIUM
verify.py—objas a variable/parameter name (_verdict_from_object(obj),obj = _parse_verdict_object(...)) is a forbidden placeholder per the project's naming rules. Rename toverdict_object.maybe_escalate(~84 lines) andrun_verification(~85 lines) exceed the 50-line guideline. Each has a clean extraction: child-Job construction/persist, and the schema tempfile context manager.jobs.py(935→1061) andcli.py(914→995) were already over the 800-line max and grew. Not new, butScopeResultDict/VerifyResultDict/ScopeStatus/VerifyVerdictlive injobs.pyonly becausescope.py/verify.pyimport them back — defining them in their own modules would stop growing the oversized file.escalate.py's "never raises" is also partial:create_job_dir,prompt_path.write_text, andsave_stateare unguarded againstOSError. Same root cause as the verify finding, lower likelihood.
Confirmed sound
delegation_verdict's S4/S5 gate composition (scope veto → check → verify, any-pass tracking, fail-closed on undetermined) is correct and pre-S4/S5 behaviour is preserved by dedicated tests. scope.py's resolved-path matching defeats ../symlink escape. credentials.py's scrub/withhold predicate agreement is enforced by test so the two can never disagree. No hardcoded secrets, no shell injection (git always argv, shell=False), no Any/bare-dict violations, no 3.10+-only syntax, zero runtime dependencies preserved. Tests are behaviour-focused and cover failure paths well — the gaps above are precisely the paths not yet covered.
Reviewed by Claude Code.
maybe_escalate gated only on delegation_verdict != "failed", but that verdict is "failed" for ANY non-success terminal status, so a CANCELLED job (explicit user intent to stop) or a TIMED_OUT job was silently re-dispatched to a larger, costlier peer. Gate on JobState.SUCCEEDED first so only a declared-gate failure (check / scope / verify) escalates; a delegate that did not finish cleanly does not. TIMED_OUT does not escalate: it produced no graded artifact and a bigger, slower peer is at least as likely to time out again. Also guard the child-staging writes (create_job_dir, prompt/command writes, save_state) against OSError so the module's "never raises" contract holds on a read-only or full disk.
tempfile.mkstemp() ran before run_verification's try block, so a read-only /tmp, a full disk, or a restricted TMPDIR raised OSError out of the verifier — which both docstrings promise never happens — past the worker's unguarded caller, leaving the job non-terminal forever after the check and scope gates already ran and destroying the delegate's real work. Extract the schema file into a _schema_file context manager that degrades to non-structured mode when the temp file cannot be created and always unlinks on exit, so verification never propagates OSError. This also trims run_verification back under the 50-line guideline.
Credential scrubbing was wired into the durable-job worker but not the default
`crossagent --agent ... --prompt ...` invocation: _run_advisor ran the advisor
subprocess with no env=, so it inherited the caller's full unscrubbed
os.environ, and --pass-env was registered only for `start`. The security claim
("credential withholding for delegates") therefore exceeded the implementation
for the original dispatch mode.
Scrub via the same credentials.scrub_env helper the worker uses (one shared
policy, no drift) and add the --pass-env escape hatch to the foreground parser.
…isk staging from maybe_escalate
Relocates CheckResultDict/ScopeResultDict/ScopeStatus/VerifyResultDict/VerifyVerdict out of jobs.py into a new dependency-free types.py. This removes the inverted back-import where the gate producers (check/scope/verify) imported their own persisted-record shapes from their consumer (jobs). jobs re-exports the three it uses as Job field annotations for jobs_mod.* callers (worker). Behaviour unchanged; 513 tests green.
Summary
Delegation grants a peer agent write authority that second-opinion never had. This adds
the guardrails that authority needs: an allowlist the delegate's writes are checked against,
and credential withholding so secrets never reach the child process.
This isn't hypothetical. Aider issue #5058 — "Architect mode can turn README prompt
injection into committed backdoored code" — documents the same two-tier delegation pattern
carrying untrusted repository text all the way to a commit. Published work also finds
monitor agents approve injected-instruction-following code markedly more often when they
perceive it as their own. A delegate is best modelled as a partially-untrusted actor
operating on your repo.
Diff-scope assertion
A baseline of the working tree is captured before the delegate runs (including content
hashes), and compared after, so a tree that was already dirty isn't misattributed to the
delegate. Writes landing outside the declared allowlist fail the delegation and the
offending paths are reported.
Matching resolves both sides to real locations before comparing, so neither a
..segment nor a symlinked directory can make an out-of-scope write appear in scope. Comparison
uses
Path.relative_torather than a string prefix test, so/a/bcdoes not match adeclared root of
/a/b.Failing closed is the point
A scope check that quietly passes when it cannot determine what changed is worse than no
check — it manufactures assurance. So "couldn't determine" is a distinct
undeterminedstatus, never
ok, raised when:git statusfails while evaluating post-run changes;changes can no longer be attributed, so the result is
undeterminedrather than a pass.delegation_verdict()treats any non-okscope status asfailed, so bothviolatedand
undeterminedblock the green path.Why no fifth verdict state
Scope folds into the existing
failedrather than adding a state: work that wrote outsideits allowlist — or whose adherence can't be established — is not trustworthy work, which is
semantically a failed delegation. The pre-existing four-state contract and its tests are
unchanged.
Credential withholding
Credential-bearing environment variables are withheld from the delegate and must not appear
in
events.jsonl, the redacted command, or any job record field. The variable name isrecorded (
withheld_env) so the behaviour is auditable; the value never is.Name matching is deliberately curated rather than aggressive:
ACCESS_KEYis matched soAWS_ACCESS_KEY_IDis caught, but a bareKEYis not listed because it would also matchKEYBOARD_LAYOUT. Over-matching would silently strip benign environment from delegates andproduce baffling, hard-to-diagnose failures.
Scope enforcement is opt-in — read this before assuming coverage
When no scope is declared,
scope_resultisNoneand the gate is skipped entirely.An allowlist nobody wrote cannot be enforced, and denying all writes by default would break
every existing invocation. But it does mean a caller who omits the flag gets none of this
protection. Absent remains distinguishable from satisfied on the record (
Nonevs a dictwith
status: "ok"), so the state is honest rather than hidden.Interaction with the analytics merged in #18
analytics.pycomputes both the verdict distribution and the escalation-rate denominatorfrom
delegation_verdict(), so:code in this PR — both features route through one verdict authority.
OR undetermined scope), how many were re-dispatched." It mixes two failure kinds; this
needs stating in user-facing docs.
Test plan
python3 -m pytest -q→ 453 passed (377 at this branch point, +47 here, +29 from thefeat(dashboard): analytics rollups, delegation history, and trace_mismatch cue #18 merge). Rebased onto current
main; no conflicts.python3 -m ruff check .andruff format --check .→ both green, no unrelated filestouched.
edits pass;
..and symlink escapes don't defeat the matcher; a pre-existing dirty treeisn't attributed to the delegate; a non-git cwd yields
undeterminedrather than a pass;no credential value reaches
events.jsonlor the redacted command;schema_version1/2records still load.
worker_mainand reloading statefrom disk — not just direct calls to the state-transition helper. An earlier slice in
this series had 321 green unit tests while runtime persistence was entirely broken, so
that distinction is load-bearing rather than ceremonial.
Known gaps
unscoped would be a reasonable follow-up.
.gitignored paths inside a repo are invisible tothe assertion. Worth documenting rather than implying the check is exhaustive.