Repository navigation
fix(permissions): prevent a corrupt rules file from running permanently blocked commands - #72
EddieMurphy89 wants to merge 2 commits into
Conversation
…ly blocked commands
|
Scope This PR has stayed focused: 3 files, the fail-closed behavior change plus its regression test. No extra hardening was added in response to review comments. The only pending items are workflow approval and review. |
|
The fail-closed behavior looks good. I found two things to address before merging:
I checked the PR files in an isolated copy alongside the existing feature tests; the focused run finished with 114 passed and these 2 tests failing. |
|
Thanks for the review and for reproducing both issues. I pushed
|
Summary
In a
--yesorauto_for_mesession,apodexrelies on the persisted deny list in~/.config/apodex/permissions.jsonas the last control that stops a command the user permanently blocked. If that file exists but cannot be read or parsed,PermissionStore.load()currently catches the error and returns an empty store, so a saved deny rule can silently disappear and the previously blocked command can run unattended.This PR makes the read fail closed and the write atomic:
FileNotFoundError(first run) still yields an empty policy.load_error;denies()returnsTrueand the tool gate returnsRISK_DENYwith a clear reason instead of falling back to an empty policy.save()writes to a temporary file, fsyncs, andos.replaces it, so an interrupted write leaves the previous file intact instead of truncating it.Impact
What the bypass gives the attacker
--yesorauto_for_mesession, a corrupted rule file turns the user's permanent "never" into "run it unattended", with the user's own rights.denyaccepts command prefixes and bare tool names (_name_matches), and the catalogue includesbash,write_file,file_editor,run_python_code,web_fetch/download_file.curl/ssh/git push-class commands let an agent steered by repository content or a fetched page send what it can read (source,.env, tokens) to an external host, and an unbannedwrite_filereaches build scripts and CI configuration.auto_for_meis the CLI's stock toggle for Docker/trusted environments; it makesassess_with_rules()returnRISK_SAFEfor every call that no saved deny rule rejects, so those deployments are in exactly the same state as a--yesrun.plan_modefromsettings.json, the "no edits until I approve a plan" gate:write_filethen executes and writes the file. This PR keeps that settings change as a follow-up, but the impact is the same class of lost security state.Affected versions
Tested on
mainatcc32d880ff(2026-11-03),pyproject.tomlversion0.1.0. The repository has no release tag; the vulnerable code is still present on the currentmainas of this writing.Reproduction
The decision layer can be checked without a model or a full session:
Before this PR: the fault prints
Falseandconfirm; under--yesthe confirm is auto-approved. After this PR: it printsTrueanddeny, with the reason "saved permission rules could not be read; refusing to run with an unknown policy".A full CLI-level reproduction is the same sequence with the real binary: write a deny rule, truncate
~/.config/apodex/permissions.json, startapodex --yes, and request the denied command. The control run blocks; the fault run executes before the patch and blocks after it.Fix
apodex/permissions.py:304-319—load()distinguishes missing from unreadable/invalid;load_erroris recorded for the latter;denies()fails closed andallows()returnsFalsein that state.save()now uses a temporary file +fsync+os.replace, creating the temporary file with0600(and preserving existing owner-only bits) so the atomic replace does not widen permissions.apodex/agent_tools.py:440—assess_with_rules()checksload_errorbefore the normal rule lookup and returnsRISK_DENY.apodex/tests/test_permissions.py— new regression tests for missing/corrupt/wrong-shape/unreadable files, the gate decision, and the atomic-save failure path.The same loader shape also exists in
UserSettings.load()forsettings.json(plan_mode); this PR deliberately keeps the change focused on the permission store, and I can split the settings change into a follow-up if you prefer.Testing
pytest apodex/tests/test_permissions.py -q— 10 passed (including restrictive-mode preservation)mainand pass with the patch.