Skip to content

fix(security): D-1 load-time AST check in SkillRegistry.load + trusted-skill manifest#42

Merged
AVADSA25 merged 2 commits into
mainfrom
fix/pr1a-skill-load-ast-check
May 17, 2026
Merged

fix(security): D-1 load-time AST check in SkillRegistry.load + trusted-skill manifest#42
AVADSA25 merged 2 commits into
mainfrom
fix/pr1a-skill-load-ast-check

Conversation

@AVADSA25
Copy link
Copy Markdown
Owner

What

Phase 1 Wave 1 — PR-1A. Closes D-1 (CRITICAL) — see docs/audits/PHASE-1-SECURITY.md and the consolidated triage.

Before this PR, SkillRegistry.load called spec.loader.exec_module(mod) on any .py file in the skills directory with no load-time validationis_dangerous_skill_code only ran at write gates (/api/save_skill, /api/skill/approve, codec_self_improve._validate). A file written outside those gates loaded with full process privileges. That made "drop a file in ~/.codec/skills/, wait for restart" a full RCE primitive reachable from four independent paths (D-2 /api/forge, D-3 /api/save_skill, D-4 MCP-HTTP-exposed file_write, D-5 Step 9 permission_gate traversal).

How

Two-stage gate runs BEFORE exec_module (codec_skill_registry.py SkillRegistry.load):

  1. Trusted manifest. <skills_dir>/.manifest.json maps each approved built-in to sha256(file-bytes). If the file's hash matches an entry, the skill is hash-pinned-trusted and loads. This is what lets the 23 legitimately-dangerous built-ins (calculator, system, file_write, pilot, password_generator, health_check, ...) continue to work — their source IS dangerous, their hash IS approved at PR-review time.
  2. AST safety gate. Files NOT in the manifest run through codec_config.is_dangerous_skill_code. Any dangerous pattern → refuse → emit skill_load_blocked audit event → load() returns None. Fail-safe on any error inside the check (validator raised, file unreadable, UTF-8 decode failure).

Why manifest, not just AST-on-everything

The original PR-1A plan said "run is_dangerous_skill_code on every load." During TDD I confirmed this would break 23 of CODEC's 76 built-in skills because they legitimately use subprocess, os.system, eval, shutil.rmtree, etc. Per the plan's failure-mode section, I stopped and asked for direction. Mickael chose Option B (manifest of hashes). The manifest pins those 23 (and all other built-ins) at their reviewed hashes; any swap or replacement by an attacker invalidates the hash and re-engages the AST gate. Closes D-1 cleanly without false positives.

Before / after SkillRegistry.load

Before: if name in cache → return; read path → exec_module → cache → return. Zero validation. Any .py file in the skills dir gets executed with full process privileges on first load.

After: if name in cache → return; read bytes → sha256 hash → if hash ∈ trusted_manifest exec_module (path A); else decode UTF-8 → is_dangerous_skill_code → if dangerous refuse + emit skill_load_blocked audit + return None (path B); else exec_module (path C). Three fail-safe exit points (read fail / decode fail / validator exception) all return None. Cache-hit short-circuits both checks for performance + idempotence.

Tests

