fix(security): D-5 + D-14 + D-16 — permission_gate realpath + blocklist segment-aware#47
Merged
Merged
Conversation
…5+D-14+D-16)
Closes three Phase 1 Audit D findings in one cohesive change to the
Step 9 agent runtime:
D-5 CRITICAL permission_gate accepts path-traversal via fnmatch
D-14 MEDIUM _PATH_BLOCKLIST_SUBSTRINGS misses ~/.codec/skills + plugins
D-16 MEDIUM blocklist substring match is anchorless
All three are auto-extract / runtime paths that an LLM-drafted plan
could use to chain into D-1 RCE. Each layer closes independently.
codec_agent_runner.py — permission_gate refactor (D-5):
- New helper _path_allowed(action_path, grants) → (bool, reason):
* reject `..` segments outright (closes the dotdot bypass that
fnmatch glob-matched against grants like ~/Documents/**)
* os.path.realpath both sides — symlink-out-of-grant rejected
* fnmatch replaced with action_real.startswith(grant_real + os.sep)
Trade-off: a grant like ~/Documents/*.md now accepts any file
under realpath(~/Documents/) — safety > granularity per audit.
- New helper _emit_gate_blocked(action_path, reason, agent_id):
emits `permission_gate_blocked` audit event before raising
PermissionViolation. source=codec-agent-runner, outcome=error,
level=warning, extra={requested_path, resolved_path, reason,
agent_id}. Forensic visibility per D-5 closure §3.
- Removed unused `import fnmatch` (no other callers in the module).
codec_agent_plan.py — blocklist extension + segment-aware (D-14+D-16):
- _PATH_BLOCKLIST_SUBSTRINGS extended with 8 new entries (D-14):
/.codec/skills /.codec/plugins
/.codec/oauth_state.json /.codec/audit.log
/.codec/agents /.codec/agent_global_grants.json
/.codec/config.json /.codec/memory.db
- New helper _path_segments_match(path, pattern) does segment-aware
matching (D-16): splits both `path` (after expanduser+normpath) and
`pattern` on `/`, requires consecutive subsequence of segments.
`~/Documents/notes_ssh/foo.md` no longer matches `/.ssh` (segment
is `notes_ssh`, not `.ssh`), but `~/.ssh/config` still does.
- New helper _is_path_blocklisted(path) walks the full blocklist.
- extract_user_paths now calls _is_path_blocklisted instead of the
raw substring `any(b in raw for b in _PATH_BLOCKLIST_SUBSTRINGS)`.
Tests:
- tests/test_agent_runner.py +5 new (143 LOC):
test_permission_gate_rejects_path_traversal_dotdot
test_permission_gate_rejects_read_path_traversal
test_permission_gate_rejects_symlink_outside_grant
test_permission_gate_accepts_realpath_within_grant
test_permission_gate_emits_blocked_audit_event
- tests/test_agent_plan.py +10 new (80 LOC, 7 parametrized + 3 misc):
test_extract_user_paths_blocks_sensitive_codec_dirs (×7)
test_extract_user_paths_segment_aware_not_false_positive
test_extract_user_paths_still_blocks_real_ssh_path
test_extract_user_paths_allows_legitimate_user_paths
Verification:
- pytest tests/test_agent_runner.py tests/test_agent_plan.py
tests/test_file_write.py tests/test_skill_routes.py
tests/test_skill_registry.py tests/test_skill_contracts.py
→ 148 passed (full Wave 1 regression + new)
- python3 tests/test_skill_imports.py → 76 parsed, 0 errors
- python3 tools/generate_skill_manifest.py --check → ok
- ruff check codec_agent_runner.py codec_agent_plan.py
→ 5 errors in codec_agent_runner.py + 22 in codec_agent_plan.py,
all pre-existing on main (F401 unused imports `field`, `time`,
E402 inline imports for regex, F541 f-string-without-placeholder,
F821 List/Tuple undefined). The one new error introduced —
`fnmatch` no longer used in codec_agent_runner.py — has been
cleaned up (import removed).
Docs:
- AGENTS.md §7 new "Agent permission gate + path blocklist
(Phase 1 Wave 1, PR-1D — closes D-5 + D-14 + D-16)" subsection
documenting the four-layer defense (PR-1A + PR-1C + PR-1D blocklist
+ PR-1D runtime gate) against the D-1 RCE chain.
- docs/audits/PHASE-1-SECURITY.md: closure footnotes on D-5, D-14,
D-16.
- docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md: D-5 row flipped to
W1 — CLOSED (PR-1D). D-14 and D-16 are MEDIUM (not in the
CRITICAL findings table) but their D-section footnotes carry the
closure trail.
Wave 1 status after this PR merges:
- D-1 ✅ closed (PR-1A, #42, 48ec5d5)
- D-2 ✅ closed (PR-1B, #43, ff16664)
- D-3 ✅ closed (PR-1B, #43, ff16664)
- D-4 ✅ closed (PR-1C, #45, 0065d90)
- D-5 ✅ closed (this PR)
- D-14 ✅ closed (this PR — bonus)
- D-16 ✅ closed (this PR — bonus)
All five CRITICAL skill-loading + write-path findings are closed.
Sample audit line emitted on a blocked traversal attempt:
{"ts": "2026-05-17T...Z",
"schema": 1,
"event": "permission_gate_blocked",
"source": "codec-agent-runner",
"outcome": "error",
"level": "warning",
"message": "permission_gate refused '~/Documents/../../etc/passwd': path_traversal",
"extra": {
"requested_path": "~/Documents/../../etc/passwd",
"resolved_path": "/etc/passwd",
"reason": "path_traversal",
"agent_id": ""
}}
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
57cbd37 to
96d5924
Compare
AVADSA25
added a commit
that referenced
this pull request
May 17, 2026
#48) PR-1D (#47) merged to main as squash commit fd2b460. Update the closure footnotes for D-5, D-14, and D-16 in docs/audits/PHASE-1-SECURITY.md plus the D-5 row in docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md, replacing the branch-name placeholders with PR number + commit hash. Mirrors the citation style applied to: D-1 PR-1A #42 → 48ec5d5 D-2/3 PR-1B #43 → ff16664 D-4 PR-1C #45 → 0065d90 D-5 PR-1D #47 → fd2b460 (this commit) After this lands, Wave 1 is fully closed with complete citation trails. All five CRITICAL skill-loading + write-path findings (D-1, D-2, D-3, D-4, D-5) plus the two bonus mediums (D-14, D-16) carry merge commit hashes in their audit-doc footnotes. Co-authored-by: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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.
What
Phase 1 Wave 1 — PR-1D. The final Wave 1 PR. Closes three findings in one cohesive change to the Step 9 agent runtime:
permission_gateaccepts path-traversal via fnmatch_PATH_BLOCKLIST_SUBSTRINGSmisses~/.codec/skillsand~/.codec/pluginsAll three are auto-extract / runtime paths an LLM-drafted plan could use to chain into the D-1 RCE. Each layer closes independently.
How
codec_agent_runner.py—permission_gaterefactor (D-5)Before:
fnmatch.fnmatch(os.path.expanduser(action.path), os.path.expanduser(grant)).expanduserresolves~but NOT..or symlinks. So~/Documents/../../etc/passwdglob-matched~/Documents/**→ action allowed.After: new helper
_path_allowed(action_path, grants) → (allowed, reason):..segments outright (if ".." in Path(expanded).parts: return False, "path_traversal")os.path.realpaththe action — symlinks resolvedos.path.realpaththe directory root →action_real.startswith(grant_real + os.sep)Trade-off: a grant like
~/Documents/*.mdnow accepts any file under realpath(~/Documents/), not just*.md. Safety > granularity per the audit's explicit recommendation.Plus: new helper
_emit_gate_blockedemitspermission_gate_blockedaudit event (source=codec-agent-runner, outcome=error, level=warning, extra={requested_path,resolved_path,reason,agent_id}) before raisingPermissionViolation. Complementary to the existingagent_blocked_on_permissionevent from_run_agent.codec_agent_plan.py— blocklist extension + segment-aware (D-14 + D-16)Extended
_PATH_BLOCKLIST_SUBSTRINGSwith 8 new entries:/.codec/skills,/.codec/plugins,/.codec/oauth_state.json,/.codec/audit.log,/.codec/agents,/.codec/agent_global_grants.json,/.codec/config.json,/.codec/memory.db. The LLM-drafted plan auto-extract path now drops any user-typed path landing in these directories.Segment-aware matching (D-16): new helper
_path_segments_match(path, pattern)checks whether the pattern's/-separated segments appear as a CONSECUTIVE SUBSEQUENCE of the path's segments.pathisexpanduser+normpath-ed first to collapse..and..~/Documents/notes_ssh/matches/.ssh❌ false positivenotes_ssh, not.ssh) ✅~/.ssh/configmatches/.ssh✅.ssh) ✅/etc/passwdmatches/etc/✅/Library/Application Support/com.apple/xmatchesTests
+15 new tests (TDD: RED → GREEN):
tests/test_agent_runner.py(+5):test_permission_gate_rejects_path_traversal_dotdottest_permission_gate_rejects_read_path_traversaltest_permission_gate_rejects_symlink_outside_grant(real symlink intmp_path)test_permission_gate_accepts_realpath_within_granttest_permission_gate_emits_blocked_audit_eventtests/test_agent_plan.py(+10):test_extract_user_paths_blocks_sensitive_codec_dirs(parametrized over the 7 new sensitive paths)test_extract_user_paths_segment_aware_not_false_positivetest_extract_user_paths_still_blocks_real_ssh_pathtest_extract_user_paths_allows_legitimate_user_pathsSample audit emission
{"event": "permission_gate_blocked", "source": "codec-agent-runner", "outcome": "error", "level": "warning", "message": "permission_gate refused '~/Documents/../../etc/passwd': path_traversal", "extra": {"requested_path": "~/Documents/../../etc/passwd", "resolved_path": "/etc/passwd", "reason": "path_traversal", "agent_id": ""}}Files
codec_agent_runner.pypermission_gate, new_path_allowed+_emit_gate_blockedhelpers; removed unusedfnmatchimport. +96/−9.codec_agent_plan.py_PATH_BLOCKLIST_SUBSTRINGS(+8 entries), new_path_segments_match+_is_path_blocklistedhelpers. +55/−2.tests/test_agent_runner.pytests/test_agent_plan.pyAGENTS.md§7docs/audits/PHASE-1-SECURITY.mddocs/audits/PHASE-1-CONSOLIDATED-TRIAGE.mdW1 — CLOSED (PR-1D).Diff: 7 files, +387/−12.
Verification
pytest tests/test_agent_runner.py tests/test_agent_plan.pypython3 tests/test_skill_imports.pypython3 tools/generate_skill_manifest.py --checkruff check codec_agent_runner.py codec_agent_plan.pyWave 1 status after this PR merges
48ec5d5) ✅ff16664) ✅ff16664) ✅0065d90) ✅All 5 CRITICAL skill-loading + write-path findings will be closed. Wave 1 complete.
Test plan
pytest tests/test_agent_runner.py tests/test_agent_plan.py -v— 15 new pass, 49 pre-existing still greenruff check— no new errors~/Documents/../../etc/passwdand grant~/Documents/**→ PermissionViolation~/.codec/audit.logforpermission_gate_blockedentry with reason=path_traversal🤖 Generated with Claude Code