Background
PR #663 (closes #662) added fail-closed module-load wrappers to bootstrap_gate.py and bootstrap_prompt_gate.py mirroring the _emit_load_failure_deny pattern from merge_guard_pre. The wrapper is stdlib-only and NoReturn-annotated so module-load failures land as a deny rather than a silent fail-open.
Two follow-ups: extend the same discipline to siblings, and audit file-mode permissions on writeback paths.
Task A — Fail-closed wrapper retrofit to remaining gates
Apply the _emit_load_failure_deny pattern to:
pin_caps_gate.py
pin_staleness_gate.py
team_guard.py
worktree_guard.py
git_commit_check.py
Each currently fails open on module-load failure (e.g., ImportError from a missing shared module dependency). The fail-closed wrapper converts module-load failures into a permissionDecision: deny with a stable error message, matching the security posture established in PR #663 for bootstrap_gate / bootstrap_prompt_gate and PR #660 for merge_guard_pre.
Pattern to mirror:
# Stdlib-only deny path before wrapped imports
import sys, json
from typing import NoReturn
def _emit_load_failure_deny(stage: str, error: BaseException) -> NoReturn:
print(f"Hook load error ({HOOK_NAME}, {stage}): {error}", file=sys.stderr)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse", # or PostToolUse depending on hook
"permissionDecision": "deny",
"permissionDecisionReason": f"Hook load error ({HOOK_NAME}): {error}",
}
}))
sys.exit(2)
try:
from shared.something import ...
except BaseException as e:
_emit_load_failure_deny("import", e)
Surfaced by architect-blind in PR #663 review as Future #4.
Task B — Plugin file-permissions sweep
Audit all hooks and scripts that write to ~/.claude/ for 0o600 mode on writeback. PR #663 added 0o600 to the bootstrap-marker writeback. Remaining surfaces to audit:
merge_guard_post.py — token writeback (already 0o600, verify)
wake_lifecycle_emitter.py — STATE_FILE writeback
session_init.py — pact-session-context.json writeback
auditor_reminder.py — any state writeback
team_guard.py — config writeback
- All other hooks that touch
~/.claude/teams/, ~/.claude/tasks/, ~/.claude/pact-sessions/
For each writeback path, verify the file is created with 0o600 (writer-only readable). Same-user trust is the project's threat model, but 0o600 is the right default for files that may carry session-scoped secrets (tokens, signature material, harness identifiers).
Surfaced by security-blind in PR #663 review as F4.
Relationship to PR #663
Test plan
For Task A: counter-test each wrapped gate by deliberately breaking the import (renaming a shared.* module the gate depends on) and confirming the gate denies with the load-failure message rather than silently allowing through.
For Task B: add a test_file_modes.py that walks every writeback path and asserts the file mode is 0o600. Counter-test by reverting one path to 0o644 and confirming the test catches it.
Background
PR #663 (closes #662) added fail-closed module-load wrappers to
bootstrap_gate.pyandbootstrap_prompt_gate.pymirroring the_emit_load_failure_denypattern frommerge_guard_pre. The wrapper is stdlib-only andNoReturn-annotated so module-load failures land as a deny rather than a silent fail-open.Two follow-ups: extend the same discipline to siblings, and audit file-mode permissions on writeback paths.
Task A — Fail-closed wrapper retrofit to remaining gates
Apply the
_emit_load_failure_denypattern to:pin_caps_gate.pypin_staleness_gate.pyteam_guard.pyworktree_guard.pygit_commit_check.pyEach currently fails open on module-load failure (e.g.,
ImportErrorfrom a missingsharedmodule dependency). The fail-closed wrapper converts module-load failures into apermissionDecision: denywith a stable error message, matching the security posture established in PR #663 forbootstrap_gate/bootstrap_prompt_gateand PR #660 formerge_guard_pre.Pattern to mirror:
Surfaced by architect-blind in PR #663 review as Future #4.
Task B — Plugin file-permissions sweep
Audit all hooks and scripts that write to
~/.claude/for0o600mode on writeback. PR #663 added0o600to the bootstrap-marker writeback. Remaining surfaces to audit:merge_guard_post.py— token writeback (already0o600, verify)wake_lifecycle_emitter.py— STATE_FILE writebacksession_init.py— pact-session-context.json writebackauditor_reminder.py— any state writebackteam_guard.py— config writeback~/.claude/teams/,~/.claude/tasks/,~/.claude/pact-sessions/For each writeback path, verify the file is created with
0o600(writer-only readable). Same-user trust is the project's threat model, but0o600is the right default for files that may carry session-scoped secrets (tokens, signature material, harness identifiers).Surfaced by security-blind in PR #663 review as F4.
Relationship to PR #663
_emit_load_failure_denypattern is documented in PR Dispatch-protocol hardening: rename Task→Agent + dispatch_gate + task_lifecycle_gate + bootstrap_gate F24/F25 (#662) #663's bootstrap_gate.py and bootstrap_prompt_gate.py. Use those as the reference implementation when retrofitting.0o600writeback for the bootstrap marker is in PR Dispatch-protocol hardening: rename Task→Agent + dispatch_gate + task_lifecycle_gate + bootstrap_gate F24/F25 (#662) #663's commands/bootstrap.md heredoc producer. Once Hook-driven bootstrap marker write #664 migrates the producer to a hook, the same0o600discipline must carry over.Test plan
For Task A: counter-test each wrapped gate by deliberately breaking the import (renaming a
shared.*module the gate depends on) and confirming the gate denies with the load-failure message rather than silently allowing through.For Task B: add a
test_file_modes.pythat walks every writeback path and asserts the file mode is0o600. Counter-test by reverting one path to0o644and confirming the test catches it.