@windyroad/risk-scorer 0.13.1 → 0.13.3
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.
|
@@ -185,28 +185,59 @@ except Exception:
|
|
|
185
185
|
DRAFT=$(printf '%s' "$COMMAND" | python3 -c "
|
|
186
186
|
import sys, re
|
|
187
187
|
cmd = sys.stdin.read()
|
|
188
|
-
#
|
|
188
|
+
# P364: bash double-quote unescape. The double-quoted body capture groups
|
|
189
|
+
# carry RAW shell-escaped command text — an orchestrator must backslash-escape
|
|
190
|
+
# backticks (and \$, \", \\) inside \"...\" to survive bash parsing, e.g.
|
|
191
|
+
# --body \"Fixed in \\\`code\\\` ...\". The PostToolUse mark hook hashes the
|
|
192
|
+
# LOGICAL <draft> body (plain backticks), so the gate must undo those escapes
|
|
193
|
+
# or the two marker keys diverge → permanent deny-after-PASS. Inside double
|
|
194
|
+
# quotes a backslash is special ONLY before \$ \` \" \\ or a newline (line
|
|
195
|
+
# continuation); single-quoted and <<'EOF' forms are literal and need none.
|
|
196
|
+
# Single left-to-right pass so an escaped backslash adjacent to another escape
|
|
197
|
+
# (\\\\\` -> backslash + backtick) is NOT mis-collapsed. chr() literals keep
|
|
198
|
+
# this source free of the very metacharacters the surrounding shell double
|
|
199
|
+
# quotes would otherwise eat.
|
|
200
|
+
def unescape_dq(s):
|
|
201
|
+
out = []
|
|
202
|
+
i = 0
|
|
203
|
+
n = len(s)
|
|
204
|
+
special = set([chr(36), chr(96), chr(34), chr(92), chr(10)])
|
|
205
|
+
while i < n:
|
|
206
|
+
if s[i] == chr(92) and i + 1 < n and s[i + 1] in special:
|
|
207
|
+
if s[i + 1] != chr(10):
|
|
208
|
+
out.append(s[i + 1])
|
|
209
|
+
i += 2
|
|
210
|
+
else:
|
|
211
|
+
out.append(s[i])
|
|
212
|
+
i += 1
|
|
213
|
+
return ''.join(out)
|
|
214
|
+
# (pattern, flags, unescape) — first match wins. unescape=True for the
|
|
215
|
+
# double-quoted forms only (P364).
|
|
189
216
|
patterns = [
|
|
190
217
|
# HEREDOC body — matches a here-doc with EOF delimiter (quoted or
|
|
191
218
|
# unquoted). The literal '<<' is written as the char-class pair
|
|
192
219
|
# [<][<] so bash's command-substitution parser does NOT mis-parse
|
|
193
220
|
# this regex as a real here-doc operator (P082 implementation note).
|
|
194
|
-
# DOTALL so the body can span newlines.
|
|
195
|
-
|
|
221
|
+
# DOTALL so the body can span newlines. Left literal: the AI-canonical
|
|
222
|
+
# form is the quoted <<'EOF' heredoc, whose body bash does not unescape.
|
|
223
|
+
(r\"[<][<]\s*['\\\"]?EOF['\\\"]?\s*\n(.*?)\nEOF\", re.DOTALL, False),
|
|
196
224
|
# gh issue/pr + npm publish --body 'TEXT' / --body \"TEXT\" (existing).
|
|
197
|
-
(r\"--body[= ]'([^']*)'\", 0),
|
|
198
|
-
(r'--body[= ]\"([^\"]*)\"', 0),
|
|
225
|
+
(r\"--body[= ]'([^']*)'\", 0, False),
|
|
226
|
+
(r'--body[= ]\"([^\"]*)\"', 0, True),
|
|
199
227
|
# gh api --field summary='TEXT' / --field summary=\"TEXT\" (existing).
|
|
200
|
-
(r\"--field [a-zA-Z_]+='([^']*)'\", 0),
|
|
201
|
-
(r'--field [a-zA-Z_]+=\"([^\"]*)\"', 0),
|
|
228
|
+
(r\"--field [a-zA-Z_]+='([^']*)'\", 0, False),
|
|
229
|
+
(r'--field [a-zA-Z_]+=\"([^\"]*)\"', 0, True),
|
|
202
230
|
# git commit -m / --message single-line literal forms (P082 Phase 1).
|
|
203
|
-
(r\"(?:-m|--message)[= ]'([^']*)'\", 0),
|
|
204
|
-
(r'(?:-m|--message)[= ]\"([^\"]*)\"', 0),
|
|
231
|
+
(r\"(?:-m|--message)[= ]'([^']*)'\", 0, False),
|
|
232
|
+
(r'(?:-m|--message)[= ]\"([^\"]*)\"', 0, True),
|
|
205
233
|
]
|
|
206
|
-
for pat, flags in patterns:
|
|
234
|
+
for pat, flags, unescape in patterns:
|
|
207
235
|
m = re.search(pat, cmd, flags)
|
|
208
236
|
if m:
|
|
209
|
-
|
|
237
|
+
body = m.group(1)
|
|
238
|
+
if unescape:
|
|
239
|
+
body = unescape_dq(body)
|
|
240
|
+
print(body)
|
|
210
241
|
break
|
|
211
242
|
" 2>/dev/null || echo "")
|
|
212
243
|
;;
|
|
@@ -287,6 +318,28 @@ if [ "$EXTERNAL_COMMS_LEAK_PREFILTER" = "yes" ]; then
|
|
|
287
318
|
fi
|
|
288
319
|
fi
|
|
289
320
|
|
|
321
|
+
# ---------- Repo-visibility precondition: git-commit-message surface (P365) ----------
|
|
322
|
+
# A commit message only becomes external-facing prose when it lands in a PUBLIC
|
|
323
|
+
# GitHub repo (git log / PR commits tab / release-page auto-notes / CHANGELOG).
|
|
324
|
+
# In private or internal repos the marker-review delegation deny below is a pure
|
|
325
|
+
# false-positive (P365 — user direction 2026-06-11: "this MUST NOT fire for
|
|
326
|
+
# private repos"). Confirm visibility authoritatively via gh and silent-pass the
|
|
327
|
+
# marker gate on any non-PUBLIC result. Any INDETERMINATE result (gh absent,
|
|
328
|
+
# unauthenticated, no remote, API error → empty $REPO_VISIBILITY) is treated as
|
|
329
|
+
# non-public: a commit message is only demonstrably external when the repo is
|
|
330
|
+
# confirmably PUBLIC, so the conservative direction for THIS surface is to not
|
|
331
|
+
# fire. This is a fail-open on the voice/tone-and-prose review ONLY — the
|
|
332
|
+
# leak-pattern pre-filter above (credentials / prod-URLs) has already run for
|
|
333
|
+
# every surface in every repo, so the high-stakes secrecy net is unaffected.
|
|
334
|
+
# Scoped to git-commit-message only; the gh-issue/pr/api, npm-publish, and
|
|
335
|
+
# changeset-author surfaces are inherently external and stay gated regardless.
|
|
336
|
+
if [ "$SURFACE" = "git-commit-message" ]; then
|
|
337
|
+
REPO_VISIBILITY=$(gh repo view --json visibility -q .visibility 2>/dev/null || echo "")
|
|
338
|
+
if [ "$REPO_VISIBILITY" != "PUBLIC" ]; then
|
|
339
|
+
exit 0
|
|
340
|
+
fi
|
|
341
|
+
fi
|
|
342
|
+
|
|
290
343
|
# ---------- Marker-based gate (per-evaluator marker per ADR-028 amended 2026-05-14) ----------
|
|
291
344
|
SESSION_DIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
|
|
292
345
|
mkdir -p "$SESSION_DIR"
|
|
@@ -65,10 +65,28 @@ print(json.dumps({
|
|
|
65
65
|
" "$file_path" "$content"
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
# Mock `gh repo view --json visibility` for the git-commit-message surface
|
|
69
|
+
# repo-visibility precondition (P365). vis ∈ {PUBLIC,PRIVATE,INTERNAL}; pass the
|
|
70
|
+
# literal "FAIL" to simulate gh absent / unauthenticated (non-zero exit). The
|
|
71
|
+
# mock ignores args and prints the chosen visibility so the hook's
|
|
72
|
+
# `gh repo view --json visibility -q .visibility` resolves deterministically.
|
|
73
|
+
mock_gh_visibility() {
|
|
74
|
+
local vis="$1"
|
|
75
|
+
mkdir -p "$TEST_PROJECT_DIR/mockbin"
|
|
76
|
+
if [ "$vis" = "FAIL" ]; then
|
|
77
|
+
printf '#!/usr/bin/env bash\nexit 1\n' > "$TEST_PROJECT_DIR/mockbin/gh"
|
|
78
|
+
else
|
|
79
|
+
printf '#!/usr/bin/env bash\necho %s\n' "$vis" > "$TEST_PROJECT_DIR/mockbin/gh"
|
|
80
|
+
fi
|
|
81
|
+
chmod +x "$TEST_PROJECT_DIR/mockbin/gh"
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
# Run the hook in a project dir with RISK-POLICY.md present, piping JSON via stdin.
|
|
85
|
+
# mockbin (if populated by mock_gh_visibility) is prepended to PATH so the
|
|
86
|
+
# git-commit-message visibility precondition resolves against the mock.
|
|
69
87
|
run_hook() {
|
|
70
88
|
local input="$1"
|
|
71
|
-
run bash -c "cd '$TEST_PROJECT_DIR' && printf '%s' \"\$1\" | '$HOOK'" _ "$input"
|
|
89
|
+
run bash -c "cd '$TEST_PROJECT_DIR' && export PATH='$TEST_PROJECT_DIR/mockbin':\$PATH && printf '%s' \"\$1\" | '$HOOK'" _ "$input"
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
# ---------- Tests ----------
|
|
@@ -244,6 +262,7 @@ run_hook() {
|
|
|
244
262
|
}
|
|
245
263
|
|
|
246
264
|
@test "P082: git commit -m with leak-free body denies and delegates to risk evaluator" {
|
|
265
|
+
mock_gh_visibility PUBLIC
|
|
247
266
|
INPUT=$(build_bash_input "git commit -m \"fix(foo): handle null input\"")
|
|
248
267
|
run_hook "$INPUT"
|
|
249
268
|
[ "$status" -eq 0 ]
|
|
@@ -253,6 +272,7 @@ run_hook() {
|
|
|
253
272
|
}
|
|
254
273
|
|
|
255
274
|
@test "P082: git commit --amend -m is intercepted (P082 SC2)" {
|
|
275
|
+
mock_gh_visibility PUBLIC
|
|
256
276
|
INPUT=$(build_bash_input "git commit --amend -m \"rewritten subject\"")
|
|
257
277
|
run_hook "$INPUT"
|
|
258
278
|
[ "$status" -eq 0 ]
|
|
@@ -264,6 +284,7 @@ run_hook() {
|
|
|
264
284
|
# Build a HEREDOC-shaped command. The hook regex pulls the body BETWEEN
|
|
265
285
|
# the <<'EOF' opener and the closing EOF marker — the extracted DRAFT is
|
|
266
286
|
# the inner text, NOT the literal `$(cat <<'EOF' ... EOF)` wrapper.
|
|
287
|
+
mock_gh_visibility PUBLIC
|
|
267
288
|
BODY=$'feat(foo): add bar\n\nWe observed a build failure on Node 20.'
|
|
268
289
|
CMD=$'git commit -m "$(cat <<\'EOF\'\n'"$BODY"$'\nEOF\n)"'
|
|
269
290
|
INPUT=$(build_bash_input "$CMD")
|
|
@@ -306,6 +327,7 @@ run_hook() {
|
|
|
306
327
|
}
|
|
307
328
|
|
|
308
329
|
@test "P082: per-evaluator marker keyed on (body, git-commit-message) permits the call" {
|
|
330
|
+
mock_gh_visibility PUBLIC
|
|
309
331
|
BODY="docs(retro): close iter 3 ask-hygiene trail"
|
|
310
332
|
SURFACE="git-commit-message"
|
|
311
333
|
KEY=$(printf '%s\n%s' "$BODY" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
@@ -316,3 +338,132 @@ run_hook() {
|
|
|
316
338
|
[ "$status" -eq 0 ]
|
|
317
339
|
[ -z "$output" ]
|
|
318
340
|
}
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# P365 — repo-visibility precondition on the git-commit-message surface.
|
|
344
|
+
# A commit message is external-facing prose ONLY when it lands in a PUBLIC
|
|
345
|
+
# GitHub repo (git log / PR commits tab / release notes / CHANGELOG). In
|
|
346
|
+
# private/internal repos — or any repo whose visibility cannot be confirmed
|
|
347
|
+
# PUBLIC — the marker-review delegation deny is a pure false-positive
|
|
348
|
+
# (user direction 2026-06-11: "this MUST NOT fire for private repos"). The
|
|
349
|
+
# precondition silent-passes the marker gate for the git-commit-message
|
|
350
|
+
# surface on any non-PUBLIC / indeterminate gh result. It is scoped to that
|
|
351
|
+
# surface only and runs AFTER the leak pre-filter, so the credential/prod-URL
|
|
352
|
+
# security net survives the short-circuit.
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
@test "P365: git commit -m in a PRIVATE repo silent-passes (no external-comms deny)" {
|
|
356
|
+
mock_gh_visibility PRIVATE
|
|
357
|
+
INPUT=$(build_bash_input "git commit -m \"fix(foo): handle null input\"")
|
|
358
|
+
run_hook "$INPUT"
|
|
359
|
+
[ "$status" -eq 0 ]
|
|
360
|
+
[ -z "$output" ]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@test "P365: git commit -m in an INTERNAL repo silent-passes" {
|
|
364
|
+
mock_gh_visibility INTERNAL
|
|
365
|
+
INPUT=$(build_bash_input "git commit -m \"fix(foo): handle null input\"")
|
|
366
|
+
run_hook "$INPUT"
|
|
367
|
+
[ "$status" -eq 0 ]
|
|
368
|
+
[ -z "$output" ]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@test "P365: git commit -m when gh is unavailable/indeterminate silent-passes (fail-non-public)" {
|
|
372
|
+
mock_gh_visibility FAIL
|
|
373
|
+
INPUT=$(build_bash_input "git commit -m \"fix(foo): handle null input\"")
|
|
374
|
+
run_hook "$INPUT"
|
|
375
|
+
[ "$status" -eq 0 ]
|
|
376
|
+
[ -z "$output" ]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@test "P365: git commit -m in a PUBLIC repo still denies+delegates (gate intact, precondition surface-scoped)" {
|
|
380
|
+
mock_gh_visibility PUBLIC
|
|
381
|
+
INPUT=$(build_bash_input "git commit -m \"fix(foo): handle null input\"")
|
|
382
|
+
run_hook "$INPUT"
|
|
383
|
+
[ "$status" -eq 0 ]
|
|
384
|
+
[[ "$output" == *"deny"* ]]
|
|
385
|
+
[[ "$output" == *"git-commit-message"* ]]
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@test "P365: leak-shaped credential in a PRIVATE-repo commit body still hard-fails (security net survives)" {
|
|
389
|
+
mock_gh_visibility PRIVATE
|
|
390
|
+
INPUT=$(build_bash_input "git commit -m \"docs: token=${GH_TOKEN_LIKE}\"")
|
|
391
|
+
run_hook "$INPUT"
|
|
392
|
+
[ "$status" -eq 0 ]
|
|
393
|
+
[[ "$output" == *"deny"* ]]
|
|
394
|
+
[[ "$output" == *"git-commit-message"* ]]
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@test "P365: PRIVATE visibility does NOT short-circuit the gh-issue surface (still denies+delegates)" {
|
|
398
|
+
mock_gh_visibility PRIVATE
|
|
399
|
+
INPUT=$(build_bash_input "gh issue create --title x --body 'a clean issue body'")
|
|
400
|
+
run_hook "$INPUT"
|
|
401
|
+
[ "$status" -eq 0 ]
|
|
402
|
+
[[ "$output" == *"deny"* ]]
|
|
403
|
+
[[ "$output" == *"gh-issue-create"* ]]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# P364 — backtick-bearing double-quoted --body marker-key mismatch.
|
|
408
|
+
# When an outbound body contains markdown backticks (a code span), the
|
|
409
|
+
# orchestrator must backslash-escape them to survive bash double quotes:
|
|
410
|
+
# `gh issue comment 42 --body "Fixed in \`foo\` ..."`. Before the fix the
|
|
411
|
+
# gate extracted the RAW shell-escaped body (literal `\`` with backslash)
|
|
412
|
+
# from the command text, while the PostToolUse mark hook hashed the LOGICAL
|
|
413
|
+
# <draft> body (plain backticks). The two sha256 keys diverged → the PASS
|
|
414
|
+
# marker landed at a key the gate never re-read → permanent deny-after-PASS.
|
|
415
|
+
# After the fix the gate unescapes bash double-quote backslash-escapes on
|
|
416
|
+
# the captured double-quoted body so its DRAFT is byte-equal to the logical
|
|
417
|
+
# reviewed draft and the body-keyed marker permits. DISTINCT from P276 /
|
|
418
|
+
# P010 (whitespace / frontmatter) — this is a shell-escaping-layer fix.
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
@test "P364: backtick-bearing double-quoted --body permits when marker keyed on the unescaped logical body" {
|
|
422
|
+
# Logical draft body (plain backticks) — what the agent wraps in <draft>
|
|
423
|
+
# and what the mark hook hashes via compute_external_comms_key.
|
|
424
|
+
LOGICAL='Fixed in `compute_external_comms_key` per the patch on Node 20.'
|
|
425
|
+
SURFACE="gh-issue-comment"
|
|
426
|
+
KEY=$(printf '%s\n%s' "$LOGICAL" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
427
|
+
touch "${RDIR}/external-comms-risk-reviewed-${KEY}"
|
|
428
|
+
|
|
429
|
+
# The orchestrator backslash-escapes the backticks to survive bash double
|
|
430
|
+
# quotes — this is the byte-different command text the gate actually sees.
|
|
431
|
+
CMD='gh issue comment 42 --body "Fixed in \`compute_external_comms_key\` per the patch on Node 20."'
|
|
432
|
+
INPUT=$(build_bash_input "$CMD")
|
|
433
|
+
run_hook "$INPUT"
|
|
434
|
+
[ "$status" -eq 0 ]
|
|
435
|
+
[ -z "$output" ]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
@test "P364: escaped backslash adjacent to escaped backtick (\\\\\\\`) unescapes to literal backslash + backtick (single left-to-right pass)" {
|
|
439
|
+
# Adjacency edge case (architect note): \\\` is escaped-backslash THEN
|
|
440
|
+
# escaped-backtick and must unescape to a literal backslash followed by a
|
|
441
|
+
# literal backtick — NOT mis-collapsed. The logical body therefore carries
|
|
442
|
+
# one backslash then one backtick.
|
|
443
|
+
LOGICAL=$'edge: \\`token here'
|
|
444
|
+
SURFACE="gh-issue-comment"
|
|
445
|
+
KEY=$(printf '%s\n%s' "$LOGICAL" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
446
|
+
touch "${RDIR}/external-comms-risk-reviewed-${KEY}"
|
|
447
|
+
|
|
448
|
+
# Command text carries \\\` (escaped backslash + escaped backtick).
|
|
449
|
+
CMD='gh issue comment 42 --body "edge: \\\`token here"'
|
|
450
|
+
INPUT=$(build_bash_input "$CMD")
|
|
451
|
+
run_hook "$INPUT"
|
|
452
|
+
[ "$status" -eq 0 ]
|
|
453
|
+
[ -z "$output" ]
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@test "P364: single-quoted --body with literal backticks stays literal (no unescaping applied)" {
|
|
457
|
+
# Inside single quotes bash does NOT process backslashes or backticks, so
|
|
458
|
+
# the orchestrator writes plain backticks and the captured body already
|
|
459
|
+
# equals the logical draft. The fix must NOT alter the single-quote path.
|
|
460
|
+
LOGICAL='Fixed in `plain_span` here.'
|
|
461
|
+
SURFACE="gh-issue-comment"
|
|
462
|
+
KEY=$(printf '%s\n%s' "$LOGICAL" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
463
|
+
touch "${RDIR}/external-comms-risk-reviewed-${KEY}"
|
|
464
|
+
|
|
465
|
+
INPUT=$(build_bash_input "gh issue comment 42 --body 'Fixed in \`plain_span\` here.'")
|
|
466
|
+
run_hook "$INPUT"
|
|
467
|
+
[ "$status" -eq 0 ]
|
|
468
|
+
[ -z "$output" ]
|
|
469
|
+
}
|