+13 new tests in tests/test_skill_registry.py (TDD: written first, watched fail, then implemented). Coverage:

  • 4× refuses dangerous patterns (subprocess import, eval(), os.system(), __import__())
  • 1× accepts safe skill
  • 1× handles unreadable file
  • 1× AST-validator exception fails safe (refuses, doesn't fall through to exec_module)
  • 1× block event includes full metadata (skill_name, skill_path, reason, file_sha256)
  • 1× cached module skips re-check (perf + idempotence)
  • 1× trusted-manifest bypass (skill with dangerous source loads because hash matches)
  • 1× hash mismatch still refused (manifest claims a different hash → file is NOT trusted → AST runs → refused)
  • 1× corrupted manifest falls back to AST check (treat as empty trusted set)
  • 1× no manifest at all → AST check applies (the typical ~/.codec/skills/ case)

Sample audit log entry

Hand-verified by dropping a synthetic malicious skill (import subprocess; subprocess.run([...]).read('/etc/passwd')) into a temp dir with no manifest, calling load(), then grepping ~/.codec/audit.log:

{
  "ts": "2026-05-17T11:12:02.529+00:00",
  "schema": 1,
  "event": "skill_load_blocked",
  "source": "codec-skill-registry",
  "outcome": "error",
  "level": "warning",
  "message": "Refused skill backdoor: Dangerous import: subprocess",
  "extra": {
    "skill_name": "backdoor",
    "skill_path": "/.../tmp/codec-handverify-.../skills/backdoor.py",
    "reason": "Dangerous import: subprocess",
    "file_sha256": "3e738d6243ebc3a04f0b73633c4e0a4d4712dd0b43f25baca0e208529e5cbd1f"
  }
}

is_dangerous_skill_code signature

No change required — it already returns tuple[bool, str] (verified by grep across the 5 existing call sites). Plan step 2 was conditional on its return type; condition didn't apply.

CI changes

Two new steps in .github/workflows/ci.yml:

  • Trusted-skill manifest is current (D-1 gate) — runs python tools/generate_skill_manifest.py --check, fails the build with a diff if a built-in skill was edited without regenerating the manifest.
  • Skill registry load-time AST gate tests (D-1) — runs pytest tests/test_skill_registry.py -v.

Files changed

  • codec_skill_registry.py — two-stage gate + manifest loader
  • tools/generate_skill_manifest.py — new, generates skills/.manifest.json with --write / --check modes
  • skills/.manifest.json — new, sha256 of every approved built-in (74 entries; codec.py + _*.py excluded per tests/test_skill_contracts.py NON_SKILL_FILES)
  • tests/test_skill_registry.py — new test file, 13 tests
  • AGENTS.md §7 — new "Skill load-time safety gate" subsection + contributor workflow
  • docs/audits/PHASE-1-SECURITY.md D-1 — closure footnote
  • docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md — D-1 row status W1W1 — CLOSED (PR-1A)
  • .github/workflows/ci.yml — two new CI steps

This PR includes a prior commit (docs(audits): add Phase 1 audit reports + consolidated triage) that lands the 6 Phase 1 audit documents the closure footnote references. Required because PR-1A is the first PR after the audits.

Regression

The pre-existing test failures on main documented in docs/known-issues.md remain (6 failures, identical to parent). No new regressions — confirmed by stash-and-rerun against the parent commit. The 23 built-in skills that would have broken with naive AST-on-everything are all hash-pinned in skills/.manifest.json and load normally.

Out of scope (later PRs)

  • D-2 /api/forge review-gate routing (PR-1B)
  • D-3 /api/save_skill review-gate routing (PR-1B)
  • D-4 file_write block-roots expansion (PR-1C)
  • D-5 permission_gate realpath + _PATH_BLOCKLIST_SUBSTRINGS (PR-1D)
  • D-17 positive-allowlist for is_dangerous_skill_code AST check (optional PR-1E)
  • Sandbox-exec by default (Wave 2 design discussion)

Merge model

Chat-review-then-merge per Mickael's brief. Not self-merging.

Test plan

  • python3 -m pytest tests/test_skill_registry.py -v — 13 passed locally
  • python3 -m pytest tests/test_skill_contracts.py tests/test_oauth_provider.py tests/test_retry.py — all pass (no regressions in CI-gated tests)
  • python3 tests/test_skill_imports.py — 76 skills parsed, 0 errors
  • python3 tools/generate_skill_manifest.py --check — manifest current
  • ruff check codec_skill_registry.py tests/test_skill_registry.py tools/generate_skill_manifest.py — clean
  • Hand-verify: drop malicious skill → load returns None → audit log entry written
  • Stash-and-rerun against parent commit — same 6 pre-existing failures, no new regressions
  • CI green on PR
  • Mickael chat review

🤖 Generated with Claude Code

Mikarina13 and others added 2 commits May 17, 2026 12:57
5 of 6 Phase 1 audits complete (Code Quality, Reliability, Security,
Apple App, Investor). Audit B (Projects + Pilot) is a placeholder
pending feature description.

Total findings: 103 across the five audits — 20 CRITICAL, 35 HIGH,
34 MEDIUM, ~14 LOW. The chokepoint critical is D-1 (skill registry
lazy-load = RCE for anyone who can drop a .py file in ~/.codec/skills/),
which is closed in the next commit (PR-1A).

Reports:
- docs/audits/PHASE-1-CODE-QUALITY.md
- docs/audits/PHASE-1-RELIABILITY.md
- docs/audits/PHASE-1-SECURITY.md
- docs/audits/PHASE-1-APPLE-APP.md
- docs/audits/PHASE-1-INVESTOR-READINESS.md
- docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md (wave plan + decisions needed)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the chokepoint critical from Phase 1 Audit D (see
docs/audits/PHASE-1-SECURITY.md finding D-1). Before this change,
SkillRegistry.load called spec.loader.exec_module on any .py file in
the skills directory with NO load-time validation — making "drop a file
in ~/.codec/skills/, wait for restart" a full RCE primitive reachable
from four independent paths (D-2 /api/forge, D-3 /api/save_skill,
D-4 file_write skill, D-5 permission_gate path-traversal).

Two-stage gate runs BEFORE exec_module:

  1. Trusted manifest: <skills_dir>/.manifest.json maps each approved
     built-in to sha256(file-bytes). If the file's hash matches, the
     skill is hash-pinned-trusted and loads. This is what lets
     legitimately-dangerous built-ins (calculator, system, file_write,
     pilot, ...) continue to work — their source IS dangerous, their
     hash IS approved at PR-review time.

  2. AST safety gate: files NOT in the manifest run through
     codec_config.is_dangerous_skill_code. Any dangerous pattern
     (subprocess, eval, os.system, __import__, etc.) → refuse +
     emit skill_load_blocked audit event + return None. Fail-safe
     on any error inside the check itself (validator raised, file
     unreadable, UTF-8 decode failure).

Why a manifest, not just AST-on-everything: of CODEC's 76 built-in
skills, 23 legitimately use patterns the AST check flags as dangerous.
Running the AST gate on every load (the original PR-1A plan) would
break ~30% of the product. The manifest pins those 23 (and all other
built-ins) at their reviewed hashes; any swap or replacement by an
attacker invalidates the hash and re-engages the AST gate. Closes
D-1 cleanly without false positives.

Changes:
- codec_skill_registry.py: SkillRegistry.scan() loads manifest into
  self._trusted_hashes; SkillRegistry.load() checks hash then AST.
- tools/generate_skill_manifest.py: generates / verifies
  skills/.manifest.json (sha256 of every eligible built-in source).
  --check mode used by CI to fail on drift.
- skills/.manifest.json: committed manifest for the current 74
  built-in skills (codec.py + _*.py excluded per existing
  test_skill_contracts.py convention).
- tests/test_skill_registry.py: 13 tests covering refusal of dangerous
  patterns (subprocess, eval, os.system, __import__), audit event
  emission with full metadata (skill_name, skill_path, reason,
  file_sha256), safe-skill load path, unreadable-file fail-safe,
  AST-validator-exception fail-safe, cache-hit skips recheck,
  manifest-trusted bypass, hash-mismatch refusal, corrupted manifest
  falls back to AST check, no-manifest treats dir as untrusted.
- AGENTS.md §7: new "Skill load-time safety gate" subsection
  documents both stages, the contributor workflow for manifest
  regeneration, and links back to D-1 in the audit doc.
- docs/audits/PHASE-1-SECURITY.md: closure footnote appended to D-1.
- docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md: D-1 row status flipped
  to "W1 — CLOSED (PR-1A)".
- .github/workflows/ci.yml: added two new CI steps:
  "Trusted-skill manifest is current (D-1 gate)" runs
  tools/generate_skill_manifest.py --check; "Skill registry
  load-time AST gate tests (D-1)" runs the new test file.

Verification:
- All 13 new tests pass (TDD: written first, watched fail, then
  implemented).
- 6 of CODEC's pre-existing test failures (test_full_product_audit
  + test_high_fixes) remain, identical to parent — confirmed by
  stash-and-rerun against the parent commit. No NEW regressions
  introduced by this change.
