Repository navigation
fix(permissions): prevent a corrupt rules file from running permanently blocked commands - #72
Open
EddieMurphy89 wants to merge 1 commit into
Open
EddieMurphy89 wants to merge 1 commit into
EddieMurphy89 wants to merge 1 commit into
Conversation
…ly blocked commands
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
The lost control is the user's entire persisted permission policy, not one command. The deny list accepts command prefixes and bare tool names (
bash,write_file,file_editor,run_python_code,web_fetch/download_file), so a single unreadable or truncatedpermissions.jsoncan remove every saved "never" at once. In a--yesor stockauto_for_mesession — the CLI's auto-approve mode for Docker/trusted environments —assess_with_rules()then returnsRISK_SAFEfor any call no remaining rule rejects, and the tool runs with the user's own privileges and no prompt.A deployment that implements a read-only or offline posture by denying those tools loses that posture in one step: files become writable, network tools usable, and code execution available. Because the rules are re-read at every session start, the widened surface persists for every later session until someone rewrites the file by hand; nothing in the UI or logs reports the loss, and the rules do not come back on their own. Restored
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.The same
except Exceptionloader shape also coverssettings.json'splan_mode, so a corrupted settings file can silently drop the "no edits until I approve a plan" gate and let a mutating tool such aswrite_fileexecute. This PR keeps the 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.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
uv run pytest apodex/tests/test_permissions.py -q— 9 passedmainand pass with the patch.