@windyroad/risk-scorer 0.3.5-preview.188 → 0.3.6-preview.192
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 +7 -22
- package/hooks/lib/risk-gate.sh +48 -2
- package/hooks/risk-score-mark.sh +7 -3
- package/hooks/test/risk-gate.bats +114 -0
- package/package.json +1 -1
package/hooks/git-push-gate.sh
CHANGED
|
@@ -32,7 +32,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*git push(\s|$)'; then
|
|
|
32
32
|
exit 0
|
|
33
33
|
fi
|
|
34
34
|
|
|
35
|
-
# Gate push:watch on push risk score
|
|
35
|
+
# Gate push:watch on push risk score (inherits three-band TTL via check_risk_gate — P090)
|
|
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")
|
|
@@ -45,27 +45,12 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
|
|
|
45
45
|
if [ -f "${RDIR}/clean" ]; then
|
|
46
46
|
exit 0
|
|
47
47
|
fi
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
PUSH_SCORE_TIME=$(_mtime "$PUSH_SCORE_FILE")
|
|
55
|
-
PUSH_AGE=$(( PUSH_NOW - PUSH_SCORE_TIME ))
|
|
56
|
-
PUSH_TTL="${RISK_TTL:-3600}"
|
|
57
|
-
if [ "$PUSH_AGE" -ge "$PUSH_TTL" ]; then
|
|
58
|
-
risk_gate_deny "Push blocked: Push risk score expired (${PUSH_AGE}s old, TTL ${PUSH_TTL}s). Delegate to risk-scorer to rescore."
|
|
59
|
-
exit 0
|
|
60
|
-
fi
|
|
61
|
-
PUSH_SCORE=$(cat "$PUSH_SCORE_FILE" 2>/dev/null || echo "")
|
|
62
|
-
if ! echo "$PUSH_SCORE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
|
|
63
|
-
risk_gate_deny "Push blocked: Push risk score is not yet available (scoring in progress). Wait a moment and retry."
|
|
64
|
-
exit 0
|
|
65
|
-
fi
|
|
66
|
-
PUSH_DENIED=$(python3 -c "print('yes' if float('${PUSH_SCORE}') >= 5 else 'no')" 2>/dev/null || echo "no")
|
|
67
|
-
if [ "$PUSH_DENIED" = "yes" ]; then
|
|
68
|
-
risk_gate_deny "Push blocked: Push risk score ${PUSH_SCORE}/25 (Medium or above). To proceed: (1) release first via \`npm run release:watch\`, (2) split the push, or (3) add risk-reducing measures. If risk-neutral or risk-reducing, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') — it will create a bypass marker."
|
|
48
|
+
if ! check_risk_gate "$SESSION_ID" "push"; then
|
|
49
|
+
if [ "$RISK_GATE_CATEGORY" = "threshold" ]; then
|
|
50
|
+
risk_gate_deny "Push blocked: Push risk score ${RISK_GATE_SCORE}/25 (Medium or above). To proceed: (1) release first via \`npm run release:watch\`, (2) split the push, or (3) add risk-reducing measures. If risk-neutral or risk-reducing, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') — it will create a bypass marker."
|
|
51
|
+
else
|
|
52
|
+
risk_gate_deny "Push blocked: ${RISK_GATE_REASON}"
|
|
53
|
+
fi
|
|
69
54
|
exit 0
|
|
70
55
|
fi
|
|
71
56
|
fi
|
package/hooks/lib/risk-gate.sh
CHANGED
|
@@ -9,6 +9,18 @@ source "$_RISK_GATE_DIR/gate-helpers.sh"
|
|
|
9
9
|
|
|
10
10
|
# Check risk gate for a given action. Returns 0 if allowed, 1 if denied.
|
|
11
11
|
# Sets RISK_GATE_REASON on failure with human-readable message.
|
|
12
|
+
# Also sets RISK_GATE_CATEGORY ∈ {missing, expired, drift, invalid, threshold}
|
|
13
|
+
# and RISK_GATE_SCORE (on threshold) for callers that customise deny messages.
|
|
14
|
+
#
|
|
15
|
+
# Implements the three-band TTL policy (P090, ADR-009 footnote):
|
|
16
|
+
# Band A: age < TTL/2 → pass silently (no slide).
|
|
17
|
+
# Band B: TTL/2 ≤ age < TTL → if state-hash is invariant since the
|
|
18
|
+
# scorer ran, pass AND slide the marker
|
|
19
|
+
# forward (touch score file); bounded by
|
|
20
|
+
# a 2×TTL hard-cap from the scorer-run
|
|
21
|
+
# birth time stored in <action>-born.
|
|
22
|
+
# If the hash drifted, halt as before.
|
|
23
|
+
# Band C: age ≥ TTL → halt with the existing expired message.
|
|
12
24
|
# Usage: check_risk_gate "$SESSION_ID" "commit"
|
|
13
25
|
check_risk_gate() {
|
|
14
26
|
local SESSION_ID="$1"
|
|
@@ -16,24 +28,37 @@ check_risk_gate() {
|
|
|
16
28
|
local RDIR
|
|
17
29
|
RDIR=$(_risk_dir "$SESSION_ID")
|
|
18
30
|
local SCORE_FILE="${RDIR}/${ACTION}"
|
|
31
|
+
local BORN_FILE="${RDIR}/${ACTION}-born"
|
|
19
32
|
local HASH_FILE="${RDIR}/state-hash"
|
|
20
33
|
local TTL_SECONDS="${RISK_TTL:-3600}"
|
|
21
34
|
|
|
35
|
+
RISK_GATE_CATEGORY=""
|
|
36
|
+
RISK_GATE_SCORE=""
|
|
37
|
+
|
|
22
38
|
# 1. Score file must exist (fail-closed)
|
|
23
39
|
if [ ! -f "$SCORE_FILE" ]; then
|
|
40
|
+
RISK_GATE_CATEGORY="missing"
|
|
24
41
|
RISK_GATE_REASON="No ${ACTION} risk score found. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to assess cumulative pipeline risk."
|
|
25
42
|
return 1
|
|
26
43
|
fi
|
|
27
44
|
|
|
28
|
-
# 2. TTL
|
|
45
|
+
# 2. TTL — Band C hard expiry first
|
|
29
46
|
local NOW=$(date +%s)
|
|
30
47
|
local SCORE_TIME=$(_mtime "$SCORE_FILE")
|
|
31
48
|
local AGE=$(( NOW - SCORE_TIME ))
|
|
32
49
|
if [ "$AGE" -ge "$TTL_SECONDS" ]; then
|
|
50
|
+
RISK_GATE_CATEGORY="expired"
|
|
33
51
|
RISK_GATE_REASON="Risk score expired (${AGE}s old, TTL ${TTL_SECONDS}s). Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore."
|
|
34
52
|
return 1
|
|
35
53
|
fi
|
|
36
54
|
|
|
55
|
+
# Detect Band B candidacy (age in [TTL/2, TTL))
|
|
56
|
+
local HALF_TTL=$(( TTL_SECONDS / 2 ))
|
|
57
|
+
local BAND_B=0
|
|
58
|
+
if [ "$AGE" -ge "$HALF_TTL" ]; then
|
|
59
|
+
BAND_B=1
|
|
60
|
+
fi
|
|
61
|
+
|
|
37
62
|
# 3. Drift detection — pipeline state hash must match
|
|
38
63
|
# The hash is computed from git diff HEAD --stat at prompt submit time.
|
|
39
64
|
# If you staged files AFTER the prompt, the hash will differ.
|
|
@@ -43,15 +68,34 @@ check_risk_gate() {
|
|
|
43
68
|
local CURRENT_HASH
|
|
44
69
|
CURRENT_HASH=$("$_RISK_GATE_DIR/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
|
|
45
70
|
if [ "$STORED_HASH" != "$CURRENT_HASH" ]; then
|
|
71
|
+
RISK_GATE_CATEGORY="drift"
|
|
46
72
|
RISK_GATE_REASON="Pipeline state drift: working tree changed since the last ${ACTION} risk assessment. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore against the current state."
|
|
47
73
|
return 1
|
|
48
74
|
fi
|
|
75
|
+
|
|
76
|
+
# Band B + hash invariant: slide the marker forward, bounded by 2×TTL
|
|
77
|
+
# from the scorer-run birth time. The hard cap prevents an unchanged-but-
|
|
78
|
+
# perpetually-idle tree from riding a single marker indefinitely.
|
|
79
|
+
if [ "$BAND_B" = "1" ]; then
|
|
80
|
+
if [ -f "$BORN_FILE" ]; then
|
|
81
|
+
local BORN_TIME=$(_mtime "$BORN_FILE")
|
|
82
|
+
local BORN_AGE=$(( NOW - BORN_TIME ))
|
|
83
|
+
local HARD_CAP=$(( TTL_SECONDS * 2 ))
|
|
84
|
+
if [ "$BORN_AGE" -ge "$HARD_CAP" ]; then
|
|
85
|
+
RISK_GATE_CATEGORY="expired"
|
|
86
|
+
RISK_GATE_REASON="Risk score expired (${BORN_AGE}s total since scoring, hard cap ${HARD_CAP}s). Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore."
|
|
87
|
+
return 1
|
|
88
|
+
fi
|
|
89
|
+
fi
|
|
90
|
+
touch "$SCORE_FILE"
|
|
91
|
+
fi
|
|
49
92
|
fi
|
|
50
|
-
# No hash file = backward compat, skip drift check
|
|
93
|
+
# No hash file = backward compat, skip drift check and Band B slide
|
|
51
94
|
|
|
52
95
|
# 4. Read and validate score
|
|
53
96
|
local SCORE=$(cat "$SCORE_FILE" 2>/dev/null || echo "")
|
|
54
97
|
if ! echo "$SCORE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
|
|
98
|
+
RISK_GATE_CATEGORY="invalid"
|
|
55
99
|
RISK_GATE_REASON="Risk score file contains an invalid value. Re-run the risk-scorer agent."
|
|
56
100
|
return 1
|
|
57
101
|
fi
|
|
@@ -63,6 +107,8 @@ print('yes' if score >= 5 else 'no')
|
|
|
63
107
|
" 2>/dev/null || echo "no")
|
|
64
108
|
|
|
65
109
|
if [ "$DENIED" = "yes" ]; then
|
|
110
|
+
RISK_GATE_CATEGORY="threshold"
|
|
111
|
+
RISK_GATE_SCORE="$SCORE"
|
|
66
112
|
RISK_GATE_REASON="${ACTION} risk score ${SCORE}/25 (Medium or above). To proceed: (1) split the ${ACTION}, (2) add risk-reducing measures, or (3) for a LIVE INCIDENT, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') with incident context for an incident bypass."
|
|
67
113
|
return 1
|
|
68
114
|
fi
|
package/hooks/risk-score-mark.sh
CHANGED
|
@@ -42,9 +42,13 @@ if echo "$SUBAGENT" | grep -qE 'risk-scorer.pipeline'; then
|
|
|
42
42
|
PUSH=$(echo "$SCORES_LINE" | grep -oE 'push=[0-9]+' | cut -d= -f2) || true
|
|
43
43
|
RELEASE=$(echo "$SCORES_LINE" | grep -oE 'release=[0-9]+' | cut -d= -f2) || true
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
# Birth markers (<action>-born) capture the scorer-run timestamp. Band B
|
|
46
|
+
# of the three-band TTL policy (P090) uses them to enforce a 2×TTL
|
|
47
|
+
# hard-cap on sliding-window extension, so an unchanged-but-idle tree
|
|
48
|
+
# cannot ride a single score indefinitely.
|
|
49
|
+
[ -n "$COMMIT" ] && { printf '%s' "$COMMIT" > "${RDIR}/commit"; touch "${RDIR}/commit-born"; }
|
|
50
|
+
[ -n "$PUSH" ] && { printf '%s' "$PUSH" > "${RDIR}/push"; touch "${RDIR}/push-born"; }
|
|
51
|
+
[ -n "$RELEASE" ] && { printf '%s' "$RELEASE" > "${RDIR}/release"; touch "${RDIR}/release-born"; }
|
|
48
52
|
fi
|
|
49
53
|
|
|
50
54
|
# Parse RISK_BYPASS: reducing|incident
|
|
@@ -105,3 +105,117 @@ assert_gate_allows() {
|
|
|
105
105
|
[[ "$output" == *"deny"* ]]
|
|
106
106
|
[[ "$output" == *"Test reason"* ]]
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Three-band TTL policy (P090)
|
|
111
|
+
# Band A: age < TTL/2 → pass silently, no slide
|
|
112
|
+
# Band B: TTL/2 <= age < TTL → consult state-hash; if invariant, pass + slide
|
|
113
|
+
# the marker forward (touch score file) bounded by 2*TTL hard cap
|
|
114
|
+
# from the scorer-run birth time (<action>-born); if drifted, halt
|
|
115
|
+
# Band C: age >= TTL → halt with existing "expired" message
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
# Helper: backdate file mtime by N seconds (portable between macOS and Linux)
|
|
119
|
+
_backdate() {
|
|
120
|
+
local file="$1" seconds="$2"
|
|
121
|
+
local stamp
|
|
122
|
+
stamp=$(date -v-${seconds}S +%Y%m%d%H%M.%S 2>/dev/null \
|
|
123
|
+
|| date -d "${seconds} seconds ago" +%Y%m%d%H%M.%S 2>/dev/null)
|
|
124
|
+
touch -t "$stamp" "$file"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Helper: write a matching state-hash for the current working tree
|
|
128
|
+
_write_matching_hash() {
|
|
129
|
+
local target="$1"
|
|
130
|
+
local hash
|
|
131
|
+
hash=$("$HOOKS_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
|
|
132
|
+
echo "$hash" > "$target"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@test "Band A (age < TTL/2): passes, does NOT slide the marker" {
|
|
136
|
+
printf '3' > "$SCORE_FILE"
|
|
137
|
+
touch "$SCORE_FILE"
|
|
138
|
+
rm -f "$HASH_FILE"
|
|
139
|
+
BEFORE_MTIME=$(_mtime "$SCORE_FILE")
|
|
140
|
+
sleep 1
|
|
141
|
+
assert_gate_allows "$TEST_SESSION" "commit"
|
|
142
|
+
AFTER_MTIME=$(_mtime "$SCORE_FILE")
|
|
143
|
+
[ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@test "Band B (TTL/2 <= age < TTL) with hash invariant: passes AND slides marker forward" {
|
|
147
|
+
printf '3' > "$SCORE_FILE"
|
|
148
|
+
_backdate "$SCORE_FILE" 3
|
|
149
|
+
_write_matching_hash "$HASH_FILE"
|
|
150
|
+
touch "${SCORE_FILE}-born"
|
|
151
|
+
BEFORE_MTIME=$(_mtime "$SCORE_FILE")
|
|
152
|
+
assert_gate_allows "$TEST_SESSION" "commit"
|
|
153
|
+
AFTER_MTIME=$(_mtime "$SCORE_FILE")
|
|
154
|
+
[ "$AFTER_MTIME" -gt "$BEFORE_MTIME" ]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@test "Band B with no hash file: passes but does NOT slide (no invariance proof)" {
|
|
158
|
+
printf '3' > "$SCORE_FILE"
|
|
159
|
+
_backdate "$SCORE_FILE" 3
|
|
160
|
+
rm -f "$HASH_FILE"
|
|
161
|
+
BEFORE_MTIME=$(_mtime "$SCORE_FILE")
|
|
162
|
+
sleep 1
|
|
163
|
+
assert_gate_allows "$TEST_SESSION" "commit"
|
|
164
|
+
AFTER_MTIME=$(_mtime "$SCORE_FILE")
|
|
165
|
+
[ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@test "Band B with hash mismatch: denies with drift (no slide)" {
|
|
169
|
+
printf '3' > "$SCORE_FILE"
|
|
170
|
+
_backdate "$SCORE_FILE" 3
|
|
171
|
+
echo "staleold" > "$HASH_FILE"
|
|
172
|
+
touch "${SCORE_FILE}-born"
|
|
173
|
+
BEFORE_MTIME=$(_mtime "$SCORE_FILE")
|
|
174
|
+
assert_gate_denies "$TEST_SESSION" "commit" "drift"
|
|
175
|
+
AFTER_MTIME=$(_mtime "$SCORE_FILE")
|
|
176
|
+
[ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@test "Band B with hard-cap exceeded (born-age >= 2*TTL): denies even if hash invariant" {
|
|
180
|
+
printf '3' > "$SCORE_FILE"
|
|
181
|
+
_backdate "$SCORE_FILE" 3
|
|
182
|
+
_write_matching_hash "$HASH_FILE"
|
|
183
|
+
touch "${SCORE_FILE}-born"
|
|
184
|
+
_backdate "${SCORE_FILE}-born" 12
|
|
185
|
+
assert_gate_denies "$TEST_SESSION" "commit" "expired"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@test "Band C (age >= TTL): denies regardless of hash invariance" {
|
|
189
|
+
printf '3' > "$SCORE_FILE"
|
|
190
|
+
_backdate "$SCORE_FILE" 10
|
|
191
|
+
_write_matching_hash "$HASH_FILE"
|
|
192
|
+
touch "${SCORE_FILE}-born"
|
|
193
|
+
assert_gate_denies "$TEST_SESSION" "commit" "expired"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@test "Band B denial exports RISK_GATE_CATEGORY=drift on hash mismatch" {
|
|
197
|
+
printf '3' > "$SCORE_FILE"
|
|
198
|
+
_backdate "$SCORE_FILE" 3
|
|
199
|
+
echo "staleold" > "$HASH_FILE"
|
|
200
|
+
RISK_GATE_CATEGORY=""
|
|
201
|
+
! check_risk_gate "$TEST_SESSION" "commit"
|
|
202
|
+
[ "$RISK_GATE_CATEGORY" = "drift" ]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@test "Band C denial exports RISK_GATE_CATEGORY=expired" {
|
|
206
|
+
printf '3' > "$SCORE_FILE"
|
|
207
|
+
_backdate "$SCORE_FILE" 10
|
|
208
|
+
RISK_GATE_CATEGORY=""
|
|
209
|
+
! check_risk_gate "$TEST_SESSION" "commit"
|
|
210
|
+
[ "$RISK_GATE_CATEGORY" = "expired" ]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@test "Threshold denial exports RISK_GATE_CATEGORY=threshold and RISK_GATE_SCORE" {
|
|
214
|
+
printf '7' > "$SCORE_FILE"
|
|
215
|
+
touch "$SCORE_FILE"
|
|
216
|
+
RISK_GATE_CATEGORY=""
|
|
217
|
+
RISK_GATE_SCORE=""
|
|
218
|
+
! check_risk_gate "$TEST_SESSION" "commit"
|
|
219
|
+
[ "$RISK_GATE_CATEGORY" = "threshold" ]
|
|
220
|
+
[ "$RISK_GATE_SCORE" = "7" ]
|
|
221
|
+
}
|
package/package.json
CHANGED