Skip to content

Fail-closed wrapper retrofit to remaining gates + plugin file-permissions sweep #667

@michael-wojcik

Description

@michael-wojcik

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions