@windyroad/risk-scorer 0.12.7-preview.598 → 0.12.8-preview.617

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.
@@ -310,5 +310,5 @@
310
310
  }
311
311
  },
312
312
  "name": "wr-risk-scorer",
313
- "version": "0.12.7"
313
+ "version": "0.12.8"
314
314
  }
@@ -36,10 +36,23 @@ fi
36
36
  if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
37
37
  if [ -n "$SESSION_ID" ]; then
38
38
  RDIR=$(_risk_dir "$SESSION_ID")
39
- # Risk-reducing/neutral bypass for push
39
+ # Risk-reducing/neutral bypass for push — session-scoped, drift-
40
+ # revalidated (P192). Persists across multiple push attempts while
41
+ # pipeline-state hash matches and TTL is unexpired; consumed on
42
+ # drift or TTL expiry. Symmetric with the commit-gate change above.
40
43
  if [ -f "${RDIR}/reducing-push" ]; then
44
+ NOW=$(date +%s)
45
+ MARK_TIME=$(_mtime "${RDIR}/reducing-push")
46
+ AGE=$(( NOW - MARK_TIME ))
47
+ TTL_SECONDS="${RISK_TTL:-3600}"
48
+ if [ "$AGE" -lt "$TTL_SECONDS" ] && [ -f "${RDIR}/state-hash" ]; then
49
+ STORED_HASH=$(cat "${RDIR}/state-hash")
50
+ CURRENT_HASH=$("$SCRIPT_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
51
+ if [ "$STORED_HASH" = "$CURRENT_HASH" ]; then
52
+ exit 0
53
+ fi
54
+ fi
41
55
  rm -f "${RDIR}/reducing-push"
42
- exit 0
43
56
  fi
44
57
  # Clean tree bypass: if no uncommitted changes, pushing existing commits is safe
45
58
  if [ -f "${RDIR}/clean" ]; then
@@ -99,10 +112,21 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run release:watch(\s|$)'; the
99
112
  rm -f "${RDIR}/incident-release"
100
113
  exit 0
101
114
  fi
102
- # Risk-reducing bypass for release
115
+ # Risk-reducing bypass for release — session-scoped, drift-
116
+ # revalidated (P192). Same lifecycle as reducing-push above.
103
117
  if [ -f "${RDIR}/reducing-release" ]; then
118
+ NOW=$(date +%s)
119
+ MARK_TIME=$(_mtime "${RDIR}/reducing-release")
120
+ AGE=$(( NOW - MARK_TIME ))
121
+ TTL_SECONDS="${RISK_TTL:-3600}"
122
+ if [ "$AGE" -lt "$TTL_SECONDS" ] && [ -f "${RDIR}/state-hash" ]; then
123
+ STORED_HASH=$(cat "${RDIR}/state-hash")
124
+ CURRENT_HASH=$("$SCRIPT_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
125
+ if [ "$STORED_HASH" = "$CURRENT_HASH" ]; then
126
+ exit 0
127
+ fi
128
+ fi
104
129
  rm -f "${RDIR}/reducing-release"
105
- exit 0
106
130
  fi
107
131
  # CI-status precondition (P208): a green CI run on the target
108
132
  # branch is required before shipping. Fail-closed on gh errors.
package/hooks/hooks.json CHANGED
@@ -16,7 +16,7 @@
16
16
  { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wip-risk-mark.sh" }] },
17
17
  { "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-score-mark.sh" }] },
18
18
  { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-hash-refresh.sh" }] },
19
- { "matcher": "Agent|Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-slide-marker.sh" }] }
19
+ { "matcher": "Agent|Bash|Skill", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-slide-marker.sh" }] }
20
20
  ]
21
21
  }
22
22
  }
@@ -64,10 +64,25 @@ if [ -f "${RDIR}/clean" ]; then
64
64
  exit 0
65
65
  fi
66
66
 
67
- # Risk-reducing/neutral bypass
67
+ # Risk-reducing/neutral bypass — session-scoped, drift-revalidated (P192).
68
+ # Preserved across multiple commits while pipeline-state hash matches and
69
+ # TTL is unexpired; consumed on drift or TTL expiry so a genuine risk-
70
+ # profile change forces a fresh wr-risk-scorer:pipeline rescore. Mirrors
71
+ # the clean-marker persist-until-drift precedent (above) — distinct from
72
+ # incident-release / ci-bypass, which remain deliberate one-time overrides.
68
73
  if [ -f "${RDIR}/reducing-commit" ]; then
