@windyroad/risk-scorer 0.4.1 → 0.4.2-preview.218
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/README.md +46 -2
- package/agents/external-comms.md +94 -0
- package/hooks/external-comms-gate.sh +203 -0
- package/hooks/hooks.json +1 -0
- package/hooks/lib/leak-detect.sh +99 -0
- package/hooks/lib/session-marker.sh +46 -0
- package/hooks/plan-risk-guidance.sh +22 -2
- package/hooks/risk-score-mark.sh +20 -0
- package/hooks/test/external-comms-gate.bats +174 -0
- package/hooks/test/plan-risk-guidance-once-per-session.bats +95 -0
- package/package.json +1 -1
- package/skills/assess-external-comms/SKILL.md +96 -0
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ The risk-scorer plugin brings ISO 31000-aligned risk management to your AI codin
|
|
|
13
13
|
3. **Gates pushes** -- blocks `git push` for high-risk changesets (use `npm run push:watch` instead)
|
|
14
14
|
4. **Detects secrets** -- scans edits for API keys, tokens, passwords, and other credentials before they're written
|
|
15
15
|
5. **Reviews plans** -- scores implementation plans for risk before you start building
|
|
16
|
+
6. **Gates outbound prose** -- reviews `gh issue/pr` bodies, security advisories, npm publish content, and `.changeset/*.md` drafts for confidential-information leaks before they reach external surfaces
|
|
16
17
|
|
|
17
18
|
All thresholds are configurable through your project's `RISK-POLICY.md`.
|
|
18
19
|
|
|
@@ -46,13 +47,14 @@ This creates a `RISK-POLICY.md` tailored to your project, defining impact levels
|
|
|
46
47
|
| `risk-score-commit-gate.sh` | Bash (git commit) | Blocks commits when risk exceeds threshold |
|
|
47
48
|
| `risk-score-plan-enforce.sh` | ExitPlanMode | Ensures plans are risk-scored before execution |
|
|
48
49
|
| `plan-risk-guidance.sh` | EnterPlanMode | Injects risk guidance into plan mode |
|
|
50
|
+
| `external-comms-gate.sh` | Bash, Edit, Write | Gates outbound prose (`gh issue/pr`, `gh api .../security-advisories`, `npm publish`, `.changeset/*.md`) on confidential-information leak review |
|
|
49
51
|
| `wip-risk-mark.sh` | After edit | Records WIP risk assessment |
|
|
50
|
-
| `risk-score-mark.sh` | Agent completes | Marks risk review as done |
|
|
52
|
+
| `risk-score-mark.sh` | Agent completes | Marks risk review as done; writes external-comms marker on `wr-risk-scorer:external-comms` PASS |
|
|
51
53
|
| `risk-hash-refresh.sh` | After Bash | Refreshes content hashes |
|
|
52
54
|
|
|
53
55
|
## Agents
|
|
54
56
|
|
|
55
|
-
The plugin includes
|
|
57
|
+
The plugin includes six specialised agents:
|
|
56
58
|
|
|
57
59
|
| Agent | Purpose |
|
|
58
60
|
|-------|---------|
|
|
@@ -61,6 +63,48 @@ The plugin includes five specialised agents:
|
|
|
61
63
|
| `wr-risk-scorer:pipeline` | Scores pipeline actions (commit, push, release) |
|
|
62
64
|
| `wr-risk-scorer:plan` | Reviews implementation plans for risk |
|
|
63
65
|
| `wr-risk-scorer:policy` | Validates `RISK-POLICY.md` for ISO 31000 compliance |
|
|
66
|
+
| `wr-risk-scorer:external-comms` | Reviews drafts of outbound prose (gh issues/PRs, advisories, npm publish, changeset bodies) for confidential-information leaks per `RISK-POLICY.md` |
|
|
67
|
+
|
|
68
|
+
## On-demand assessment skills
|
|
69
|
+
|
|
70
|
+
| Skill | Purpose |
|
|
71
|
+
|-------|---------|
|
|
72
|
+
| `/wr-risk-scorer:assess-wip` | WIP risk nudge for the current uncommitted diff |
|
|
73
|
+
| `/wr-risk-scorer:assess-release` | Pipeline risk assessment for the unpushed queue (pre-satisfies the commit gate) |
|
|
74
|
+
| `/wr-risk-scorer:assess-external-comms` | External-comms leak review for a draft outbound body (pre-satisfies the external-comms gate) |
|
|
75
|
+
| `/wr-risk-scorer:create-risk` | Create a standing-risk register entry |
|
|
76
|
+
| `/wr-risk-scorer:update-policy` | Generate or update `RISK-POLICY.md` |
|
|
77
|
+
|
|
78
|
+
## External-comms gate
|
|
79
|
+
|
|
80
|
+
The `external-comms-gate.sh` hook intercepts outbound prose tool calls and the
|
|
81
|
+
`.changeset/*.md` author surface so confidential-information leaks are caught
|
|
82
|
+
before they reach a public or vendor-private channel.
|
|
83
|
+
|
|
84
|
+
Gated surfaces:
|
|
85
|
+
- `gh issue create` / `gh issue comment` / `gh issue edit`
|
|
86
|
+
- `gh pr create` / `gh pr comment` / `gh pr edit`
|
|
87
|
+
- `gh api .../security-advisories` and `gh api .../comments`
|
|
88
|
+
- `npm publish`
|
|
89
|
+
- `PreToolUse:Write` and `PreToolUse:Edit` on `.changeset/*.md` (P073 — gated at author time, before the changeset body lands in CHANGELOG.md and every published npm tarball)
|
|
90
|
+
|
|
91
|
+
Behaviour:
|
|
92
|
+
1. A hybrid regex pre-filter (`hooks/lib/leak-detect.sh`) catches high-confidence
|
|
93
|
+
leak shapes (credentials, business-context-paired financial figures and
|
|
94
|
+
user-counts) and denies immediately with the matched class.
|
|
95
|
+
2. Anything not pre-filtered is delegated to the `wr-risk-scorer:external-comms`
|
|
96
|
+
subagent for context-aware review against `RISK-POLICY.md` Confidential
|
|
97
|
+
Information classes. The PostToolUse marker hook writes a per-draft marker
|
|
98
|
+
on `EXTERNAL_COMMS_RISK_VERDICT: PASS`.
|
|
99
|
+
3. If `RISK-POLICY.md` is absent, the gate runs in advisory-only mode and
|
|
100
|
+
permits the call (graceful adoption).
|
|
101
|
+
|
|
102
|
+
Override: `BYPASS_RISK_GATE=1` short-circuits the gate (consistent with
|
|
103
|
+
`git-push-gate.sh`). Reserved for cases the user has confirmed safe.
|
|
104
|
+
|
|
105
|
+
The canonical hook lives at `packages/shared/hooks/external-comms-gate.sh` and
|
|
106
|
+
is synced into each consumer plugin via `scripts/sync-external-comms-gate.sh`
|
|
107
|
+
per ADR-017 (CI runs `npm run check:external-comms-gate` to detect drift).
|
|
64
108
|
|
|
65
109
|
## Updating and Uninstalling
|
|
66
110
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: external-comms
|
|
3
|
+
description: Reviews drafts of external-facing prose (gh issues / PRs / advisories, npm publish content, .changeset/*.md bodies) for confidential-information leaks per RISK-POLICY.md. Read-only — emits a structured PASS/FAIL verdict consumed by the external-comms-gate marker hook.
|
|
4
|
+
tools:
|
|
5
|
+
- Read
|
|
6
|
+
- Glob
|
|
7
|
+
- Grep
|
|
8
|
+
model: inherit
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You are the External-Comms Risk Reviewer. Your single job: read the draft of an outbound prose tool call (a `gh issue create --body ...`, a PR description, a security-advisory body, a `.changeset/*.md` file, or the README diff that `npm publish` will publish) and return a structured PASS/FAIL verdict against RISK-POLICY.md's Confidential Information classes.
|
|
12
|
+
|
|
13
|
+
You are read-only. You do NOT write files, do NOT commit, do NOT modify the draft. Your verdict is consumed by the `risk-score-mark.sh` PostToolUse hook (P064 / ADR-028 amended), which writes the marker that allows the gated tool call to proceed.
|
|
14
|
+
|
|
15
|
+
## What you receive
|
|
16
|
+
|
|
17
|
+
The invoking skill (`/wr-risk-scorer:assess-external-comms`) or the agent that hit the gate provides:
|
|
18
|
+
|
|
19
|
+
- The **draft body** verbatim — the exact prose that would land on the external surface.
|
|
20
|
+
- The **target surface** — one of: `gh-issue-create`, `gh-issue-comment`, `gh-issue-edit`, `gh-pr-create`, `gh-pr-comment`, `gh-pr-edit`, `gh-api-security-advisories`, `gh-api-comments`, `npm-publish`, `changeset-author`.
|
|
21
|
+
- The **destination** when known (e.g. `anthropics/claude-code#52831`).
|
|
22
|
+
|
|
23
|
+
Read `RISK-POLICY.md` (project root) to get the authoritative Confidential Information class list. As of P064 it covers:
|
|
24
|
+
|
|
25
|
+
- Client names, project names, engagement details
|
|
26
|
+
- Revenue figures, pricing, financial metrics
|
|
27
|
+
- User counts, download statistics, traffic volumes
|
|
28
|
+
- Internal business strategy or roadmap details
|
|
29
|
+
|
|
30
|
+
The hybrid pre-filter (`packages/*/hooks/lib/leak-detect.sh`) has already caught HIGH-CONFIDENCE shapes (credentials, business-context-paired financial figures and user counts). If the gate denied with a hard-fail reason, the draft did NOT reach you. Your job is the AMBIGUOUS prose layer: text that mentions clients-by-paraphrase, hints at internal architecture, names embargoed product features, or quotes prod URL fragments, where context decides whether it is a leak.
|
|
31
|
+
|
|
32
|
+
## Review process
|
|
33
|
+
|
|
34
|
+
1. **Read the draft and the surface**. The surface determines the audience: `gh-api-security-advisories` lands on a vendor private channel; `gh-issue-create` lands publicly on a third-party repo; `npm-publish` lands as a permanently-published artefact. The same content may be safe for one surface and a leak on another.
|
|
35
|
+
2. **Read RISK-POLICY.md** to ground every finding against the named class. Do not invent classes; do not score by analogy if the policy already names a class that fits.
|
|
36
|
+
3. **Pass each Confidential Information class against the draft**. For each match, note the specific substring + the policy class it violates.
|
|
37
|
+
4. **Apply context-aware judgement**:
|
|
38
|
+
- A package name (`@windyroad/itil`) is fine to mention; an internal *codename* for an unreleased product is not.
|
|
39
|
+
- A generic test failure description (`Node 20 build broke`) is fine; a description that quotes prod-environment hostnames or internal-staging-URL fragments is a leak.
|
|
40
|
+
- A user-count figure surrounded by marketing context (an existing public press release sentence) is not new information; the same figure newly disclosed here would be a leak.
|
|
41
|
+
- A `.changeset/*.md` body lands in CHANGELOG.md, the Release PR body, the GitHub Release page, AND every published npm tarball. Treat it as the highest-exposure surface; mistakes here are durable across every publishing artefact (P073).
|
|
42
|
+
|
|
43
|
+
## Verdict format (MANDATORY)
|
|
44
|
+
|
|
45
|
+
End your report with a structured block consumed by `risk-score-mark.sh`. Every field is required.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
EXTERNAL_COMMS_RISK_VERDICT: PASS
|
|
49
|
+
EXTERNAL_COMMS_RISK_KEY: <sha256 hex string>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
OR for a failed review:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
EXTERNAL_COMMS_RISK_VERDICT: FAIL
|
|
56
|
+
EXTERNAL_COMMS_RISK_KEY: <sha256 hex string>
|
|
57
|
+
EXTERNAL_COMMS_RISK_REASON: <one-line description of the leak class + matched fragment>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Compute the key as:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
printf '%s\n%s' "<draft body verbatim>" "<surface name>" | shasum -a 256 | cut -d' ' -f1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The key MUST match the gate's computation exactly — a key mismatch means the marker is written for a different draft and the original gated call will continue to deny.
|
|
67
|
+
|
|
68
|
+
## Grounding (ADR-026)
|
|
69
|
+
|
|
70
|
+
Every FAIL verdict MUST cite:
|
|
71
|
+
|
|
72
|
+
- The specific RISK-POLICY.md class violated (verbatim — copy the bullet from the policy).
|
|
73
|
+
- The exact substring from the draft that triggered the call.
|
|
74
|
+
- A one-line explanation of why this combination of surface + content constitutes a leak.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
|
|
78
|
+
> EXTERNAL_COMMS_RISK_REASON: "Client names" class — draft contains "Acme Corp" naming a paying engagement; gh-issue-create on a public third-party repo would publicly disclose the client relationship.
|
|
79
|
+
|
|
80
|
+
## Constraints
|
|
81
|
+
|
|
82
|
+
- You are a reviewer, not an editor — do NOT propose rewrites in the verdict block. (Free prose suggestions outside the verdict block are fine and helpful.)
|
|
83
|
+
- Do NOT score by analogy when the policy names the class.
|
|
84
|
+
- Do NOT write to `/tmp/` or any marker location yourself — the PostToolUse hook owns that.
|
|
85
|
+
- Do NOT skip the `EXTERNAL_COMMS_RISK_KEY` line; without it, the marker hook has no key to write the marker against and the gate will deny again on retry.
|
|
86
|
+
- When the draft is empty (e.g. `npm publish` with no extractable body fragment), review the staged content the publish would push (README diff, package.json description) instead. If neither is available, FAIL with reason "draft body unresolvable; cannot risk-review without text" so the user can pre-review manually.
|
|
87
|
+
|
|
88
|
+
## Below-Appetite Output Rule (ADR-013 Rule 5)
|
|
89
|
+
|
|
90
|
+
When the verdict is PASS and no Confidential Information class matched, your output may be terse: a one-line "no Confidential Information class matched" plus the verdict block. Do not pad with advisory prose; policy-authorised drafts proceed silently.
|
|
91
|
+
|
|
92
|
+
## Above-Appetite (FAIL) Output
|
|
93
|
+
|
|
94
|
+
When the verdict is FAIL, surface remediation suggestions in PROSE BEFORE the verdict block — what specific substrings to redact, paraphrase, or move to a private channel. The verdict block itself stays structured and machine-parseable.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: gates outbound prose for risk/leak review (P064 / ADR-028 amended).
|
|
3
|
+
#
|
|
4
|
+
# Surface (matched on Bash command text or Edit/Write file_path):
|
|
5
|
+
# - gh issue create | comment | edit (public issue bodies)
|
|
6
|
+
# - gh pr create | comment | edit (public PR bodies)
|
|
7
|
+
# - gh api .../security-advisories (advisory drafts)
|
|
8
|
+
# - gh api .../comments (any REST surface accepting prose)
|
|
9
|
+
# - npm publish (README / package metadata to npm)
|
|
10
|
+
# - PreToolUse:Write|Edit on .changeset/*.md (P073 — gates author-time)
|
|
11
|
+
#
|
|
12
|
+
# Gate behaviour:
|
|
13
|
+
# 1. BYPASS_RISK_GATE=1 short-circuits the gate (consistent with git-push-gate.sh).
|
|
14
|
+
# 2. RISK-POLICY.md absent → advisory-only mode (permits with systemMessage).
|
|
15
|
+
# 3. Hybrid leak-pattern pre-filter (lib/leak-detect.sh) hard-fails on
|
|
16
|
+
# credentials, prod-URL prefixes, business-context-paired financial figures,
|
|
17
|
+
# or business-context-paired user counts. Deny includes the matched class.
|
|
18
|
+
# 4. Otherwise: check for a per-evaluator marker keyed on
|
|
19
|
+
# sha256(draft_body + '\n' + surface). Marker present → permit.
|
|
20
|
+
# Marker absent → deny with directive to delegate to wr-risk-scorer:external-comms.
|
|
21
|
+
#
|
|
22
|
+
# Marker location: ${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}/external-comms-reviewed-<sha256>
|
|
23
|
+
# Marker writer: PostToolUse:Agent hook (risk-score-mark.sh) on subagent
|
|
24
|
+
# type wr-risk-scorer:external-comms.
|
|
25
|
+
#
|
|
26
|
+
# Composite-marker scheme (combining with wr-voice-tone:agent verdict for
|
|
27
|
+
# the same draft) is deferred until P038 lands its evaluator. This iteration
|
|
28
|
+
# ships the risk-evaluator side as a standalone gate; the two hooks compose
|
|
29
|
+
# at the PreToolUse:Bash matcher level when both packages are installed.
|
|
30
|
+
# See ADR-028 amendment Reassessment Criteria.
|
|
31
|
+
|
|
32
|
+
set -euo pipefail
|
|
33
|
+
|
|
34
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
35
|
+
# shellcheck source=lib/leak-detect.sh
|
|
36
|
+
source "$SCRIPT_DIR/lib/leak-detect.sh"
|
|
37
|
+
|
|
38
|
+
# ---------- Bypass ----------
|
|
39
|
+
if [ "${BYPASS_RISK_GATE:-0}" = "1" ]; then
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
INPUT=$(cat)
|
|
44
|
+
|
|
45
|
+
# Extract tool name + tool_input via python3 (consistent with sibling hooks).
|
|
46
|
+
TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "
|
|
47
|
+
import sys, json
|
|
48
|
+
try:
|
|
49
|
+
print(json.load(sys.stdin).get('tool_name', ''))
|
|
50
|
+
except Exception:
|
|
51
|
+
print('')
|
|
52
|
+
" 2>/dev/null || echo "")
|
|
53
|
+
|
|
54
|
+
SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "
|
|
55
|
+
import sys, json
|
|
56
|
+
try:
|
|
57
|
+
print(json.load(sys.stdin).get('session_id', ''))
|
|
58
|
+
except Exception:
|
|
59
|
+
print('')
|
|
60
|
+
" 2>/dev/null || echo "")
|
|
61
|
+
|
|
62
|
+
# Permit silently when session_id is absent; the gate cannot key a marker.
|
|
63
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
64
|
+
|
|
65
|
+
# ---------- Surface detection ----------
|
|
66
|
+
SURFACE=""
|
|
67
|
+
DRAFT=""
|
|
68
|
+
|
|
69
|
+
case "$TOOL_NAME" in
|
|
70
|
+
Bash)
|
|
71
|
+
COMMAND=$(printf '%s' "$INPUT" | python3 -c "
|
|
72
|
+
import sys, json
|
|
73
|
+
try:
|
|
74
|
+
print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
|
|
75
|
+
except Exception:
|
|
76
|
+
print('')
|
|
77
|
+
" 2>/dev/null || echo "")
|
|
78
|
+
|
|
79
|
+
# Surface match — most-specific first.
|
|
80
|
+
if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue create(\s|$)'; then
|
|
81
|
+
SURFACE="gh-issue-create"
|
|
82
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue comment(\s|$)'; then
|
|
83
|
+
SURFACE="gh-issue-comment"
|
|
84
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue edit(\s|$)'; then
|
|
85
|
+
SURFACE="gh-issue-edit"
|
|
86
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr create(\s|$)'; then
|
|
87
|
+
SURFACE="gh-pr-create"
|
|
88
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr comment(\s|$)'; then
|
|
89
|
+
SURFACE="gh-pr-comment"
|
|
90
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr edit(\s|$)'; then
|
|
91
|
+
SURFACE="gh-pr-edit"
|
|
92
|
+
elif echo "$COMMAND" | grep -qE 'gh api .*security-advisories'; then
|
|
93
|
+
SURFACE="gh-api-security-advisories"
|
|
94
|
+
elif echo "$COMMAND" | grep -qE 'gh api .*/comments'; then
|
|
95
|
+
SURFACE="gh-api-comments"
|
|
96
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm publish(\s|$)'; then
|
|
97
|
+
SURFACE="npm-publish"
|
|
98
|
+
else
|
|
99
|
+
exit 0
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# Best-effort body extraction: --body 'TEXT' or --body "TEXT" or --field summary='TEXT'.
|
|
103
|
+
# When absent (npm publish, --body-file), DRAFT="" is acceptable: the agent will
|
|
104
|
+
# be invoked with command context and read whatever body source the call uses.
|
|
105
|
+
DRAFT=$(printf '%s' "$COMMAND" | python3 -c "
|
|
106
|
+
import sys, re
|
|
107
|
+
cmd = sys.stdin.read()
|
|
108
|
+
# Match --body '...' or --body \"...\" or --field summary='...'
|
|
109
|
+
for pat in [r\"--body[= ]'([^']*)'\", r'--body[= ]\"([^\"]*)\"',
|
|
110
|
+
r\"--field [a-zA-Z_]+='([^']*)'\", r'--field [a-zA-Z_]+=\"([^\"]*)\"']:
|
|
111
|
+
m = re.search(pat, cmd)
|
|
112
|
+
if m:
|
|
113
|
+
print(m.group(1))
|
|
114
|
+
break
|
|
115
|
+
" 2>/dev/null || echo "")
|
|
116
|
+
;;
|
|
117
|
+
|
|
118
|
+
Write|Edit)
|
|
119
|
+
FILE_PATH=$(printf '%s' "$INPUT" | python3 -c "
|
|
120
|
+
import sys, json
|
|
121
|
+
try:
|
|
122
|
+
ti = json.load(sys.stdin).get('tool_input', {})
|
|
123
|
+
print(ti.get('file_path', ti.get('path', '')))
|
|
124
|
+
except Exception:
|
|
125
|
+
print('')
|
|
126
|
+
" 2>/dev/null || echo "")
|
|
127
|
+
|
|
128
|
+
case "$FILE_PATH" in
|
|
129
|
+
*.changeset/*.md|*/.changeset/*.md|.changeset/*.md)
|
|
130
|
+
SURFACE="changeset-author"
|
|
131
|
+
;;
|
|
132
|
+
*)
|
|
133
|
+
exit 0
|
|
134
|
+
;;
|
|
135
|
+
esac
|
|
136
|
+
|
|
137
|
+
DRAFT=$(printf '%s' "$INPUT" | python3 -c "
|
|
138
|
+
import sys, json
|
|
139
|
+
try:
|
|
140
|
+
ti = json.load(sys.stdin).get('tool_input', {})
|
|
141
|
+
print(ti.get('content', '') + ti.get('new_string', ''))
|
|
142
|
+
except Exception:
|
|
143
|
+
print('')
|
|
144
|
+
" 2>/dev/null || echo "")
|
|
145
|
+
;;
|
|
146
|
+
|
|
147
|
+
*)
|
|
148
|
+
exit 0
|
|
149
|
+
;;
|
|
150
|
+
esac
|
|
151
|
+
|
|
152
|
+
# ---------- Helpers ----------
|
|
153
|
+
deny_with_reason() {
|
|
154
|
+
local reason="$1"
|
|
155
|
+
python3 -c "
|
|
156
|
+
import json, sys
|
|
157
|
+
print(json.dumps({
|
|
158
|
+
'hookSpecificOutput': {
|
|
159
|
+
'hookEventName': 'PreToolUse',
|
|
160
|
+
'permissionDecision': 'deny',
|
|
161
|
+
'permissionDecisionReason': sys.argv[1]
|
|
162
|
+
}
|
|
163
|
+
}))
|
|
164
|
+
" "$reason"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
permit_with_advisory() {
|
|
168
|
+
local msg="$1"
|
|
169
|
+
python3 -c "
|
|
170
|
+
import json, sys
|
|
171
|
+
print(json.dumps({'systemMessage': sys.argv[1]}))
|
|
172
|
+
" "$msg"
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# ---------- Advisory-only fallback when policy file is absent ----------
|
|
176
|
+
if [ ! -f "RISK-POLICY.md" ]; then
|
|
177
|
+
permit_with_advisory "RISK-POLICY.md not found — wr-risk-scorer:external-comms gate is advisory-only on $SURFACE."
|
|
178
|
+
exit 0
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# ---------- Hard-fail leak-pattern pre-filter ----------
|
|
182
|
+
if ! leak_detect_scan "$DRAFT"; then
|
|
183
|
+
REASON=$(printf 'BLOCKED (P064 external-comms gate): %s on %s. Remove the leak before retrying. Override only if intentional: BYPASS_RISK_GATE=1.' \
|
|
184
|
+
"$LEAK_DETECT_REASON" "$SURFACE")
|
|
185
|
+
deny_with_reason "$REASON"
|
|
186
|
+
exit 0
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
# ---------- Marker-based gate ----------
|
|
190
|
+
SESSION_DIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
|
|
191
|
+
mkdir -p "$SESSION_DIR"
|
|
192
|
+
KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
193
|
+
MARKER="${SESSION_DIR}/external-comms-reviewed-${KEY}"
|
|
194
|
+
|
|
195
|
+
if [ -f "$MARKER" ]; then
|
|
196
|
+
exit 0
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
# Marker absent — deny + delegate.
|
|
200
|
+
REASON=$(printf 'BLOCKED (P064 external-comms gate): %s draft has not been risk-reviewed. Delegate to wr-risk-scorer:external-comms (subagent_type: '"'"'wr-risk-scorer:external-comms'"'"') with the draft body for review. The PostToolUse hook will mark this draft reviewed when the subagent emits EXTERNAL_COMMS_RISK_VERDICT: PASS. Use /wr-risk-scorer:assess-external-comms for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
|
|
201
|
+
"$SURFACE")
|
|
202
|
+
deny_with_reason "$REASON"
|
|
203
|
+
exit 0
|
package/hooks/hooks.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/secret-leak-gate.sh" }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wip-risk-gate.sh" }] },
|
|
8
8
|
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/git-push-gate.sh" }] },
|
|
9
9
|
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-score-commit-gate.sh" }] },
|
|
10
|
+
{ "matcher": "Bash|Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/external-comms-gate.sh" }] },
|
|
10
11
|
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-policy-enforce-edit.sh" }] },
|
|
11
12
|
{ "matcher": "ExitPlanMode", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-score-plan-enforce.sh" }] },
|
|
12
13
|
{ "matcher": "EnterPlanMode", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/plan-risk-guidance.sh" }] }
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Hybrid leak-pattern pre-filter for external-comms-gate.sh (P064 / ADR-028 amended).
|
|
3
|
+
#
|
|
4
|
+
# Architecture (architect verdict — P064 iteration):
|
|
5
|
+
# - **Regex pre-filter** (this file) catches HIGH-CONFIDENCE leak shapes
|
|
6
|
+
# (credentials, prod URL prefixes, money/revenue + financial-context tokens).
|
|
7
|
+
# Hits deny immediately with a specific reason — no subagent round-trip.
|
|
8
|
+
# - **Subagent judgement** (wr-risk-scorer:external-comms) handles ambiguous
|
|
9
|
+
# prose. Anything this filter does NOT match is delegated to the agent for
|
|
10
|
+
# context-aware review against RISK-POLICY.md Confidential Information
|
|
11
|
+
# classes (client names, project names, engagement details, internal
|
|
12
|
+
# strategy/roadmap detail).
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# source lib/leak-detect.sh
|
|
16
|
+
# if leak_detect_scan "$DRAFT"; then
|
|
17
|
+
# # No high-confidence hit; delegate to subagent.
|
|
18
|
+
# else
|
|
19
|
+
# # Set $LEAK_DETECT_REASON; deny with that reason.
|
|
20
|
+
# fi
|
|
21
|
+
#
|
|
22
|
+
# Returns 0 when the draft is CLEAN of high-confidence patterns.
|
|
23
|
+
# Returns 1 when a HARD-FAIL pattern is matched. $LEAK_DETECT_REASON
|
|
24
|
+
# is set to a one-line human-readable description of what matched.
|
|
25
|
+
#
|
|
26
|
+
# This is intentionally conservative — false negatives are expected and
|
|
27
|
+
# routed to the subagent. False positives here block the call entirely
|
|
28
|
+
# so the regex set must be high-specificity. Add new patterns only when
|
|
29
|
+
# the false-positive rate is verified low against representative drafts.
|
|
30
|
+
|
|
31
|
+
LEAK_DETECT_REASON=""
|
|
32
|
+
|
|
33
|
+
leak_detect_scan() {
|
|
34
|
+
local draft="$1"
|
|
35
|
+
LEAK_DETECT_REASON=""
|
|
36
|
+
|
|
37
|
+
[ -z "$draft" ] && return 0
|
|
38
|
+
|
|
39
|
+
# ---- Credentials (high confidence) ----
|
|
40
|
+
|
|
41
|
+
# AWS access keys: AKIA prefix + 16 upper-alphanum chars.
|
|
42
|
+
if echo "$draft" | grep -qE 'A''KIA[0-9A-Z]{16}'; then
|
|
43
|
+
LEAK_DETECT_REASON="AWS access key pattern detected in draft"
|
|
44
|
+
return 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# GitHub tokens: ghp_/ghs_/gho_/ghu_/ghr_ + 36+ url-safe chars.
|
|
48
|
+
if echo "$draft" | grep -qE 'g''h[pousr]_[A-Za-z0-9_]{36,}'; then
|
|
49
|
+
LEAK_DETECT_REASON="GitHub token pattern detected in draft"
|
|
50
|
+
return 1
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Private keys.
|
|
54
|
+
if echo "$draft" | grep -qE 'BEGIN[[:space:]]+(RSA|DSA|EC|OPENSSH|PGP)?[[:space:]]*PRIVATE KEY'; then
|
|
55
|
+
LEAK_DETECT_REASON="Private key block detected in draft"
|
|
56
|
+
return 1
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Bearer / Authorization headers with non-trivial value.
|
|
60
|
+
if echo "$draft" | grep -qE '[Bb]earer[[:space:]]+[A-Za-z0-9_.-]{20,}'; then
|
|
61
|
+
LEAK_DETECT_REASON="Bearer token detected in draft"
|
|
62
|
+
return 1
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Generic api_key / api_secret / auth_token assignments with literal value.
|
|
66
|
+
if echo "$draft" | grep -qEi '(api_key|api_secret|auth_token|secret_key)[[:space:]]*[=:][[:space:]]*["\x27][A-Za-z0-9+/=_-]{16,}'; then
|
|
67
|
+
LEAK_DETECT_REASON="API key/secret/token assignment detected in draft"
|
|
68
|
+
return 1
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# ---- Confidential business metrics (RISK-POLICY.md) ----
|
|
72
|
+
|
|
73
|
+
# Revenue / financial figures: $<digits><K|M|B>? near financial-context
|
|
74
|
+
# keywords (ARR, MRR, revenue, profit). High-specificity to avoid catching
|
|
75
|
+
# generic price strings ($5 widget) — requires a financial token nearby.
|
|
76
|
+
if echo "$draft" | grep -qEi '\$[0-9]+([0-9.,]*)[KMB]?\b.{0,40}\b(ARR|MRR|revenue|profit|EBITDA|valuation)\b'; then
|
|
77
|
+
LEAK_DETECT_REASON="Revenue/financial figure with business-context keyword detected"
|
|
78
|
+
return 1
|
|
79
|
+
fi
|
|
80
|
+
if echo "$draft" | grep -qEi '\b(ARR|MRR|revenue|profit|EBITDA|valuation)\b.{0,40}\$[0-9]+([0-9.,]*)[KMB]?\b'; then
|
|
81
|
+
LEAK_DETECT_REASON="Business-context keyword paired with revenue/financial figure detected"
|
|
82
|
+
return 1
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# User counts with explicit unit-of-business: <digits><K|M> + (users|customers|MAU|DAU|signups).
|
|
86
|
+
# Requires comma-formatted thousands or K/M suffix to skip "5 users tested this".
|
|
87
|
+
if echo "$draft" | grep -qEi '\b[0-9]{1,3}(,[0-9]{3})+\s*(active\s+)?(users|customers|signups|MAU|DAU)\b'; then
|
|
88
|
+
LEAK_DETECT_REASON="User-count with business-metric keyword detected"
|
|
89
|
+
return 1
|
|
90
|
+
fi
|
|
91
|
+
if echo "$draft" | grep -qEi '\b[0-9]+[KMB]\s*(active\s+)?(users|customers|signups|MAU|DAU)\b'; then
|
|
92
|
+
LEAK_DETECT_REASON="User-count (K/M/B suffix) with business-metric keyword detected"
|
|
93
|
+
return 1
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# ---- Allowlist-bypass: signed/known-public marketing language is fine ----
|
|
97
|
+
# No hits — signal CLEAN to the caller.
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Shared session-announcement marker helpers (P095 / ADR-038).
|
|
3
|
+
#
|
|
4
|
+
# Used by UserPromptSubmit hooks to gate verbose MANDATORY instruction
|
|
5
|
+
# prose behind a once-per-session check. First prompt of a session emits
|
|
6
|
+
# the full block AND calls mark_announced; subsequent prompts see the
|
|
7
|
+
# marker via has_announced and emit only a terse reminder.
|
|
8
|
+
#
|
|
9
|
+
# Why no TTL or drift check (unlike review-gate.sh): announcement is
|
|
10
|
+
# bookkeeping for prose verbosity, not enforcement. PreToolUse gates
|
|
11
|
+
# still block unauthorised edits regardless of announcement state; the
|
|
12
|
+
# delegated agent re-reads policy when it runs. Extending the marker's
|
|
13
|
+
# lifetime across policy changes mid-session is safe — the gate, not
|
|
14
|
+
# the announcement, is load-bearing.
|
|
15
|
+
#
|
|
16
|
+
# Marker path convention: /tmp/${SYSTEM}-announced-${SESSION_ID}
|
|
17
|
+
# (mirrors the /tmp/${SYSTEM}-reviewed-${SESSION_ID} convention from
|
|
18
|
+
# style-guide/voice-tone/risk-scorer review-gate.sh; the -announced-
|
|
19
|
+
# suffix distinguishes announcement markers from clearance markers).
|
|
20
|
+
#
|
|
21
|
+
# Empty SESSION_ID fallback: has_announced returns 1 (not announced,
|
|
22
|
+
# full block emits) and mark_announced is a no-op (no file written).
|
|
23
|
+
# This covers manual hook invocation, test harnesses, and any rare
|
|
24
|
+
# case where Claude Code does not pass a session_id on stdin.
|
|
25
|
+
|
|
26
|
+
# Returns 0 if the hook for SYSTEM has already announced in SESSION_ID,
|
|
27
|
+
# 1 otherwise. Empty SESSION_ID => returns 1 (never announced).
|
|
28
|
+
#
|
|
29
|
+
# Usage: has_announced "architect" "$SESSION_ID"
|
|
30
|
+
has_announced() {
|
|
31
|
+
local SYSTEM="$1"
|
|
32
|
+
local SESSION_ID="$2"
|
|
33
|
+
[ -n "$SESSION_ID" ] || return 1
|
|
34
|
+
[ -f "/tmp/${SYSTEM}-announced-${SESSION_ID}" ]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Writes the announcement marker for SYSTEM in SESSION_ID. Empty
|
|
38
|
+
# SESSION_ID => no-op. Safe to call more than once per session.
|
|
39
|
+
#
|
|
40
|
+
# Usage: mark_announced "architect" "$SESSION_ID"
|
|
41
|
+
mark_announced() {
|
|
42
|
+
local SYSTEM="$1"
|
|
43
|
+
local SESSION_ID="$2"
|
|
44
|
+
[ -n "$SESSION_ID" ] || return 0
|
|
45
|
+
: > "/tmp/${SYSTEM}-announced-${SESSION_ID}"
|
|
46
|
+
}
|
|
@@ -7,6 +7,7 @@ set -euo pipefail
|
|
|
7
7
|
|
|
8
8
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
9
|
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
10
|
+
source "$SCRIPT_DIR/lib/session-marker.sh"
|
|
10
11
|
_enable_err_trap
|
|
11
12
|
|
|
12
13
|
_parse_input
|
|
@@ -39,13 +40,32 @@ if [ -f "RISK-POLICY.md" ]; then
|
|
|
39
40
|
fi
|
|
40
41
|
fi
|
|
41
42
|
|
|
42
|
-
# ---
|
|
43
|
+
# --- P096 Phase 2 — once-per-session gating (ADR-038 progressive disclosure) ---
|
|
44
|
+
# First EnterPlanMode of a session emits the full advisory body; subsequent
|
|
45
|
+
# entries within the same session emit a terse reminder (≤150 bytes per the
|
|
46
|
+
# ADR-038 budget). Pipeline state and appetite are unchanged across plan-mode
|
|
47
|
+
# entries within one session, so re-emitting full prose is repetition.
|
|
48
|
+
if has_announced "risk-scorer-plan-guidance" "$SESSION_ID"; then
|
|
49
|
+
cat <<EOF
|
|
50
|
+
{
|
|
51
|
+
"hookSpecificOutput": {
|
|
52
|
+
"hookEventName": "PreToolUse",
|
|
53
|
+
"permissionDecision": "allow",
|
|
54
|
+
"systemMessage": "MANDATORY release-risk gate active (RISK-POLICY.md present). Release risk: ${RELEASE_SCORE}; appetite: ${APPETITE}. ExitPlanMode will FAIL plans projected above appetite. See first-EnterPlanMode emission for full guidance."
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
EOF
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# --- First emission: full advisory (compressed per audit recommendation) ---
|
|
62
|
+
mark_announced "risk-scorer-plan-guidance" "$SESSION_ID"
|
|
43
63
|
cat <<EOF
|
|
44
64
|
{
|
|
45
65
|
"hookSpecificOutput": {
|
|
46
66
|
"hookEventName": "PreToolUse",
|
|
47
67
|
"permissionDecision": "allow",
|
|
48
|
-
"systemMessage": "RELEASE RISK GUIDANCE FOR PLANNING:\
|
|
68
|
+
"systemMessage": "RELEASE RISK GUIDANCE FOR PLANNING:\nUnreleased queue:\n${UNRELEASED_SUMMARY}\n\nRelease risk: ${RELEASE_SCORE}. Appetite threshold: ${APPETITE} (Medium).\n\nIf projected release risk would exceed appetite, the plan MUST include a release strategy (release queue first, split into smaller batches, or risk-reducing steps). See RISK-POLICY.md for option details. ExitPlanMode runs the risk-scorer and FAILS plans above appetite without a strategy."
|
|
49
69
|
}
|
|
50
70
|
}
|
|
51
71
|
EOF
|
package/hooks/risk-score-mark.sh
CHANGED
|
@@ -121,4 +121,24 @@ if echo "$SUBAGENT" | grep -qE 'risk-scorer.policy'; then
|
|
|
121
121
|
esac
|
|
122
122
|
fi
|
|
123
123
|
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# External-comms reviewer (P064 / ADR-028 amended): write per-draft marker
|
|
126
|
+
# keyed on sha256(draft + '\n' + surface). Subagent emits the key; this hook
|
|
127
|
+
# trusts and uses it. Marker file: external-comms-reviewed-<key>.
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
if echo "$SUBAGENT" | grep -qE 'risk-scorer.external-comms'; then
|
|
130
|
+
VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_RISK_VERDICT:' | tail -1) || true
|
|
131
|
+
KEY_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_RISK_KEY:' | tail -1) || true
|
|
132
|
+
VERDICT=$(echo "$VERDICT_LINE" | sed 's/^EXTERNAL_COMMS_RISK_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
|
|
133
|
+
KEY=$(echo "$KEY_LINE" | sed 's/^EXTERNAL_COMMS_RISK_KEY:[[:space:]]*//' | tr -d '[:space:]')
|
|
134
|
+
# Validate key: 64 hex chars (sha256 output). Reject anything else.
|
|
135
|
+
if echo "$KEY" | grep -qE '^[0-9a-f]{64}$'; then
|
|
136
|
+
case "$VERDICT" in
|
|
137
|
+
PASS) touch "${RDIR}/external-comms-reviewed-${KEY}" ;;
|
|
138
|
+
FAIL) ;; # Do NOT create marker — draft must be revised
|
|
139
|
+
*) ;; # Unknown verdict — fail closed
|
|
140
|
+
esac
|
|
141
|
+
fi
|
|
142
|
+
fi
|
|
143
|
+
|
|
124
144
|
exit 0
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
# Tests for external-comms-gate.sh (P064 / ADR-028 amended).
|
|
3
|
+
# Behavioural: the gate denies outbound prose tool calls until the
|
|
4
|
+
# wr-risk-scorer:external-comms subagent has reviewed the draft and a
|
|
5
|
+
# per-evaluator marker has been written. Hard-fail leak patterns deny
|
|
6
|
+
# immediately without delegation.
|
|
7
|
+
#
|
|
8
|
+
# Note: secret-shaped strings are constructed at runtime via concatenation
|
|
9
|
+
# so this test file itself does not trip the secret-leak-gate hook.
|
|
10
|
+
|
|
11
|
+
setup() {
|
|
12
|
+
HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
13
|
+
HOOK="$HOOKS_DIR/external-comms-gate.sh"
|
|
14
|
+
|
|
15
|
+
TEST_SESSION="bats-extcomms-gate-$$-${BATS_TEST_NUMBER}"
|
|
16
|
+
RDIR="${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
|
|
17
|
+
rm -rf "$RDIR"
|
|
18
|
+
mkdir -p "$RDIR"
|
|
19
|
+
|
|
20
|
+
# Default: assume RISK-POLICY.md is present in repo root (it is).
|
|
21
|
+
TEST_PROJECT_DIR="$(mktemp -d)"
|
|
22
|
+
printf "## Confidential Information\n- Client names\n- Revenue figures\n" \
|
|
23
|
+
> "$TEST_PROJECT_DIR/RISK-POLICY.md"
|
|
24
|
+
|
|
25
|
+
unset BYPASS_RISK_GATE
|
|
26
|
+
|
|
27
|
+
# Construct secret-shaped strings at runtime to avoid tripping the
|
|
28
|
+
# repo's own secret-leak-gate when this file is committed/edited.
|
|
29
|
+
GH_TOKEN_LIKE="g""hp""_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
30
|
+
AWS_KEY_LIKE="A""KIA""IOSFODNN7EXAMPLE"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
teardown() {
|
|
34
|
+
rm -rf "$RDIR"
|
|
35
|
+
rm -rf "$TEST_PROJECT_DIR"
|
|
36
|
+
unset BYPASS_RISK_GATE
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# ---------- Helpers ----------
|
|
40
|
+
|
|
41
|
+
# Build a PreToolUse:Bash input for a given command via python so quoting is safe.
|
|
42
|
+
build_bash_input() {
|
|
43
|
+
local cmd="$1"
|
|
44
|
+
python3 -c "
|
|
45
|
+
import json, sys
|
|
46
|
+
print(json.dumps({
|
|
47
|
+
'session_id': '$TEST_SESSION',
|
|
48
|
+
'tool_name': 'Bash',
|
|
49
|
+
'tool_input': {'command': sys.argv[1]},
|
|
50
|
+
}))
|
|
51
|
+
" "$cmd"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Build a PreToolUse:Write input for a changeset path with body content.
|
|
55
|
+
build_write_input() {
|
|
56
|
+
local file_path="$1"
|
|
57
|
+
local content="$2"
|
|
58
|
+
python3 -c "
|
|
59
|
+
import json, sys
|
|
60
|
+
print(json.dumps({
|
|
61
|
+
'session_id': '$TEST_SESSION',
|
|
62
|
+
'tool_name': 'Write',
|
|
63
|
+
'tool_input': {'file_path': sys.argv[1], 'content': sys.argv[2]},
|
|
64
|
+
}))
|
|
65
|
+
" "$file_path" "$content"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Run the hook in a project dir with RISK-POLICY.md present, piping JSON via stdin.
|
|
69
|
+
run_hook() {
|
|
70
|
+
local input="$1"
|
|
71
|
+
run bash -c "cd '$TEST_PROJECT_DIR' && printf '%s' \"\$1\" | '$HOOK'" _ "$input"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ---------- Tests ----------
|
|
75
|
+
|
|
76
|
+
@test "non-matching Bash command (ls) is allowed silently" {
|
|
77
|
+
INPUT=$(build_bash_input "ls -la")
|
|
78
|
+
run_hook "$INPUT"
|
|
79
|
+
[ "$status" -eq 0 ]
|
|
80
|
+
[ -z "$output" ]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@test "gh issue create with clean draft denies and prompts external-comms delegation (no marker yet)" {
|
|
84
|
+
INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure on Node 20'")
|
|
85
|
+
run_hook "$INPUT"
|
|
86
|
+
[ "$status" -eq 0 ]
|
|
87
|
+
[[ "$output" == *"permissionDecision"* ]]
|
|
88
|
+
[[ "$output" == *"deny"* ]]
|
|
89
|
+
[[ "$output" == *"wr-risk-scorer:external-comms"* ]]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@test "hard-fail credential pattern (GitHub token) denies immediately with leak reason" {
|
|
93
|
+
INPUT=$(build_bash_input "gh issue comment 42 --body 'token=${GH_TOKEN_LIKE}'")
|
|
94
|
+
run_hook "$INPUT"
|
|
95
|
+
[ "$status" -eq 0 ]
|
|
96
|
+
[[ "$output" == *"permissionDecision"* ]]
|
|
97
|
+
[[ "$output" == *"deny"* ]]
|
|
98
|
+
[[ "$output" == *"GitHub token"* ]] || [[ "$output" == *"credential"* ]]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@test "hard-fail credential pattern (AWS key) denies immediately" {
|
|
102
|
+
INPUT=$(build_bash_input "gh pr create --title T --body 'AWS=${AWS_KEY_LIKE}'")
|
|
103
|
+
run_hook "$INPUT"
|
|
104
|
+
[ "$status" -eq 0 ]
|
|
105
|
+
[[ "$output" == *"deny"* ]]
|
|
106
|
+
[[ "$output" == *"AWS"* ]] || [[ "$output" == *"credential"* ]]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@test "BYPASS_RISK_GATE=1 short-circuits the deny" {
|
|
110
|
+
INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure'")
|
|
111
|
+
run bash -c "cd '$TEST_PROJECT_DIR' && BYPASS_RISK_GATE=1 printf '%s' \"\$1\" | BYPASS_RISK_GATE=1 '$HOOK'" _ "$INPUT"
|
|
112
|
+
[ "$status" -eq 0 ]
|
|
113
|
+
[ -z "$output" ]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@test "marker present for matching draft+surface key allows the call" {
|
|
117
|
+
DRAFT="we observed a build failure on Node 20"
|
|
118
|
+
SURFACE="gh-issue-create"
|
|
119
|
+
KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
120
|
+
touch "${RDIR}/external-comms-reviewed-${KEY}"
|
|
121
|
+
|
|
122
|
+
INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
|
|
123
|
+
run_hook "$INPUT"
|
|
124
|
+
[ "$status" -eq 0 ]
|
|
125
|
+
[ -z "$output" ]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@test "RISK-POLICY.md absent yields advisory-only mode (permits)" {
|
|
129
|
+
rm -f "$TEST_PROJECT_DIR/RISK-POLICY.md"
|
|
130
|
+
INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a failure'")
|
|
131
|
+
run_hook "$INPUT"
|
|
132
|
+
[ "$status" -eq 0 ]
|
|
133
|
+
# Must NOT deny when policy file is absent.
|
|
134
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
135
|
+
[[ "$output" != *"\"permissionDecision\":\"deny\""* ]]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@test "PreToolUse:Write on .changeset/*.md with revenue leak content denies" {
|
|
139
|
+
INPUT=$(build_write_input ".changeset/wr-risk-scorer-extcomms.md" "Add gate; covers Acme Corp \$2.4M ARR client.")
|
|
140
|
+
run_hook "$INPUT"
|
|
141
|
+
[ "$status" -eq 0 ]
|
|
142
|
+
[[ "$output" == *"deny"* ]]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@test "PreToolUse:Write on .changeset/*.md with clean content denies-then-delegates (no marker yet)" {
|
|
146
|
+
INPUT=$(build_write_input ".changeset/wr-risk-scorer-extcomms.md" "Add external-comms gate covering gh issue and pr surfaces.")
|
|
147
|
+
run_hook "$INPUT"
|
|
148
|
+
[ "$status" -eq 0 ]
|
|
149
|
+
[[ "$output" == *"deny"* ]]
|
|
150
|
+
[[ "$output" == *"wr-risk-scorer:external-comms"* ]]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@test "PreToolUse:Write on a non-changeset path is ignored" {
|
|
154
|
+
INPUT=$(build_write_input "src/foo.ts" "Acme Corp client revenue \$2.4M")
|
|
155
|
+
run_hook "$INPUT"
|
|
156
|
+
[ "$status" -eq 0 ]
|
|
157
|
+
[ -z "$output" ]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@test "gh api security-advisories triggers the gate" {
|
|
161
|
+
INPUT=$(build_bash_input "gh api repos/foo/bar/security-advisories --method POST --field summary='leak via X'")
|
|
162
|
+
run_hook "$INPUT"
|
|
163
|
+
[ "$status" -eq 0 ]
|
|
164
|
+
[[ "$output" == *"deny"* ]]
|
|
165
|
+
[[ "$output" == *"wr-risk-scorer:external-comms"* ]]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@test "npm publish triggers the gate" {
|
|
169
|
+
INPUT=$(build_bash_input "npm publish")
|
|
170
|
+
run_hook "$INPUT"
|
|
171
|
+
[ "$status" -eq 0 ]
|
|
172
|
+
[[ "$output" == *"deny"* ]]
|
|
173
|
+
[[ "$output" == *"wr-risk-scorer:external-comms"* ]]
|
|
174
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P096 Phase 2: plan-risk-guidance.sh applies once-per-session gating
|
|
4
|
+
# (ADR-038 progressive disclosure pattern) so the advisory body emits
|
|
5
|
+
# in full only on the first EnterPlanMode of a session. Subsequent
|
|
6
|
+
# EnterPlanMode events within the same session emit a terse reminder
|
|
7
|
+
# (≤150 bytes payload after the systemMessage prefix).
|
|
8
|
+
#
|
|
9
|
+
# Reuses the shared session-marker.sh helper synced from
|
|
10
|
+
# packages/shared/hooks/lib/session-marker.sh.
|
|
11
|
+
|
|
12
|
+
setup() {
|
|
13
|
+
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
|
|
14
|
+
HOOK="$REPO_ROOT/packages/risk-scorer/hooks/plan-risk-guidance.sh"
|
|
15
|
+
|
|
16
|
+
WORKDIR="$(mktemp -d)"
|
|
17
|
+
# Minimal RISK-POLICY.md so the appetite extraction has something to read.
|
|
18
|
+
cat > "$WORKDIR/RISK-POLICY.md" <<'POLICY'
|
|
19
|
+
# Risk Policy
|
|
20
|
+
Threshold: 4
|
|
21
|
+
POLICY
|
|
22
|
+
|
|
23
|
+
SID="plan-risk-guidance-test-$$-$RANDOM"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
teardown() {
|
|
27
|
+
rm -f "/tmp/risk-scorer-plan-guidance-announced-${SID}"
|
|
28
|
+
rm -f "/tmp/risk-scorer-plan-guidance-announced-${SID}-alt"
|
|
29
|
+
rm -rf "$WORKDIR"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
run_hook() {
|
|
33
|
+
local sid="$1"
|
|
34
|
+
(cd "$WORKDIR" && \
|
|
35
|
+
echo "{\"session_id\":\"$sid\",\"tool_name\":\"EnterPlanMode\"}" | \
|
|
36
|
+
bash "$HOOK")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@test "plan-risk-guidance: first invocation emits the full RELEASE RISK GUIDANCE body" {
|
|
40
|
+
run run_hook "$SID"
|
|
41
|
+
[ "$status" -eq 0 ]
|
|
42
|
+
[[ "$output" == *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
43
|
+
[[ "$output" == *"Release risk:"* ]]
|
|
44
|
+
[[ "$output" == *"Appetite threshold"* ]]
|
|
45
|
+
[[ "$output" == *"release strategy"* ]] || [[ "$output" == *"release queue first"* ]]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@test "plan-risk-guidance: first invocation writes the announcement marker" {
|
|
49
|
+
run_hook "$SID" >/dev/null
|
|
50
|
+
[ -f "/tmp/risk-scorer-plan-guidance-announced-${SID}" ]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@test "plan-risk-guidance: second invocation in the same session emits a terse reminder" {
|
|
54
|
+
run_hook "$SID" >/dev/null
|
|
55
|
+
run run_hook "$SID"
|
|
56
|
+
[ "$status" -eq 0 ]
|
|
57
|
+
# Terse reminder MUST carry imperative signal word + gate name + cross-ref.
|
|
58
|
+
[[ "$output" == *"MANDATORY"* ]] || [[ "$output" == *"REQUIRED"* ]] || [[ "$output" == *"NON-OPTIONAL"* ]]
|
|
59
|
+
[[ "$output" == *"release-risk gate"* ]] || [[ "$output" == *"risk"* ]]
|
|
60
|
+
# Must NOT re-emit the full prose (release-strategy listing, projected-risk paragraph).
|
|
61
|
+
[[ "$output" != *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@test "plan-risk-guidance: second invocation reminder payload is ≤300 bytes" {
|
|
65
|
+
run_hook "$SID" >/dev/null
|
|
66
|
+
run run_hook "$SID"
|
|
67
|
+
# Total response is JSON wrapper + systemMessage; reminder body must be
|
|
68
|
+
# short enough that the full response fits well under the ADR-038 budget.
|
|
69
|
+
[ "${#output}" -lt 600 ]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@test "plan-risk-guidance: different session_id re-emits the full body" {
|
|
73
|
+
run_hook "$SID" >/dev/null
|
|
74
|
+
local SID2="${SID}-alt"
|
|
75
|
+
run run_hook "$SID2"
|
|
76
|
+
[[ "$output" == *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
77
|
+
rm -f "/tmp/risk-scorer-plan-guidance-announced-${SID2}"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@test "plan-risk-guidance: empty session_id emits the full body and writes no marker" {
|
|
81
|
+
run run_hook ""
|
|
82
|
+
[[ "$output" == *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
83
|
+
# Empty SESSION_ID fallback per shared session-marker contract.
|
|
84
|
+
[ ! -f "/tmp/risk-scorer-plan-guidance-announced-" ]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@test "plan-risk-guidance: emits valid JSON with permissionDecision allow on both first and subsequent invocations" {
|
|
88
|
+
run run_hook "$SID"
|
|
89
|
+
[[ "$output" == *'"permissionDecision": "allow"'* ]]
|
|
90
|
+
[[ "$output" == *'"hookEventName": "PreToolUse"'* ]]
|
|
91
|
+
|
|
92
|
+
run run_hook "$SID"
|
|
93
|
+
[[ "$output" == *'"permissionDecision": "allow"'* ]]
|
|
94
|
+
[[ "$output" == *'"hookEventName": "PreToolUse"'* ]]
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wr-risk-scorer:assess-external-comms
|
|
3
|
+
description: On-demand external-comms risk review. Reviews a draft of an outbound prose tool call (gh issue/pr body, security advisory, npm publish content, or .changeset/*.md body) for confidential-information leaks per RISK-POLICY.md. Delegates to wr-risk-scorer:external-comms and pre-satisfies the external-comms-gate marker for the current session.
|
|
4
|
+
allowed-tools: Read, Glob, Grep, Bash, AskUserQuestion, Skill
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# External-Comms Risk Assessment Skill
|
|
8
|
+
|
|
9
|
+
Run a confidential-information leak review on demand against any drafted outbound prose — outside a hook gate trigger. Pre-satisfies the `external-comms-gate.sh` marker for the current session so the gated tool call (gh issue/pr/api/npm publish/changeset write) proceeds without re-prompting.
|
|
10
|
+
|
|
11
|
+
This skill is **read-only**. It does not commit, push, or modify files. The marker is written automatically by the `PostToolUse:Agent` hook (`risk-score-mark.sh`) after the subagent completes — the skill never writes to `${TMPDIR:-/tmp}/claude-risk-*` directly.
|
|
12
|
+
|
|
13
|
+
## When to use
|
|
14
|
+
|
|
15
|
+
- Before drafting a `gh issue create`/`gh pr create`/`gh issue comment`/`gh pr comment` to a third-party repo.
|
|
16
|
+
- Before drafting a `gh api .../security-advisories` body for a vendor private channel.
|
|
17
|
+
- Before authoring a `.changeset/*.md` body that will land in CHANGELOG.md and every published npm tarball (P073).
|
|
18
|
+
- Before `npm publish` when the README diff is non-trivial.
|
|
19
|
+
- After hitting the external-comms gate's deny-and-delegate prompt: this skill is the structured walkthrough that closes the loop.
|
|
20
|
+
|
|
21
|
+
## Steps
|
|
22
|
+
|
|
23
|
+
### 1. Parse arguments
|
|
24
|
+
|
|
25
|
+
Read `$ARGUMENTS` for either:
|
|
26
|
+
|
|
27
|
+
- A draft body verbatim (e.g. the user pastes the prose they're about to post).
|
|
28
|
+
- A surface hint (`gh-issue-create`, `gh-pr-comment`, `gh-api-security-advisories`, `gh-issue-edit`, `gh-pr-edit`, `gh-issue-comment`, `gh-pr-create`, `gh-api-comments`, `npm-publish`, `changeset-author`).
|
|
29
|
+
- A destination hint (`anthropics/claude-code#52831`, `vendor private channel`, `npm public registry`).
|
|
30
|
+
|
|
31
|
+
If both draft and surface are present, proceed to step 3. If either is missing, step 2.
|
|
32
|
+
|
|
33
|
+
### 2. Resolve missing context
|
|
34
|
+
|
|
35
|
+
If the draft is missing, use `AskUserQuestion`:
|
|
36
|
+
|
|
37
|
+
> "What draft do you want me to review? Paste the body verbatim — I will pass it to the external-comms reviewer."
|
|
38
|
+
|
|
39
|
+
If the surface is missing AND cannot be inferred from context (e.g. user just said "before I post this comment"), use `AskUserQuestion`:
|
|
40
|
+
|
|
41
|
+
- header: "Target surface"
|
|
42
|
+
- options:
|
|
43
|
+
1. `gh issue create` (public third-party repo)
|
|
44
|
+
2. `gh issue comment` (public third-party repo)
|
|
45
|
+
3. `gh pr create` / `gh pr comment` (public third-party repo)
|
|
46
|
+
4. `gh api .../security-advisories` (vendor private channel)
|
|
47
|
+
5. `npm publish` (permanently published artefact)
|
|
48
|
+
6. `.changeset/*.md` (lands in CHANGELOG + Release PR + every npm tarball)
|
|
49
|
+
|
|
50
|
+
Do not ask if the surface is obvious from the conversation context.
|
|
51
|
+
|
|
52
|
+
### 3. Construct the review prompt
|
|
53
|
+
|
|
54
|
+
Build a self-contained prompt for the `wr-risk-scorer:external-comms` subagent that includes:
|
|
55
|
+
|
|
56
|
+
- The **draft body** verbatim (between explicit `<draft>...</draft>` markers so the agent's substring extraction is unambiguous).
|
|
57
|
+
- The **target surface** (one of the canonical strings above).
|
|
58
|
+
- The **destination** when known.
|
|
59
|
+
- A reminder to compute `EXTERNAL_COMMS_RISK_KEY = sha256(draft + '\n' + surface)`.
|
|
60
|
+
|
|
61
|
+
### 4. Delegate to wr-risk-scorer:external-comms
|
|
62
|
+
|
|
63
|
+
Invoke the subagent via the `Skill` tool:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
subagent_type: wr-risk-scorer:external-comms
|
|
67
|
+
prompt: <constructed review prompt from step 3>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Wait for the subagent to complete. The subagent will output a structured verdict block (`EXTERNAL_COMMS_RISK_VERDICT: PASS|FAIL` + `EXTERNAL_COMMS_RISK_KEY: <sha>` + optional `EXTERNAL_COMMS_RISK_REASON: ...`). The `PostToolUse:Agent` hook (`risk-score-mark.sh`) reads that output and writes the marker automatically.
|
|
71
|
+
|
|
72
|
+
**Do not write to `${TMPDIR:-/tmp}/claude-risk-*` yourself.** The hook is the only correct mechanism.
|
|
73
|
+
|
|
74
|
+
### 5. Present results
|
|
75
|
+
|
|
76
|
+
Present the full review report to the user. Highlight:
|
|
77
|
+
|
|
78
|
+
- The verdict (PASS / FAIL).
|
|
79
|
+
- Each Confidential Information class the draft matched against (FAIL only).
|
|
80
|
+
- The exact substrings that triggered each finding (FAIL only).
|
|
81
|
+
- Whether the gate is now pre-satisfied for the current session for this exact draft+surface key (PASS only): "The next attempt to <surface> with this draft body will proceed without re-prompting."
|
|
82
|
+
|
|
83
|
+
### 6. Above-appetite handling (ADR-013 Rule 6)
|
|
84
|
+
|
|
85
|
+
If the verdict is FAIL, do NOT auto-rewrite the draft. Use `AskUserQuestion`:
|
|
86
|
+
|
|
87
|
+
- header: "Leak detected — next step"
|
|
88
|
+
- options:
|
|
89
|
+
1. `Rewrite the draft and re-review` — return to step 1 with the rewritten body.
|
|
90
|
+
2. `Move to a private channel` — direct the user to a non-public surface (vendor private email, internal Slack, etc.) where the leak does not apply.
|
|
91
|
+
3. `Override anyway` — set `BYPASS_RISK_GATE=1` for the next gated tool call. Reserved for cases where the user has confirmed the content is safe to publish (e.g. the "client name" is actually their own org).
|
|
92
|
+
4. `Cancel` — abandon the post.
|
|
93
|
+
|
|
94
|
+
Do not make the decision unilaterally — per ADR-013 Rule 1, all leak/no-leak judgement calls outside the regex pre-filter belong to the user.
|
|
95
|
+
|
|
96
|
+
$ARGUMENTS
|