@paths.design/caws-cli 11.1.0 → 11.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "11.1.0",
3
+ "version": "11.1.1",
4
4
  "description": "CAWS CLI - the governed core for CAWS project state, scope, claims, gates, waivers, and evidence (v11.1). Restores canonical spec/worktree lifecycle on the vNext kernel/store/shell architecture.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "preferGlobal": true,
10
10
  "files": [
11
11
  "dist",
12
- "README.md"
12
+ "README.md",
13
+ "templates/hook-packs/**"
13
14
  ],
14
15
  "scripts": {
15
16
  "build": "node scripts/build-cli.js",
@@ -33,7 +34,9 @@
33
34
  "validate": "echo 'CLI package validation not required'",
34
35
  "caws:validate": "node dist/index.js validate",
35
36
  "clean": "rm -rf dist test-caws-project .agent && npm run test:cleanup",
36
- "prepare": "husky >/dev/null 2>&1 || true"
37
+ "prepare": "husky >/dev/null 2>&1 || true",
38
+ "smoke:fresh-install": "node scripts/fresh-install-smoke.mjs",
39
+ "prepublishOnly": "npm run build && node scripts/fresh-install-smoke.mjs"
37
40
  },
38
41
  "keywords": [
39
42
  "caws",
@@ -0,0 +1,172 @@
1
+ <!--
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 1,4,6,8,11,12,13,16,17
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ -->
9
+
10
+ # CAWS Claude Code Hook Pack
11
+
12
+ This directory contains the v11 Claude Code hook pack — pre-tool-call
13
+ governance infrastructure that interposes between the agent and its Edit,
14
+ Write, and Bash tools. The kernel/store/shell trinity owns canonical state;
15
+ these hooks **project** that state into refusals at the agent's boundary,
16
+ where the kernel cannot reach (the kernel runs downstream of the tool
17
+ call).
18
+
19
+ ## The contract: every hook here exists because of an incident
20
+
21
+ This pack is not optional scaffolding. Every script under
22
+ `.claude/hooks/` traces to a specific entry in
23
+ `packages/caws-cli/docs-status/failure-lineage.md`. Modifying or removing
24
+ a hook requires:
25
+
26
+ 1. Naming the lineage entry the hook covers.
27
+ 2. Identifying the replacement mechanism that preserves the protection.
28
+ 3. Documenting the change in the same lineage doc.
29
+
30
+ Hooks may be **evolved** as the v11 state model evolves. They may not be
31
+ removed or weakened by an agent's local judgment. If you think a guard
32
+ is wrong, stop and ask the user.
33
+
34
+ ## Lineage map
35
+
36
+ | File | Lineage entries | What it prevents |
37
+ |------|----------------|------------------|
38
+ | `block-dangerous.sh` + `classify_command.py` | 1, 17 | catastrophic git operations; tokenized-argv bypasses; danger latch |
39
+ | `worktree-guard.sh` | 4, 6, 11 | amend/stash/reset/force-push during active worktrees; cross-boundary file copies |
40
+ | `worktree-write-guard.sh` | 4, 8, 13 | base-branch writes when worktrees are active (enforcement returns in CLI-WORKTREE-001); baseline-clobber |
41
+ | `scope-guard.sh` | 8, 11, 12, 16 | edits outside the active spec's `scope.in`; cross-spec union interference; unbound → no authority |
42
+ | `session-caws-status.sh` | 4, 11 | inherited-dirty-state collision; foreign-claim soft-block; version-skew |
43
+ | `reset-strikes.sh` | 8, 16 | human-authorized strike reset (escape hatch, not auto-resettable) |
44
+ | `reset-danger-latch.sh` | 17 | human-authorized danger latch reset |
45
+ | `guard-strikes.sh` | 8, 16 | progressive enforcement (strike 1 warn → strike 3 block) |
46
+ | `audit.sh` | 9 | per-tool-call audit log |
47
+ | `session-log.sh` | 10 | per-turn narrative + structured transcripts |
48
+ | `dispatch/*` | 8, 11, 17 | wires Claude Code's lifecycle to the registered handler list |
49
+ | `lib/*` | 8, 16 | shared input parsing and handler runner |
50
+
51
+ ## v11 state-model awareness
52
+
53
+ The v11 pack reads CAWS state under both v10 and v11 shapes during the
54
+ transition window:
55
+
56
+ - **Specs**: `lifecycle_state` is read first; `status` is the v10 fallback.
57
+ Terminal states (closed, archived, completed) are not enforced.
58
+ `draft` does NOT participate in union-wide blocking unless it is the
59
+ authoritative/bound spec.
60
+ - **Worktrees registry**: both v11 direct-key
61
+ (`{"<name>": {...}}`) and v10 nested
62
+ (`{"worktrees": {"<name>": {...}}}`) shapes are accepted.
63
+ - **Bound spec id**: both `entry.specId` (v10) and `entry.spec_id` (v11)
64
+ are accepted.
65
+
66
+ ## Version-skew warning
67
+
68
+ `session-caws-status.sh` emits a non-blocking WARNING when the global
69
+ `caws` binary's major version differs from the repo's `caws-cli` major
70
+ version. Hooks parse local state directly, but any CLI advice in
71
+ diagnostics may be invalid. Consider matching major versions:
72
+ `npm install -g @paths.design/caws-cli@^<repo-major>`.
73
+
74
+ ## Activation
75
+
76
+ Claude Code reads `.claude/settings.json` at session start. Installing
77
+ the pack mid-session does NOT activate it until the session is restarted.
78
+ `caws init --agent-surface claude-code` prints an activation instruction
79
+ saying so. Do not continue substantive work after install without
80
+ restarting first; the hooks you just installed are not yet enforcing.
81
+
82
+ ## settings.json wiring
83
+
84
+ The pack does NOT manage `.claude/settings.json` — that file commonly
85
+ carries user-authored `permissions` and `env` blocks that the pack
86
+ should not overwrite. If you do not have a `.claude/settings.json`, add
87
+ the following minimum configuration so the dispatch entrypoints fire on
88
+ the Claude Code lifecycle:
89
+
90
+ ```jsonc
91
+ {
92
+ "hooks": {
93
+ "PreToolUse": [
94
+ {
95
+ "matcher": "Bash|Read|Write|Edit|Glob|Grep|NotebookEdit",
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dispatch/pre_tool_use.sh",
100
+ "timeout": 45
101
+ }
102
+ ]
103
+ }
104
+ ],
105
+ "PostToolUse": [
106
+ {
107
+ "matcher": "Write|Edit|Bash|ExitPlanMode",
108
+ "hooks": [
109
+ {
110
+ "type": "command",
111
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dispatch/post_tool_use.sh",
112
+ "timeout": 60
113
+ }
114
+ ]
115
+ }
116
+ ],
117
+ "SessionStart": [
118
+ {
119
+ "hooks": [
120
+ {
121
+ "type": "command",
122
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dispatch/session_start.sh",
123
+ "timeout": 30
124
+ }
125
+ ]
126
+ }
127
+ ],
128
+ "Stop": [
129
+ {
130
+ "hooks": [
131
+ {
132
+ "type": "command",
133
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dispatch/stop.sh",
134
+ "timeout": 30
135
+ }
136
+ ]
137
+ }
138
+ ],
139
+ "PreCompact": [
140
+ {
141
+ "hooks": [
142
+ {
143
+ "type": "command",
144
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-log.sh",
145
+ "timeout": 10
146
+ }
147
+ ]
148
+ }
149
+ ]
150
+ }
151
+ }
152
+ ```
153
+
154
+ ## Managed file headers
155
+
156
+ Every managed file in this pack carries a header like:
157
+
158
+ ```
159
+ # CAWS-MANAGED-HOOK
160
+ # hook_pack: claude-code
161
+ # hook_pack_version: <N>
162
+ # caws_min_major: 11
163
+ # lineage_refs: <comma-separated entries>
164
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
165
+ ```
166
+
167
+ The header is what `caws init` uses to distinguish managed files (safe to
168
+ update on re-install under a documented policy) from local user files
169
+ (refused without explicit `--adopt` or `--overwrite`).
170
+
171
+ Removing or editing the header turns the file into an unmanaged
172
+ snowflake. Re-running install will then refuse to touch it — by design.
@@ -0,0 +1,121 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 9
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # CAWS Audit Hook for Claude Code
9
+ # Logs agent actions for compliance and debugging
10
+ # @author @darianrosebrook
11
+
12
+ set -euo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ # shellcheck source=lib/parse-input.sh
16
+ source "$SCRIPT_DIR/lib/parse-input.sh"
17
+
18
+ # --- CWD resilience ---
19
+ # PostToolUse hooks fire AFTER the command runs. If the command destroyed
20
+ # the working directory (e.g., caws worktree merge deletes the worktree),
21
+ # the hook process inherits a nonexistent CWD and most commands will fail.
22
+ # Recover to a safe directory before doing anything else.
23
+ if ! pwd >/dev/null 2>&1 || [ ! -d "$(pwd 2>/dev/null || echo __gone__)" ]; then
24
+ cd "${CLAUDE_PROJECT_DIR:-$HOME}" 2>/dev/null || cd "$HOME"
25
+ fi
26
+
27
+ parse_hook_input
28
+
29
+ # Get event type from argument or input
30
+ EVENT_TYPE="${1:-tool-use}"
31
+
32
+ # Back-compat aliases. HOOK_CWD can be "" when stdin lacks a cwd field;
33
+ # audit.sh always wants a non-empty placeholder so jq --arg stays happy.
34
+ SESSION_ID="$HOOK_SESSION_ID"
35
+ CWD="${HOOK_CWD:-.}"
36
+ HOOK_EVENT="${HOOK_EVENT_NAME:-unknown}"
37
+ TOOL_NAME="$HOOK_TOOL_NAME"
38
+ PERMISSION_MODE="$HOOK_PERMISSION_MODE"
39
+
40
+ # Ensure log directory exists
41
+ LOG_DIR="${CLAUDE_PROJECT_DIR:-.}/.claude/logs"
42
+ mkdir -p "$LOG_DIR"
43
+
44
+ # Log file path
45
+ LOG_FILE="$LOG_DIR/audit.log"
46
+ DATE_LOG_FILE="$LOG_DIR/audit-$(date +%Y-%m-%d).log"
47
+
48
+ # Timestamp
49
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
50
+
51
+ # Build log entry based on event type
52
+ case "$EVENT_TYPE" in
53
+ session-start)
54
+ SOURCE="${HOOK_SOURCE:-unknown}"
55
+ MODEL="${HOOK_MODEL:-unknown}"
56
+ LOG_ENTRY=$(jq -n \
57
+ --arg ts "$TIMESTAMP" \
58
+ --arg sid "$SESSION_ID" \
59
+ --arg event "session_start" \
60
+ --arg source "$SOURCE" \
61
+ --arg model "$MODEL" \
62
+ --arg cwd "$CWD" \
63
+ '{timestamp: $ts, session_id: $sid, event: $event, source: $source, model: $model, cwd: $cwd}')
64
+ ;;
65
+
66
+ stop)
67
+ # HOOK_STOP_HOOK_ACTIVE is "0" or "1"; jq --argjson needs true/false.
68
+ if [[ "$HOOK_STOP_HOOK_ACTIVE" == "1" ]]; then
69
+ STOP_HOOK_ACTIVE="true"
70
+ else
71
+ STOP_HOOK_ACTIVE="false"
72
+ fi
73
+ LOG_ENTRY=$(jq -n \
74
+ --arg ts "$TIMESTAMP" \
75
+ --arg sid "$SESSION_ID" \
76
+ --arg event "session_stop" \
77
+ --arg cwd "$CWD" \
78
+ --argjson hook_active "$STOP_HOOK_ACTIVE" \
79
+ '{timestamp: $ts, session_id: $sid, event: $event, cwd: $cwd, stop_hook_active: $hook_active}')
80
+ ;;
81
+
82
+ tool-use)
83
+ # Tool-specific info lifted from HOOK_* env vars set by parse_hook_input.
84
+ # HOOK_TOOL_INPUT_JSON and HOOK_TOOL_RESPONSE_JSON are pre-serialized
85
+ # JSON strings, always valid (empty "{}" at minimum), so jq --argjson
86
+ # below never trips on missing fields.
87
+ TOOL_INPUT="$HOOK_TOOL_INPUT_JSON"
88
+ TOOL_RESPONSE="$HOOK_TOOL_RESPONSE_JSON"
89
+ TOOL_USE_ID="$HOOK_TOOL_USE_ID"
90
+ FILE_PATH="$HOOK_FILE_PATH"
91
+ COMMAND="$HOOK_COMMAND"
92
+
93
+ LOG_ENTRY=$(jq -n \
94
+ --arg ts "$TIMESTAMP" \
95
+ --arg sid "$SESSION_ID" \
96
+ --arg event "tool_use" \
97
+ --arg tool "$TOOL_NAME" \
98
+ --arg file "$FILE_PATH" \
99
+ --arg cmd "$COMMAND" \
100
+ --arg cwd "$CWD" \
101
+ --arg mode "$PERMISSION_MODE" \
102
+ '{timestamp: $ts, session_id: $sid, event: $event, tool: $tool, file: $file, command: $cmd, cwd: $cwd, permission_mode: $mode}')
103
+ ;;
104
+
105
+ *)
106
+ LOG_ENTRY=$(jq -n \
107
+ --arg ts "$TIMESTAMP" \
108
+ --arg sid "$SESSION_ID" \
109
+ --arg event "$EVENT_TYPE" \
110
+ --arg hook "$HOOK_EVENT" \
111
+ --arg cwd "$CWD" \
112
+ '{timestamp: $ts, session_id: $sid, event: $event, hook_event: $hook, cwd: $cwd}')
113
+ ;;
114
+ esac
115
+
116
+ # Append to log files
117
+ echo "$LOG_ENTRY" >> "$LOG_FILE"
118
+ echo "$LOG_ENTRY" >> "$DATE_LOG_FILE"
119
+
120
+ # Success - allow operation to continue
121
+ exit 0
@@ -0,0 +1,158 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 1,17
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # CAWS Command Safety Gate for Claude Code
9
+ # Delegates to classify_command.py for robust command parsing and classification.
10
+ # Falls back to bash pattern matching if Python is unavailable.
11
+ #
12
+ # The Python classifier handles:
13
+ # - Heredoc-aware parsing (won't false-positive on quoted dangerous commands)
14
+ # - Quoted-region stripping (echo "git reset --hard" is safe)
15
+ # - Pipeline-aware dangers (curl | sh)
16
+ # - Context-aware rm classification (safe prefixes vs dangerous targets)
17
+ # - Proper shell segmentation (&&, ||, ;, |)
18
+ #
19
+ # @author @darianrosebrook
20
+
21
+ set -euo pipefail
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+
25
+ danger_state_dir() {
26
+ local project_dir="${CLAUDE_PROJECT_DIR:-.}"
27
+ local state_dir="$project_dir/.claude/hooks/state"
28
+ mkdir -p "$state_dir"
29
+ printf '%s\n' "$state_dir"
30
+ }
31
+
32
+ danger_latch_file() {
33
+ local session_id="$1"
34
+ local safe_session
35
+ safe_session=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
36
+ printf '%s/danger-latch-%s.json\n' "$(danger_state_dir)" "$safe_session"
37
+ }
38
+
39
+ emit_block_json() {
40
+ local reason="$1"
41
+ jq -n --arg msg "$reason" '{ decision: "block", reason: $msg }'
42
+ }
43
+
44
+ emit_ask_json() {
45
+ local reason="$1"
46
+ jq -n --arg msg "$reason" '{
47
+ hookSpecificOutput: {
48
+ hookEventName: "PreToolUse",
49
+ permissionDecision: "ask",
50
+ permissionDecisionReason: $msg
51
+ }
52
+ }'
53
+ }
54
+
55
+ record_danger_latch() {
56
+ local file="$1"
57
+ local decision="$2"
58
+ local reason="$3"
59
+ local command="$4"
60
+
61
+ mkdir -p "$(dirname "$file")"
62
+ jq -n \
63
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
64
+ --arg hook "block-dangerous.sh" \
65
+ --arg decision "$decision" \
66
+ --arg reason "$reason" \
67
+ --arg command "$command" \
68
+ '{
69
+ ts: $ts,
70
+ hook: $hook,
71
+ decision: $decision,
72
+ reason: $reason,
73
+ command: $command,
74
+ message: "Dangerous command boundary engaged. User reset required before more Bash commands may run in this session."
75
+ }' > "$file"
76
+ }
77
+
78
+ # Read JSON input from Claude Code
79
+ INPUT=$(cat)
80
+
81
+ # Extract tool info
82
+ TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')
83
+ COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')
84
+ # Fallback to "unknown" when no session id is available so the latch still
85
+ # engages. Multiple concurrent sessions without an id will share the "unknown"
86
+ # latch -- safer than not latching at all.
87
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // env.CLAUDE_SESSION_ID // env.HOOK_SESSION_ID // "unknown"')
88
+
89
+ # Only check Bash tool
90
+ if [[ "$TOOL_NAME" != "Bash" ]] || [[ -z "$COMMAND" ]]; then
91
+ exit 0
92
+ fi
93
+
94
+ LATCH_FILE="$(danger_latch_file "$SESSION_ID")"
95
+ if [[ -f "$LATCH_FILE" ]]; then
96
+ REASON="A dangerous command was previously blocked or sent for approval in this Claude session. This is a human-review boundary, not a retryable syntax error. Do not rephrase, wrap, reorder, alias, or indirectly invoke the command. Ask the user to clear the latch with .claude/hooks/reset-danger-latch.sh before more Bash commands may run. Sentinel: $LATCH_FILE"
97
+ emit_block_json "$REASON"
98
+ exit 0
99
+ fi
100
+
101
+ # --- Python classifier (preferred path) ---
102
+ CLASSIFIER="$SCRIPT_DIR/classify_command.py"
103
+ if [[ ! -f "$CLASSIFIER" ]] || ! command -v python3 >/dev/null 2>&1; then
104
+ REASON="command classifier unavailable; dangerous-command safety cannot verify Bash semantics. This is a human-review boundary. Command was: $COMMAND"
105
+ record_danger_latch "$LATCH_FILE" "ask" "classifier unavailable" "$COMMAND"
106
+ emit_ask_json "$REASON"
107
+ exit 0
108
+ fi
109
+
110
+ REPO_ROOT="${CLAUDE_PROJECT_DIR:-.}"
111
+ CLASSIFIER_STDERR=$(mktemp)
112
+ RESULT=$(printf '%s' "$COMMAND" | python3 "$CLASSIFIER" \
113
+ --repo-root "$REPO_ROOT" \
114
+ --home "$HOME" \
115
+ --cwd "$(pwd)" 2>"$CLASSIFIER_STDERR") || {
116
+ DIAG=$(head -c 200 "$CLASSIFIER_STDERR" 2>/dev/null || true)
117
+ rm -f "$CLASSIFIER_STDERR"
118
+ RESULT="{\"decision\":\"ask\",\"reason\":\"command classifier failed: ${DIAG:-unknown error}\"}"
119
+ }
120
+ rm -f "$CLASSIFIER_STDERR"
121
+
122
+ DECISION=$(printf '%s' "$RESULT" | jq -r '.decision // "ask"')
123
+ REASON=$(printf '%s' "$RESULT" | jq -r '.reason // "unknown"')
124
+
125
+ case "$DECISION" in
126
+ allow)
127
+ exit 0
128
+ ;;
129
+ deny)
130
+ FULL_REASON="$REASON. This is a HARD BLOCK — Claude Code will refuse the command. This is a human-review boundary, not a retryable syntax error. Do not rephrase, wrap, reorder, alias, or indirectly invoke this command (e.g. via 'command git ...', 'env ... git ...', 'bash -lc \"...\"', or 'git --bare init'). Stop and ask the user for the next step. Command was: $COMMAND"
131
+ record_danger_latch "$LATCH_FILE" "$DECISION" "$REASON" "$COMMAND"
132
+ emit_block_json "$FULL_REASON"
133
+ exit 0
134
+ ;;
135
+ ask)
136
+ FULL_REASON="$REASON. Claude Code will PAUSE and ask the user to approve before running. This may alter destructive or authority-bearing state. Do not attempt to bypass this by rephrasing the command, switching syntax, or wrapping the invocation. If permission is not granted, stop and ask the user for the next step. Command was: $COMMAND"
137
+ record_danger_latch "$LATCH_FILE" "$DECISION" "$REASON" "$COMMAND"
138
+ emit_ask_json "$FULL_REASON"
139
+ exit 0
140
+ ;;
141
+ *)
142
+ # Unknown decision value -- malformed classifier output. Do NOT fall
143
+ # through to the weaker regex fallback; ask+latch instead so a
144
+ # corrupted classifier cannot silently downgrade safety.
145
+ FULL_REASON="command classifier returned an unrecognized decision '$DECISION'. Claude Code will PAUSE and ask the user. This is a human-review boundary. Command was: $COMMAND"
146
+ record_danger_latch "$LATCH_FILE" "ask" "classifier unknown decision: $DECISION" "$COMMAND"
147
+ emit_ask_json "$FULL_REASON"
148
+ exit 0
149
+ ;;
150
+ esac
151
+
152
+ # Every classifier outcome (allow/deny/ask/unknown) exits inside the case
153
+ # above. There is no flat-regex fallback; if classify_command.py cannot run,
154
+ # the early-exit at the top of this script ask-latches the command. That
155
+ # keeps the dangerous-command decision in a single semantic layer.
156
+
157
+ # shellcheck disable=SC2317 # Defense-in-depth tail; unreachable on a healthy classifier.
158
+ exit 0