74
+ NOW=$(date +%s)
75
+ MARK_TIME=$(_mtime "${RDIR}/reducing-commit")
76
+ AGE=$(( NOW - MARK_TIME ))
77
+ TTL_SECONDS="${RISK_TTL:-3600}"
78
+ if [ "$AGE" -lt "$TTL_SECONDS" ] && [ -f "${RDIR}/state-hash" ]; then
79
+ STORED_HASH=$(cat "${RDIR}/state-hash")
80
+ CURRENT_HASH=$("$SCRIPT_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
81
+ if [ "$STORED_HASH" = "$CURRENT_HASH" ]; then
82
+ exit 0
83
+ fi
84
+ fi
69
85
  rm -f "${RDIR}/reducing-commit"
70
- exit 0
71
86
  fi
72
87
 
73
88
  # Gate check: existence, TTL, drift, threshold
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # Risk Scorer - PostToolUse:Agent|Bash slide-marker hook (P111).
2
+ # Risk Scorer - PostToolUse:Agent|Bash|Skill slide-marker hook (P111 + P213).
3
3
  # Slides the parent session's existing risk score files forward on
4
4
  # subprocess return, treating subprocess wall-clock as continuous parent-
5
5
  # session work for TTL purposes. Only TOUCHES existing score files — never
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env bats
2
+ # P192: Risk-reducing/within-appetite bypass markers (`reducing-commit`,
3
+ # `reducing-push`, `reducing-release`) must persist across multiple commits/
4
+ # pushes/releases within the standard TTL window AS LONG AS the pipeline-state
5
+ # hash still matches what was scored — eliminating the per-commit re-mint
6
+ # round-trip that drives the 3+-rescores-per-session friction. Drift or TTL
7
+ # expiry consumes the marker and forces a fresh `wr-risk-scorer:pipeline`
8
+ # rescore. `incident-release` remains single-use (deliberate one-time
9
+ # override).
10
+ #
11
+ # Behavioural contract:
12
+ # (a) reducing-* marker exists + tree hash matches stored state-hash + TTL
13
+ # not expired → gate allows AND marker persists (reusable).
14
+ # (b) reducing-* marker exists + tree hash differs from state-hash → marker
15
+ # consumed, gate falls through to check_risk_gate (which denies on
16
+ # drift or missing score).
17
+ # (c) reducing-* marker exists + TTL expired (relative to marker mtime) →
18
+ # marker consumed, gate falls through.
19
+ # (d) incident-release marker stays single-use (unchanged behaviour) —
20
+ # regression guard.
21
+ #
22
+ # Tests invoke the gate hooks directly (script + stdin JSON), the way the
23
+ # Claude Code hook runtime calls them.
24
+
25
+ setup() {
26
+ HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
27
+ COMMIT_GATE="$HOOKS_DIR/risk-score-commit-gate.sh"
28
+ PUSH_GATE="$HOOKS_DIR/git-push-gate.sh"
29
+
30
+ TEST_SESSION="bats-p192-$$-${BATS_TEST_NUMBER}"
31
+ RDIR="${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
32
+ rm -rf "$RDIR"
33
+ mkdir -p "$RDIR"
34
+
35
+ # Minimal git repo so pipeline-state.sh --hash-inputs produces a stable
36
+ # tree hash (git stash create needs a real repo).
37
+ TMP_REPO="$(mktemp -d)"
38
+ cd "$TMP_REPO"
39
+ git init -q -b main
40
+ git config user.email "test@example.com"
41
+ git config user.name "Test"
42
+ cat > RISK-POLICY.md <<EOF
43
+ # Risk Policy
44
+
45
+ Last reviewed: $(date -u +%Y-%m-%d)
46
+
47
+ ## Risk Appetite
48
+
49
+ Pipeline gates block when cumulative residual risk exceeds 4.
50
+ EOF
51
+ git add RISK-POLICY.md
52
+ git commit -q -m "initial"
53
+
54
+ # Default short TTL so we can exercise expiry without slow tests.
55
+ export RISK_TTL=5
56
+ }
57
+
58
+ teardown() {
59
+ rm -rf "$RDIR"
60
+ rm -rf "$TMP_REPO"
61
+ unset RISK_TTL 2>/dev/null || true
62
+ }
63
+
64
+ # Compute the current pipeline-state hash the same way the gate does
65
+ _current_hash() {
66
+ bash -c "
67
+ source '$HOOKS_DIR/lib/gate-helpers.sh'
68
+ '$HOOKS_DIR/lib/pipeline-state.sh' --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1
69
+ "
70
+ }
71
+
72
+ # Portable backdate by N seconds
73
+ _backdate() {
74
+ local file="$1" seconds="$2"
75
+ local stamp
76
+ stamp=$(date -v-${seconds}S +%Y%m%d%H%M.%S 2>/dev/null \
77
+ || date -d "${seconds} seconds ago" +%Y%m%d%H%M.%S 2>/dev/null)
78
+ touch -t "$stamp" "$file"
79
+ }
80
+
81
+ invoke_commit_gate() {
82
+ local cmd="$1"
83
+ local input
84
+ input=$(python3 -c "
85
+ import json, sys
86
+ print(json.dumps({
87
+ 'tool_name': 'Bash',
88
+ 'tool_input': {'command': sys.argv[1]},
89
+ 'session_id': sys.argv[2],
90
+ }))
91
+ " "$cmd" "$TEST_SESSION")
92
+ echo "$input" | bash "$COMMIT_GATE"
93
+ }
94
+
95
+ invoke_push_gate() {
96
+ local cmd="$1"
97
+ local input
98
+ input=$(python3 -c "
99
+ import json, sys
100
+ print(json.dumps({
101
+ 'tool_name': 'Bash',
102
+ 'tool_input': {'command': sys.argv[1]},
103
+ 'session_id': sys.argv[2],
104
+ }))
105
+ " "$cmd" "$TEST_SESSION")
106
+ echo "$input" | bash "$PUSH_GATE"
107
+ }
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Commit gate — reducing-commit persistence
111
+ # ---------------------------------------------------------------------------
112
+
113
+ @test "reducing-commit marker persists when tree hash matches stored state-hash" {
114
+ HASH=$(_current_hash)
115
+ echo "$HASH" > "$RDIR/state-hash"
116
+ touch "$RDIR/reducing-commit"
117
+
118
+ run invoke_commit_gate 'git commit -m "x"'
119
+ [ "$status" -eq 0 ]
120
+ [[ "$output" != *"deny"* ]]
121
+
122
+ # Marker MUST still exist after a successful allow — this is the load-
123
+ # bearing behaviour change.
124
+ [ -f "$RDIR/reducing-commit" ]
125
+ }
126
+
127
+ @test "reducing-commit marker survives back-to-back commits (no rescore round-trip)" {
128
+ HASH=$(_current_hash)
129
+ echo "$HASH" > "$RDIR/state-hash"
130
+ touch "$RDIR/reducing-commit"
131
+
132
+ # Three sequential allows — current single-use marker consumes on first,
133
+ # leaving #2 and #3 to fall through to check_risk_gate (which denies on
134
+ # missing score). New persistent-within-TTL contract: all three pass.
135
+ run invoke_commit_gate 'git commit -m "1"'; [ "$status" -eq 0 ]; [[ "$output" != *"deny"* ]]
136
+ run invoke_commit_gate 'git commit -m "2"'; [ "$status" -eq 0 ]; [[ "$output" != *"deny"* ]]
137
+ run invoke_commit_gate 'git commit -m "3"'; [ "$status" -eq 0 ]; [[ "$output" != *"deny"* ]]
138
+
139
+ [ -f "$RDIR/reducing-commit" ]
140
+ }
141
+
142
+ @test "reducing-commit marker is consumed when tree hash drifts from stored state-hash" {
143
+ echo "stale-hash-value-from-prior-tree" > "$RDIR/state-hash"
144
+ touch "$RDIR/reducing-commit"
145
+
146
+ run invoke_commit_gate 'git commit -m "x"'
147
+ # Marker MUST be consumed when drift detected.
148
+ [ ! -f "$RDIR/reducing-commit" ]
149
+ }
150
+
151
+ @test "reducing-commit marker is consumed when TTL has expired" {
152
+ HASH=$(_current_hash)
153
+ echo "$HASH" > "$RDIR/state-hash"
154
+ touch "$RDIR/reducing-commit"
155
+ _backdate "$RDIR/reducing-commit" 10 # TTL is 5
156
+
157
+ run invoke_commit_gate 'git commit -m "x"'
158
+ # TTL-expired marker MUST be consumed and the gate must NOT silently allow
159
+ # purely on marker presence.
160
+ [ ! -f "$RDIR/reducing-commit" ]
161
+ }
162
+
163
+ @test "reducing-commit marker without state-hash file is consumed (no invariance proof)" {
164
+ rm -f "$RDIR/state-hash"
165
+ touch "$RDIR/reducing-commit"
166
+
167
+ run invoke_commit_gate 'git commit -m "x"'
168
+ # No way to verify tree-invariance → consume the marker rather than ride it.
169
+ [ ! -f "$RDIR/reducing-commit" ]
170
+ }
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Push gate — reducing-push persistence
174
+ # ---------------------------------------------------------------------------
175
+
176
+ @test "reducing-push marker persists when tree hash matches stored state-hash" {
177
+ HASH=$(_current_hash)
178
+ echo "$HASH" > "$RDIR/state-hash"
179
+ touch "$RDIR/reducing-push"
180
+
181
+ run invoke_push_gate 'npm run push:watch'
182
+ [ "$status" -eq 0 ]
183
+ [[ "$output" != *"deny"* ]]
184
+
185
+ [ -f "$RDIR/reducing-push" ]
186
+ }
187
+
188
+ @test "reducing-push marker is consumed when tree hash drifts" {
189
+ echo "stale-hash" > "$RDIR/state-hash"
190
+ touch "$RDIR/reducing-push"
191
+
192
+ run invoke_push_gate 'npm run push:watch'
193
+ [ ! -f "$RDIR/reducing-push" ]
194
+ }
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Release gate — reducing-release persistence
198
+ # ---------------------------------------------------------------------------
199
+
200
+ @test "reducing-release marker persists when tree hash matches stored state-hash" {
201
+ HASH=$(_current_hash)
202
+ echo "$HASH" > "$RDIR/state-hash"
203
+ touch "$RDIR/reducing-release"
204
+
205
+ run invoke_push_gate 'npm run release:watch'
206
+ [ "$status" -eq 0 ]
207
+ [[ "$output" != *"deny"* ]]
208
+
209
+ [ -f "$RDIR/reducing-release" ]
210
+ }
211
+
212
+ @test "reducing-release marker is consumed when tree hash drifts" {
213
+ echo "stale-hash" > "$RDIR/state-hash"
214
+ touch "$RDIR/reducing-release"
215
+
216
+ run invoke_push_gate 'npm run release:watch'
217
+ [ ! -f "$RDIR/reducing-release" ]
218
+ }
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # incident-release — single-use regression guard
222
+ # ---------------------------------------------------------------------------
223
+
224
+ @test "incident-release marker REMAINS single-use (regression guard)" {
225
+ HASH=$(_current_hash)
226
+ echo "$HASH" > "$RDIR/state-hash"
227
+ touch "$RDIR/incident-release"
228
+
229
+ run invoke_push_gate 'npm run release:watch'
230
+ [ "$status" -eq 0 ]
231
+ [[ "$output" != *"deny"* ]]
232
+
233
+ # incident bypass is a deliberate one-time override — must be consumed
234
+ # even when tree hash matches.
235
+ [ ! -f "$RDIR/incident-release" ]
236
+ }
@@ -98,3 +98,21 @@ _backdate() {
98
98
  # Fail-safe: when the hook input cannot be parsed, treat as error and skip
99
99
  [ "$BEFORE" = "$AFTER" ]
100
100
  }
101
+
102
+ @test "slide: triggers correctly on Skill tool_response shape (P213, ADR-009 2026-06-08 amendment)" {
103
+ # hooks.json matcher expansion Agent|Bash → Agent|Bash|Skill (P213 Option D)
104
+ # widens slide-marker coverage to PostToolUse:Skill completions (e.g. the
105
+ # /wr-risk-scorer:assess-* sibling assessor SKILLs run as long subprocesses
106
+ # by the AFK orchestrator). The Skill tool_response shape is identical to
107
+ # Agent|Bash (Claude Code's uniform PostToolUse contract), so the matcher-
108
+ # agnostic helper composes without code changes. This test documents that
109
+ # contract explicitly so a future hook_input shape divergence regression
110
+ # surfaces here rather than at the gate-denial site.
111
+ touch "$MARKER"
112
+ _backdate "$MARKER" 60
113
+ BEFORE=$(_mtime "$MARKER")
114
+ _HOOK_INPUT='{"tool_name":"Skill","tool_response":{"content":[{"type":"text","text":"OK"}]}}'
115
+ slide_marker_on_subprocess_return "$MARKER"
116
+ AFTER=$(_mtime "$MARKER")
117
+ [ "$AFTER" -gt "$BEFORE" ]
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.12.7-preview.598",
3
+ "version": "0.12.8-preview.617",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"