fix(security): close the untrusted-repo execution boundary (#905) - #994
Conversation
|
I'll analyze this and get back to you. |
|
Claude finished @frankbria's task in 7m 4s —— View job Bug-hunting review completeScoped to concrete defects only (logic, security, data-loss, contracts); style/coverage left to the general review.
Result: 1 minor finding.
The four core fixes are sound and verified against callers: the trust gate is keyed on the resolved workspace + sha256 of the exact command set (edit revokes approval), the store lives outside the repo, Summary comment posted inline + via |
GLM bug-hunting reviewScoped to concrete defects only (logic, security, data-loss, contracts). Style/coverage are covered by the general review.
Everything else checks out: the trust gate is keyed on |
|
Fixed the XDG finding in 013062c. Confirmed real: The branch now pops all three alongside |
|
Claude finished @frankbria's task in 3m 33s —— View job GLM bug-hunting reviewScoped to concrete defects only (logic, security, data-loss, contracts). Style/coverage are covered by the general review.
✅ GLM review: no defects found (re-review of the synchronized code after The one finding from the prior pass — the Re-verified the four core fixes against their callers; nothing new surfaced:
No inline comments — nothing met the concrete-defect bar. |
|
Claude finished @frankbria's task in 5m 7s —— View job Bug-hunting review (GLM, pass 3 — post-synchronize)Scoped to concrete defects only (logic, security, data-loss, contracts). Style/coverage left to the general review.
✅ GLM review: no defects found. The prior finding (XDG fail-closed on the
No inline comments — nothing met the concrete-defect bar. |
Cross-family adversarial review (codex) — 5 findings triagedFixed (2) — both were sibling callers of the bug I patched, the same shape as the original:
Both now go through a new leaf module Worth recording: my first two tests for these were tautological and I caught it by mutation-checking. The plan-engine one used Accepted as known, already documented (1)
Deferred to their own issues (2) — both real, both out of scope for this PR:
Both are in Known Limitations in the PR body. |

Closes #905.
Problem
Cloning an untrusted repository and running any
cfcommand was equivalent to running its code, and a prompt-injected agent could read the credential store.Four distinct doors:
run_hookexecutes hook strings withshell=True, and those strings come from files a repository can commit.cf initfiresafter_initimmediately;cf work startfiresbefore_task/after_task_*.CODEFRAME.mdfallback could supplyhooks:— and that file is located by walking up from the workspace, so even a parent directory could inject commands.render_hook_command'sshlex.quotewas not the protection it claimed. Single quotes are inert inside a double-quoted template, soecho "{{ task_title }}"with a title of$(id)rendered toecho "'$(id)'"— and the shell ran the substitution.run_commandpassed the operator'sHOME. The [P0.10] Do not pass the operator's full secret environment into LLM-drivenrun_command#721 allowlist kept secrets out of the environment, butHOMEis a pointer to them:~/.codeframeholds the credential store, whose Fernet key is machine-id-derived unlessCODEFRAME_CREDENTIAL_SECRETis set.Fix
Trust gate (
codeframe/core/hook_trust.py, new). A hook runs only if the operator recorded a decision for these exact commands in this workspace. The record lives in~/.codeframe/trusted_hooks.json— outside the repo tree, so a repo cannot grant itself trust by committing the file — and is keyed by a sha256 of the commands, so editing a hook revokes the old approval instead of inheriting it. Enforced inexecute_hook, the single point every hook execution passes through (CLI, runtime, batch, server). A refusal reuses the existing failure path, so each caller's abort/warn handling applies unchanged.cf hooks trustrecords the decision, printing the exact commands first.cf hooks shownow reports the trust state — a configured-but-untrusted hook silently never running would be worse than the vulnerability.cf hooks set/clearrecord trust themselves: those are operator actions on this machine, and the fingerprint is command-keyed, so otherwise setting your own hook would immediately refuse to run it.cf init --allow-hooks/CODEFRAME_ALLOW_HOOKS=1is the non-interactive opt-in.CODEFRAME.mdno longer supplies hooks at all (core/config.py). It still supplies every other setting; a declaredhooks:block is ignored with a warning.Context values leave the command text (
core/hooks.py). The template now renders"${CF_HOOK_TASK_TITLE}"references, and the values travel in the subprocess environment. A parameter expansion is safe quoted and unquoted, because the shell does not rescan an expansion's result for command substitution — so$(id)stays four characters either way.Sandboxed
HOME(core/agent_env.py, new leaf module).build_agent_env()is the single place the allowlist and the sandbox are built:HOMEandXDG_*point at<workspace>/.codeframe/agent-home— a real, writable directory so npm/pip/cargo/git still work. If it cannot be created, all four are dropped rather than falling back to the operator's.Three callers converge on it, because patching only the one the issue named would have left the siblings open (all three found in review):
run_command(ReAct)HOMErun_tests(ReAct)env=at all —npm testruns a repo-committedpackage.jsonscriptExecutor._execute_shell_command(legacy--engine plan)env=at all, on both theshell=Trueand argv branchesDefense in depth (
core/dangerous_commands.py): commands naming.codeframe/credentialsare refused, catching the absolute path an agent can still discover vials /home.Tests
tests/core/test_untrusted_repo_execution_905.py— 19 tests. The hook tests assert against a canary file, so they prove the command never ran rather than that we printed a refusal. Two end-to-end tests runcf initagainst a repo carrying a hostile hooks block (one viaCODEFRAME.md, one via.codeframe/config.yaml) and assert the canary is absent.Mutation-checked: reverting each of the four fixes independently fails 12 tests.
Known limitations
cf hooks trust. This is the intended fail-closed behavior, but it is a behavior change for anyone already using hooks — including via the web UI, which has no trust UI yet (CLI/env only).HOMEsandbox is not containment. It closes~,$HOMEand theXDG_*paths; the credential-store pattern catches the obvious absolute path. A deliberately obfuscated path still reaches the store — only OS-level isolation (worktree/E2B/container) actually contains a hostile shell command.cf review/cf proof rungates still run repo-controlled scripts with the operator's environment (core/gates.py) —npm test,npm run build,type-check, plustest_command/lint_commandfrom.codeframe/config.yaml. Same shape as therun_testsfix and now a one-line change each, but it alters a user-invoked command's environment (a project whose suite legitimately needs~/.npmrcor~/.awswould break), so it belongs in its own issue rather than riding along in a security fix. Filed as Gates and proof runs execute repo-controlled scripts with the operator's environment #995.core/adapters/subprocess_adapter.py,adapters/codex.py).--engine claude-code/--engine codexspawn a CLI that legitimately needs provider credentials, so sanitizing them is a design question, not a one-liner. Filed as [P0.25] Delegated agent adapters inherit the operator's full environment and HOME #996.