@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.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/git-push-gate.sh +28 -4
- package/hooks/hooks.json +1 -1
- package/hooks/risk-score-commit-gate.sh +17 -2
- package/hooks/risk-slide-marker.sh +1 -1
- package/hooks/test/reducing-marker-persistence.bats +236 -0
- package/hooks/test/slide-marker-on-subprocess-return.bats +18 -0
- package/package.json +1 -1
package/hooks/git-push-gate.sh
CHANGED
|
@@ -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