- Hand-verification: dropped a synthetic malicious skill containing
  `import subprocess` into a temp ~/.codec/skills/-equivalent dir
  with no manifest, called load() — got None back, confirmed audit
  log entry at ~/.codec/audit.log with event=skill_load_blocked,
  outcome=error, level=warning, full extra={skill_name, skill_path,
  reason, file_sha256}.
- ruff check clean on all touched files.

Contributor workflow for adding/editing built-in skills:
  python3 tools/generate_skill_manifest.py --write
  git add skills/.manifest.json
  git commit

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AVADSA25 AVADSA25 merged commit 48ec5d5 into main May 17, 2026
1 check failed
@AVADSA25 AVADSA25 deleted the fix/pr1a-skill-load-ast-check branch May 17, 2026 11:32
AVADSA25 pushed a commit that referenced this pull request May 17, 2026
PR-1A (#42) merged to main as squash commit 48ec5d5. Update the
D-1 closure footnote in docs/audits/PHASE-1-SECURITY.md and the
D-1 row in docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md to reference
the PR number + commit hash, replacing the branch-name placeholder.

This commit lands on the PR-1B branch (fix/pr1b-remove-save-skill-and-forge)
so the citation travels with PR-1B's next merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AVADSA25 pushed a commit that referenced this pull request May 17, 2026
PR-1A (#42) merged to main as squash commit 48ec5d5. Update the
D-1 closure footnote in docs/audits/PHASE-1-SECURITY.md and the
D-1 row in docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md to reference
the PR number + commit hash, replacing the branch-name placeholder.

This commit lands on the PR-1B branch (fix/pr1b-remove-save-skill-and-forge)
so the citation travels with PR-1B's next merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AVADSA25 pushed a commit that referenced this pull request May 17, 2026
CI failure root cause for PR-1A (#42) and PR-1B (#43): the smoke job
on Ubuntu runners failed at test collection because importing
codec_skill_registry → codec_config → `from pynput import keyboard`
raised ImportError on a headless display:

    ImportError: this platform is not supported:
    ('failed to acquire X connection: Bad display name ""')

pynput needs a real display (X11 / AppKit / win32). GitHub Actions
Linux runners are headless. Other modules import codec_config for
its constants and is_dangerous_skill_code — none of them need the
keyboard subsystem.

Fix: wrap the pynput import in try/except so codec_config is import-
safe in headless contexts. The two `getattr(keyboard.Key, ...)` call
sites in `_resolve_key` now early-return None when keyboard is None
(the resulting KEY_TOGGLE / KEY_VOICE / KEY_TEXT module constants
are None on headless systems — only matters if the live keyboard
listener actually runs, which it doesn't in CI).

This was triggered by the new PR-1A test
`tests/test_skill_registry.py` which CI now runs per the new
`Skill registry load-time AST gate tests (D-1)` step. Before PR-1A,
no CI-gated test imported codec_config, so the pynput crash was
latent. Production paths (codec.py daemon, dashboard, etc.) all
run on macOS where pynput imports fine.

Verified locally:
- pytest tests/test_skill_routes.py tests/test_skill_registry.py
  tests/test_skill_contracts.py tests/test_oauth_provider.py
  tests/test_retry.py → 28 passed
- python3 tests/test_skill_imports.py → 76 skills parsed, 0 errors
- python3 tools/generate_skill_manifest.py --check → ok
- Headless simulation: monkey-patch builtins.__import__ to raise on
  pynput, then `from codec_config import is_dangerous_skill_code` →
  succeeds, returns (True, 'Dangerous import: subprocess') correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AVADSA25 added a commit that referenced this pull request May 17, 2026
* fix(security): remove /api/save_skill and /api/forge endpoints (D-2 + D-3)

Closes the two write-path criticals from Phase 1 Audit D (see
docs/audits/PHASE-1-SECURITY.md):

  D-2 /api/forge       — URL fetch → LLM → write skill, no review gate
  D-3 /api/save_skill  — direct write with only a substring blocker

Both endpoints are removed entirely. Skill creation now routes
exclusively through /api/skill/review → /api/skill/approve (the
human-review-and-approve flow). The URL-fetch capability is
intentionally dropped per Mickael decision Q1 — anyone wanting to
import skill code from a URL pastes the source into the editor and
goes through the review-and-approve flow like any other skill.

Defense in depth with PR-1A: even if a malicious file reaches
~/.codec/skills/ via some other path, SkillRegistry.load refuses
it at load time via the manifest + AST gate.

Live proof of D-2: the initial RED-phase test_post_forge_returns_404
actually called the live LLM, fetched example.com, AND wrote a real
fetch_example_domain_html.py to skills/ before the endpoint was
removed. The endpoint was a working SSRF + RCE-write primitive on
this machine right up to the moment it was deleted.

Changes:

Code:
- routes/skills.py:
    * delete async def save_skill (D-3 handler)
    * delete async def forge_skill (D-2 handler)
    * module docstring updated to reflect the review-and-approve-only
      contract; unused imports (DASHBOARD_DIR, CONFIG_PATH) dropped;
      import style PEP8'd
- codec_vibe.html:
    * remove the entire Forge Modal (HTML markup + overlay + tabs)
    * remove .fm-* CSS block
    * remove Skill + Forge toolbar buttons (kept Test button — it
      calls the independent /api/run_code, not the removed endpoints)
    * remove JS: doSkill, doForge, submitForge, closeForgeModal,
      setForgeMode, _forgeMode, fmOverlay event handlers
    * update Vibe system prompt — no more mention of Skill Forge
- scripts/feature_audit.py:
    * features 10 and 12 now assert 404 (proof of removal) instead
      of "endpoint responsive"

Docs / UI references:
- AGENTS.md §7: new "Skill creation flow — review-and-approve only"
  subsection above the Skill load-time safety gate subsection.
  Documents D-2 + D-3 removal trail.
- codec_cortex.html: CODEC Vibe panel rewritten — "Skill Forge"
  removed from subtitle, features, description.
- codec_chat.html: Vibe blurb updated.
- README.md: 5 mentions updated (table row, section heading,
  "Writes its own plugins" comparison row, IDE comparison row,
  Project Structure description).
- FEATURES.md: Vibe feature list renumbered — Skill Forge items
  removed; the human-review approval workflow stays as item 17.
- setup_codec.py:457: Vibe install description.
- docs/API.md: /api/forge section removed; /api/skill/review +
  /api/skill/approve are now the documented contract.

Tests:
- NEW tests/test_skill_routes.py — 9 tests covering removal +
  replacement:
    * test_save_skill_handler_removed
    * test_forge_skill_handler_removed
    * test_no_route_decorator_strings_for_removed_endpoints
    * test_replacement_handlers_present
    * test_post_save_skill_returns_404
    * test_post_forge_returns_404
    * test_post_skill_review_still_accepts_valid_body
    * test_skill_review_rejects_empty_body
    * test_skill_approve_writes_only_after_review (full review →
      approve flow exercised via FastAPI TestClient, asserts
      no-disk-write at review step and disk-write at approve step)
- tests/test_dashboard.py:
    * test_forge_requires_input replaced with
      test_forge_endpoint_removed (asserts 404)
    * added test_save_skill_endpoint_removed
- tests/test_full_product_audit.py:
    * test_forge_endpoint replaced with test_forge_endpoint_removed
- tests/test_critical_fixes.py:
    * TestSkillForgeBlocklist class removed entirely. The substring
      blocker it tested is gone with the endpoints. Dangerous-pattern
      coverage now lives in tests/test_skill_registry.py (load-time
      AST gate) and routes/skills.py:skill_approve (write-time AST).
    * Note: this class was already failing on main per
      docs/PHASE1-STEP1-PREMERGE-AUDIT.md row #4 because it referenced
      codec_dashboard.save_skill which never existed on main; the
      deletion is a positive net for the test suite.
- tests/test_security.py:
    * test_save_skill_validates_content removed. Same root cause as
      above — referenced codec_dashboard.save_skill, was already
      failing on main per the pre-merge audit row #20.

Audit closure:
- docs/audits/PHASE-1-SECURITY.md:
    * D-2 closure footnote appended
    * D-3 closure footnote appended
- docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md:
    * D-2 row status: W1 → W1 — CLOSED (PR-1B)
    * D-3 row status: W1 → W1 — CLOSED (PR-1B)

Verification:
- pytest tests/test_skill_routes.py — 9 passed (RED watched,
  then GREEN after endpoint removal)
- pytest tests/test_skill_registry.py — 13 passed (PR-1A tests
  still green)
- pytest tests/test_skill_contracts.py — 1 passed
- pytest tests/test_oauth_provider.py + test_retry.py — passed
- python3 tests/test_skill_imports.py — 76 skills parsed, 0 errors
- python3 tools/generate_skill_manifest.py --check — manifest current
- ruff check routes/skills.py tests/test_skill_routes.py — clean
- Full regression against PR-1A baseline (stash-and-rerun): same
  12 pre-existing failures, no new regressions. Net failures count
  drops by 2 (the deleted broken tests).

Out of scope (later PRs):
- D-4 file_write block-roots expansion (PR-1C)
- D-5 permission_gate realpath + path-blocklist (PR-1D)
- D-17 positive-allowlist for is_dangerous_skill_code (optional PR-1E)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(audits): record PR-1A merge commit hash in D-1 closure footnote

PR-1A (#42) merged to main as squash commit 48ec5d5. Update the
D-1 closure footnote in docs/audits/PHASE-1-SECURITY.md and the
D-1 row in docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md to reference
the PR number + commit hash, replacing the branch-name placeholder.

This commit lands on the PR-1B branch (fix/pr1b-remove-save-skill-and-forge)
so the citation travels with PR-1B's next merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(config): handle headless ImportError on pynput gracefully

CI failure root cause for PR-1A (#42) and PR-1B (#43): the smoke job
on Ubuntu runners failed at test collection because importing
codec_skill_registry → codec_config → `from pynput import keyboard`
raised ImportError on a headless display:

    ImportError: this platform is not supported:
    ('failed to acquire X connection: Bad display name ""')

pynput needs a real display (X11 / AppKit / win32). GitHub Actions
Linux runners are headless. Other modules import codec_config for
its constants and is_dangerous_skill_code — none of them need the
keyboard subsystem.

Fix: wrap the pynput import in try/except so codec_config is import-
safe in headless contexts. The two `getattr(keyboard.Key, ...)` call
sites in `_resolve_key` now early-return None when keyboard is None
(the resulting KEY_TOGGLE / KEY_VOICE / KEY_TEXT module constants
are None on headless systems — only matters if the live keyboard
listener actually runs, which it doesn't in CI).

This was triggered by the new PR-1A test
`tests/test_skill_registry.py` which CI now runs per the new
`Skill registry load-time AST gate tests (D-1)` step. Before PR-1A,
no CI-gated test imported codec_config, so the pynput crash was
latent. Production paths (codec.py daemon, dashboard, etc.) all
run on macOS where pynput imports fine.

Verified locally:
- pytest tests/test_skill_routes.py tests/test_skill_registry.py
  tests/test_skill_contracts.py tests/test_oauth_provider.py
  tests/test_retry.py → 28 passed
- python3 tests/test_skill_imports.py → 76 skills parsed, 0 errors
- python3 tools/generate_skill_manifest.py --check → ok
- Headless simulation: monkey-patch builtins.__import__ to raise on
  pynput, then `from codec_config import is_dangerous_skill_code` →
  succeeds, returns (True, 'Dangerous import: subprocess') correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Mickael Farina <farina.mickael@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
AVADSA25 pushed a commit that referenced this pull request May 17, 2026
…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>
AVADSA25 added a commit that referenced this pull request May 17, 2026
…5+D-14+D-16) (#47)

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: Mickael Farina <farina.mickael@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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 #4248ec5d5
  D-2/3 PR-1B #43ff16664
  D-4   PR-1C #450065d90
  D-5   PR-1D #47fd2b460  (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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants