fix(security): D-4 — file_write blocks ~/.codec/ and <repo>/skills/#45
Merged
Conversation
Closes D-4 (CRITICAL) per docs/audits/PHASE-1-SECURITY.md. Before this PR, the file_write skill (SKILL_MCP_EXPOSE=True, NOT in _HTTP_BLOCKED) gave claude.ai over the 30-day OAuth token a write-path to any of: ~/.codec/skills/<x>.py → drop a malicious skill, restart, RCE ~/.codec/plugins/<x>.py → drop a plugin, wraps every tool call ~/.codec/oauth_state.json → tamper with bearer tokens ~/.codec/audit.log → erase forensic evidence ~/.codec/config.json → exfiltrate API keys ~/.codec/memory.db → poison conversation memory ~/.codec/agents/<id>/* → tamper with Step 9 runtime state <repo>/skills/<x>.py → contaminate the trusted manifest The old _BLOCKED_ROOTS only listed /System, /etc, etc. — none of the above are caught. Fix: refactor _is_safe_target with three changes. 1. Realpath the blocklist at module load. _BLOCKED_SYSTEM_ROOTS gets resolved via os.path.realpath so macOS aliases work regardless of which name the caller uses (/etc both matches a write to /etc/passwd AND a write to /private/etc/passwd via realpath of the parent). /bin → /usr/bin, /sbin → /usr/sbin resolved redundantly with /usr; harmless. 2. Block the entire ~/.codec/ tree. The file_write skill is for user-facing files (Documents, Desktop, code projects). It has no business writing into CODEC's own state directory. One rule covers skills, plugins, oauth_state.json, audit.log, config.json, memory.db, agents/, notifications.json, pending_questions.json, agent_global_grants.json, triggers_killed .json — and any future ~/.codec/* state file added by later phases. 3. Block <repo>/skills/. PR-1A's trusted-skill manifest hash-pins every built-in. If file_write could overwrite one of them, the hash would no longer match the manifest, and the load-time AST gate would kick in to refuse load — but that's defense-in-depth, not primary defense. The primary defense is: never let file_write reach a skill dir. Pre-existing bug also fixed as a side effect: /tmp writes were silently failing. The old code hard-coded "/private" in _BLOCKED_ROOTS, and macOS realpaths /tmp → /private/tmp. So every write to /tmp tripped the /private block and returned "Blocked system path: /private" — even though the home/tmp sanity check at line 100-107 was intended to allow it. The refactor drops "/private" from the blocklist (its dangerous subdirs /private/etc and /private/var are still covered via realpath of /etc and /var) and realpath- resolves /tmp + $HOME in the sanity check. Files: - skills/file_write.py: refactored _is_safe_target (+89 LOC, mostly new _BLOCKED_SYSTEM_ROOTS / _codec_blocked_roots / _build_blocked _roots helpers + realpath-resolved _BLOCKED_ROOTS_REAL). Backward- compat alias _BLOCKED_ROOTS preserved for any external reader. - skills/.manifest.json: regenerated to reflect new file_write.py hash (still 74 skills total, file_write entry updated). - tests/test_file_write.py: NEW, 21 tests covering refusal of every ~/.codec/* sensitive path + repo skills dir + symlink resolution + regression checks on ~/Documents, ~/Desktop, /tmp, ~/Projects, ~/codec-workspace + existing /etc/.ssh/.env protections. - docs/audits/PHASE-1-SECURITY.md: D-4 closure footnote. - docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md: D-4 row → "W1 — CLOSED (PR-1C)". Verification: - pytest tests/test_file_write.py -v → 21 passed (TDD: written first, watched fail with 13 expected failures + 1 surprise pre- existing bug, then implemented, then all 21 green) - 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 (PR-1A + PR-1B suites still green) - python3 tests/test_skill_imports.py → 76 skills parsed, 0 errors - python3 tools/generate_skill_manifest.py --check → ok - ruff check skills/file_write.py tests/test_file_write.py → 6 pre-existing errors (E402 on the skill metadata-before-imports convention + 1 unused-import F401), identical to main. No new ruff errors introduced. Out of scope (later in Wave 1 / Wave 2): - D-5 permission_gate realpath + _PATH_BLOCKLIST_SUBSTRINGS (PR-1D). - D-17 positive-allowlist for is_dangerous_skill_code (optional PR-1E). - Pre-existing E402 in skill files (broader cleanup — Wave 3 / A-3). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses the two scope items from the original PR-1C prompt that the
first commit missed: (3) emit an audit event when file_write refuses
a target, and (4) update AGENTS.md §7 to document the path-blocking
rule for contributors.
skills/file_write.py:
- run() now emits log_event("file_write_blocked", ...) before
returning the refusal message. Forensic visibility: any MCP-client
attempt to write to ~/.codec/skills/, ~/.codec/plugins/, or any
other blocked path lands in ~/.codec/audit.log with the resolved
target_path, the original requested_path, and the reason string.
Audit failure is caught locally so it never masks the refusal.
tests/test_file_write.py:
- new test_blocked_write_emits_file_write_blocked_audit_event:
monkey-patches codec_audit.log_event, calls run() against
~/.codec/skills/attempt.py, asserts (a) refusal string returned,
(b) exactly one file_write_blocked event captured, (c) extra
dict contains target_path + reason. 22 tests total now.
AGENTS.md:
- new "file_write skill path-blocking (Phase 1 Wave 1, PR-1C —
closes D-4)" subsection above the existing PR-1B subsection.
Documents blocked paths (whole ~/.codec/, repo skills/, macOS
system tree), audit emission contract, realpath bypass-proofing,
and the operator/contributor note about editing CODEC config files
via the dashboard PWA or direct editor rather than via file_write.
skills/.manifest.json:
- regenerated for the new file_write.py hash.
Sample audit line that an operator can grep for:
{"ts": "2026-05-17T...Z",
"schema": 1,
"event": "file_write_blocked",
"source": "codec-skill-file-write",
"outcome": "error",
"level": "warning",
"message": "file_write refused ~/.codec/skills/attempt.py: ...",
"extra": {
"target_path": "/Users/<u>/.codec/skills/attempt.py",
"requested_path": "~/.codec/skills/attempt.py",
"reason": "Blocked path: /Users/<u>/.codec"
}}
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
b4c14fe to
48c1c55
Compare
AVADSA25
pushed a commit
that referenced
this pull request
May 17, 2026
PR-1C (#45) merged to main as squash commit 0065d90. Update the D-4 closure footnote in docs/audits/PHASE-1-SECURITY.md and the D-4 row in docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md, replacing the branch-name placeholder with PR number + commit hash. Mirrors the citation style applied to D-1 (PR-1A, 48ec5d5) and D-2/D-3 (PR-1B, ff16664). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AVADSA25
pushed a commit
that referenced
this pull request
May 17, 2026
PR-1C (#45) merged to main as squash commit 0065d90. Update the D-4 closure footnote in docs/audits/PHASE-1-SECURITY.md and the D-4 row in docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md, replacing the branch-name placeholder with PR number + commit hash. Mirrors the citation style applied to D-1 (PR-1A, 48ec5d5) and D-2/D-3 (PR-1B, ff16664). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8 tasks
AVADSA25
added a commit
that referenced
this pull request
May 17, 2026
PR-1C (#45) merged to main as squash commit 0065d90. Update the D-4 closure footnote in docs/audits/PHASE-1-SECURITY.md and the D-4 row in docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md, replacing the branch-name placeholder with PR number + commit hash. Mirrors the citation style applied to D-1 (PR-1A, 48ec5d5) and D-2/D-3 (PR-1B, ff16664). 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 #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-1C. Closes D-4 (CRITICAL) — see docs/audits/PHASE-1-SECURITY.md finding D-4 and the consolidated triage.
Before this PR, the
file_writeskill (SKILL_MCP_EXPOSE=True, NOT in_HTTP_BLOCKED) gave claude.ai over the 30-day OAuth token a write-path to every security-sensitive path on the machine:~/.codec/skills/<x>.py~/.codec/plugins/<x>.py~/.codec/oauth_state.json~/.codec/audit.log~/.codec/config.json~/.codec/memory.db~/.codec/agents/<id>/*<repo>/skills/<x>.pyThe old
_BLOCKED_ROOTSonly listed/System,/etc, etc. — none of these were caught.How
skills/file_write.py:_is_safe_targetrefactored with three changes:_BLOCKED_SYSTEM_ROOTSis resolved viaos.path.realpathso macOS aliases match regardless of which name the caller uses. (/etc → /private/etc,/bin → /usr/bin, etc.)~/.codec/tree. One rule covers every CODEC state file at once — current and future.file_writeis for user-facing files (Documents, Desktop, code projects). It has no business writing into CODEC's own state directory.<repo>/skills/. Defense in depth with PR-1A's manifest: never letfile_writereach a directory where the load-time gate would catch the tampering.Pre-existing bug also fixed (side effect)
The old code hard-coded
/privatein_BLOCKED_ROOTS, and macOS realpaths/tmp → /private/tmp. So every write to/tmptripped the/privateblock. The refactor drops/private(its dangerous subdirs/private/etcand/private/varare still covered via realpath of/etcand/var) and realpath-resolves/tmp+$HOMEin the sanity check. Testtest_accepts_tmp_dirsurfaced this in the RED phase.Tests
+21 new tests in
tests/test_file_write.py(TDD: RED → GREEN).~/.codec/skills/,~/.codec/plugins/,~/.codec/oauth_state.json,~/.codec/audit.log,~/.codec/config.json,~/.codec/memory.db,~/.codec/agents/*/state.json,~/.codec/agent_global_grants.json,~/.codec/pending_questions.json,~/.codec/triggers_killed.json,<repo>/skills/, arbitrary~/.codec/<file>, plus a symlink-into-codec test (creates a symlink in tmp_path pointing at~/.codec/skills/, verifies realpath resolves it and the write is refused).~/Documents,~/Desktop,/tmp,~/Projects,~/codec-workspace— regression checks so the skill's utility is preserved./etc/passwd,~/.ssh/id_rsa,.envfiles.Files
skills/file_write.py_is_safe_target+ new_BLOCKED_SYSTEM_ROOTS/_codec_blocked_roots/_build_blocked_rootshelpers + realpath-resolved_BLOCKED_ROOTS_REAL. +89/−10 LOC. Backward-compat alias_BLOCKED_ROOTSpreserved.skills/.manifest.jsonfile_write.pyhash updated. Still 74 skills total.tests/test_file_write.pydocs/audits/PHASE-1-SECURITY.mddocs/audits/PHASE-1-CONSOLIDATED-TRIAGE.mdW1 — CLOSED (PR-1C).Diff: 5 files, +299/−16.
Verification
pytest tests/test_file_write.pypytest tests/test_skill_routes.py tests/test_skill_registry.py tests/test_skill_contracts.py tests/test_oauth_provider.py tests/test_retry.pypython3 tests/test_skill_imports.pypython3 tools/generate_skill_manifest.py --checkruff checkskills/file_write.py + tests/test_file_write.pyOut of scope (later)
permission_gaterealpath +_PATH_BLOCKLIST_SUBSTRINGSextension (last of the four D-1 enabling paths).is_dangerous_skill_code(closes D-17).Test plan
pytest tests/test_file_write.py -v— 21 passedfile_writeskill with task="save to ~/.codec/skills/x.py\n```\npayload\n```" → refusedfile_writewith task="save to ~/Documents/notes.md\n```\nhi\n```" → succeedsfile_writewith task="save to /tmp/test.log\n```\nhi\n```" → succeeds (previously was silently broken)🤖 Generated with Claude Code