@windyroad/voice-tone 0.4.0 → 0.5.0-preview.311
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 +2 -0
- package/agents/external-comms.md +90 -0
- package/hooks/external-comms-evaluator.conf +25 -0
- package/hooks/external-comms-gate.sh +241 -0
- package/hooks/external-comms-mark-reviewed.sh +83 -0
- package/hooks/hooks.json +3 -1
- package/hooks/lib/leak-detect.sh +99 -0
- package/hooks/test/external-comms-gate.bats +200 -0
- package/package.json +1 -1
- package/skills/assess-external-comms/SKILL.md +99 -0
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ The voice-tone plugin:
|
|
|
15
15
|
3. **Reviews** the proposed copy against your `docs/VOICE-AND-TONE.md` guide
|
|
16
16
|
4. **Reports** violations with suggested fixes that match your brand's voice principles, banned patterns, and word list
|
|
17
17
|
|
|
18
|
+
Beyond in-repo edits, the plugin also gates **external communications** — `gh issue create`, `gh pr create`, `gh issue/pr comment`, `gh api security-advisories`, `npm publish`, and `.changeset/*.md` author-time — via the [`wr-voice-tone:external-comms`](agents/external-comms.md) subagent and the on-demand [`/wr-voice-tone:assess-external-comms`](skills/assess-external-comms/SKILL.md) skill. This composes with `@windyroad/risk-scorer`'s sibling external-comms gate (see [ADR-028 amended 2026-05-14](../../docs/decisions/028-voice-tone-gate-external-comms.proposed.md)) — when both plugins are installed, voice/tone and risk/leak review fire independently on the same outbound prose call. Serves [JTBD-001 Enforce Governance Without Slowing Down](../../docs/jtbd/solo-developer/JTBD-001-enforce-governance.proposed.md) and [JTBD-202 Run Pre-Flight Governance Checks Before Release or Handover](../../docs/jtbd/tech-lead/JTBD-202-pre-flight-governance-check.proposed.md).
|
|
19
|
+
|
|
18
20
|
## Install
|
|
19
21
|
|
|
20
22
|
```bash
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: external-comms
|
|
3
|
+
description: Reviews drafts of external-facing prose (gh issues / PRs / advisories, npm publish content, .changeset/*.md bodies) against docs/VOICE-AND-TONE.md voice profile. Read-only — emits a structured PASS/FAIL verdict consumed by the external-comms-mark-reviewed.sh PostToolUse hook.
|
|
4
|
+
tools:
|
|
5
|
+
- Read
|
|
6
|
+
- Glob
|
|
7
|
+
- Grep
|
|
8
|
+
model: inherit
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You are the External-Comms Voice & Tone 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 `docs/VOICE-AND-TONE.md`.
|
|
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 `external-comms-mark-reviewed.sh` PostToolUse hook (P038 / ADR-028 amended 2026-05-14), which writes the per-evaluator marker that allows the gated tool call to proceed.
|
|
14
|
+
|
|
15
|
+
You are the voice-tone half of the external-comms gate. The risk/leak half is handled by `wr-risk-scorer:external-comms`. When both plugins are installed, both evaluators must PASS independently before the gate permits the tool call. The two gates compose at the firing level (per-evaluator markers, no shared composite marker).
|
|
16
|
+
|
|
17
|
+
## What you receive
|
|
18
|
+
|
|
19
|
+
The invoking skill (`/wr-voice-tone:assess-external-comms`) or the agent that hit the gate provides:
|
|
20
|
+
|
|
21
|
+
- The **draft body** verbatim — the exact prose that would land on the external surface.
|
|
22
|
+
- 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`.
|
|
23
|
+
- The **destination** when known (e.g. `anthropics/claude-code#52831`).
|
|
24
|
+
|
|
25
|
+
Read `docs/VOICE-AND-TONE.md` (project root) to get the authoritative voice profile. Typical sections include voice principles, tone by context, banned patterns, word list / terminology, and language/locale conventions.
|
|
26
|
+
|
|
27
|
+
If `docs/VOICE-AND-TONE.md` is absent, the gate will run in advisory-only mode (the canonical hook handles this before delegating to you). You should only be invoked when the policy file exists.
|
|
28
|
+
|
|
29
|
+
## Review process
|
|
30
|
+
|
|
31
|
+
1. **Read the draft and the surface**. The surface determines the audience: `gh-issue-create` lands on a public third-party repo; `npm-publish` lands as a permanently-published artefact in `README.md`; `changeset-author` populates CHANGELOG.md, the Release PR body, GitHub Release page, AND every published npm tarball. Voice-tone expectations may shift by surface — formal advisory bodies sit differently to changelog entries.
|
|
32
|
+
2. **Read `docs/VOICE-AND-TONE.md`** to ground every finding against the named principle / banned pattern / word-list entry. Do not invent rules; do not score by analogy if the guide already names a section that fits.
|
|
33
|
+
3. **Apply context-aware judgement**:
|
|
34
|
+
- AI-tell patterns (em-dashes used decoratively, "it seems", "I'd suggest", excessive hedging, overly-polite closers like "happy to help further") are common voice failures on outbound prose.
|
|
35
|
+
- Stale-target language ("let's keep this ticket open", "happy to help further") on years-old issues is incongruous; surface the mismatch.
|
|
36
|
+
- A `.changeset/*.md` body lands in CHANGELOG.md and every published tarball. Treat changelog entries as the highest-permanence surface — voice/tone errors here persist across every npm tarball and release page.
|
|
37
|
+
- Generic-AI-voice phrases damage credibility on the public face of the user's work; the guide's "Banned patterns" section is the authoritative list.
|
|
38
|
+
|
|
39
|
+
## Verdict format (MANDATORY)
|
|
40
|
+
|
|
41
|
+
End your report with a structured block consumed by `external-comms-mark-reviewed.sh`. Every field is required.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
EXTERNAL_COMMS_VOICE_TONE_VERDICT: PASS
|
|
45
|
+
EXTERNAL_COMMS_VOICE_TONE_KEY: <sha256 hex string>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
OR for a failed review:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
EXTERNAL_COMMS_VOICE_TONE_VERDICT: FAIL
|
|
52
|
+
EXTERNAL_COMMS_VOICE_TONE_KEY: <sha256 hex string>
|
|
53
|
+
EXTERNAL_COMMS_VOICE_TONE_REASON: <one-line description of the voice/tone violation + matched pattern>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Compute the key as:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
printf '%s\n%s' "<draft body verbatim>" "<surface name>" | shasum -a 256 | cut -d' ' -f1
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
## Grounding (ADR-026)
|
|
65
|
+
|
|
66
|
+
Every FAIL verdict MUST cite:
|
|
67
|
+
|
|
68
|
+
- The specific `docs/VOICE-AND-TONE.md` section / principle / banned-pattern entry violated (verbatim — copy the bullet from the guide).
|
|
69
|
+
- The exact substring from the draft that triggered the call.
|
|
70
|
+
- A one-line explanation of why this combination of surface + content violates the guide.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
|
|
74
|
+
> EXTERNAL_COMMS_VOICE_TONE_REASON: "Banned patterns — hedging closers" — draft contains "happy to help further" closing a 2-year-old issue; voice-tone guide names "happy to help further" as banned on stale targets because it implies ongoing engagement that the project cannot sustain.
|
|
75
|
+
|
|
76
|
+
## Constraints
|
|
77
|
+
|
|
78
|
+
- 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.)
|
|
79
|
+
- Do NOT score by analogy when the guide names the principle.
|
|
80
|
+
- Do NOT write to `/tmp/` or any marker location yourself — the PostToolUse hook owns that.
|
|
81
|
+
- Do NOT skip the `EXTERNAL_COMMS_VOICE_TONE_KEY` line; without it, the marker hook has no key to write the marker against and the gate will deny again on retry.
|
|
82
|
+
- 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 voice-tone-review without text" so the user can pre-review manually.
|
|
83
|
+
|
|
84
|
+
## Below-Appetite Output Rule (ADR-013 Rule 5)
|
|
85
|
+
|
|
86
|
+
When the verdict is PASS and no `docs/VOICE-AND-TONE.md` rule matched, your output may be terse: a one-line "no voice/tone violation matched" plus the verdict block. Do not pad with advisory prose; voice-compliant drafts proceed silently.
|
|
87
|
+
|
|
88
|
+
## Above-Appetite (FAIL) Output
|
|
89
|
+
|
|
90
|
+
When the verdict is FAIL, surface remediation suggestions in PROSE BEFORE the verdict block — what specific substrings to rewrite, what tone shift to apply, where to consult the guide. The verdict block itself stays structured and machine-parseable.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Per-package evaluator config for external-comms-gate.sh (ADR-028 amended 2026-05-14).
|
|
2
|
+
# Sourced by the canonical external-comms-gate.sh; NOT synced (each consumer
|
|
3
|
+
# plugin maintains its own .conf).
|
|
4
|
+
|
|
5
|
+
# Short evaluator id — used in marker filenames (external-comms-<id>-reviewed-<key>).
|
|
6
|
+
EXTERNAL_COMMS_EVALUATOR_ID=voice-tone
|
|
7
|
+
|
|
8
|
+
# Subagent type the deny message directs to.
|
|
9
|
+
EXTERNAL_COMMS_SUBAGENT_TYPE=wr-voice-tone:external-comms
|
|
10
|
+
|
|
11
|
+
# Structured-output prefix the PostToolUse:Agent hook parses from the subagent's
|
|
12
|
+
# stdout (EXTERNAL_COMMS_VOICE_TONE_VERDICT + EXTERNAL_COMMS_VOICE_TONE_KEY).
|
|
13
|
+
EXTERNAL_COMMS_VERDICT_PREFIX=EXTERNAL_COMMS_VOICE_TONE
|
|
14
|
+
|
|
15
|
+
# On-demand skill for pre-flight delegation.
|
|
16
|
+
EXTERNAL_COMMS_ASSESS_SKILL=/wr-voice-tone:assess-external-comms
|
|
17
|
+
|
|
18
|
+
# Policy file whose absence triggers advisory-only mode.
|
|
19
|
+
EXTERNAL_COMMS_POLICY_FILE=docs/VOICE-AND-TONE.md
|
|
20
|
+
|
|
21
|
+
# Whether to run the leak-pattern pre-filter (lib/leak-detect.sh). Voice-tone
|
|
22
|
+
# evaluator reviews tone/voice only; leak detection is the risk evaluator's
|
|
23
|
+
# concern (run by packages/risk-scorer/hooks/external-comms-gate.sh when that
|
|
24
|
+
# plugin is also installed).
|
|
25
|
+
EXTERNAL_COMMS_LEAK_PREFILTER=no
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: gates outbound prose for evaluator review (P064 / P038 / ADR-028 amended 2026-05-14).
|
|
3
|
+
#
|
|
4
|
+
# This is the CANONICAL hook synced byte-identically into each consumer plugin
|
|
5
|
+
# (risk-scorer, voice-tone, …) via ADR-017 duplicate-script pattern. Each copy
|
|
6
|
+
# sources `${SCRIPT_DIR}/external-comms-evaluator.conf` to determine its
|
|
7
|
+
# evaluator identity (risk / voice-tone / …) — the .conf file is per-package
|
|
8
|
+
# and NOT synced.
|
|
9
|
+
#
|
|
10
|
+
# Surface (matched on Bash command text or Edit/Write file_path):
|
|
11
|
+
# - gh issue create | comment | edit (public issue bodies)
|
|
12
|
+
# - gh pr create | comment | edit (public PR bodies)
|
|
13
|
+
# - gh api .../security-advisories (advisory drafts)
|
|
14
|
+
# - gh api .../comments (any REST surface accepting prose)
|
|
15
|
+
# - npm publish (README / package metadata to npm)
|
|
16
|
+
# - PreToolUse:Write|Edit on .changeset/*.md (P073 — gates author-time)
|
|
17
|
+
#
|
|
18
|
+
# Gate behaviour:
|
|
19
|
+
# 1. BYPASS_RISK_GATE=1 short-circuits the gate (consistent with git-push-gate.sh).
|
|
20
|
+
# 2. POLICY_FILE absent → advisory-only mode (permits with systemMessage).
|
|
21
|
+
# 3. Hybrid leak-pattern pre-filter (lib/leak-detect.sh) hard-fails on
|
|
22
|
+
# credentials, prod-URL prefixes, business-context-paired financial figures,
|
|
23
|
+
# or business-context-paired user counts. Deny includes the matched class.
|
|
24
|
+
# (Voice-tone evaluator: skips leak pre-filter — leak detection is the
|
|
25
|
+
# risk evaluator's concern; voice-tone reviews tone/voice only.)
|
|
26
|
+
# 4. Otherwise: check for THIS evaluator's per-evaluator marker keyed on
|
|
27
|
+
# sha256(draft_body + '\n' + surface). Marker present → permit.
|
|
28
|
+
# Marker absent → deny with directive to delegate to this plugin's
|
|
29
|
+
# subagent (configured via external-comms-evaluator.conf).
|
|
30
|
+
#
|
|
31
|
+
# Marker location: ${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}/external-comms-<EVALUATOR_ID>-reviewed-<sha256>
|
|
32
|
+
# Marker writer: PostToolUse:Agent hook in each consumer plugin
|
|
33
|
+
# (risk-score-mark.sh or external-comms-mark-reviewed.sh) on
|
|
34
|
+
# subagent type wr-<plugin>:external-comms.
|
|
35
|
+
#
|
|
36
|
+
# Per-evaluator marker scheme (ADR-028 amended 2026-05-14): when both
|
|
37
|
+
# voice-tone and risk-scorer are installed, both gates fire on the same
|
|
38
|
+
# PreToolUse event; each gate denies until its own per-evaluator marker
|
|
39
|
+
# exists. Gates compose at firing level — no shared composite marker.
|
|
40
|
+
|
|
41
|
+
set -euo pipefail
|
|
42
|
+
|
|
43
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
44
|
+
# shellcheck source=lib/leak-detect.sh
|
|
45
|
+
source "$SCRIPT_DIR/lib/leak-detect.sh"
|
|
46
|
+
|
|
47
|
+
# ---------- Per-package evaluator config (ADR-028 amended 2026-05-14) ----------
|
|
48
|
+
# Each consumer plugin ships its own external-comms-evaluator.conf alongside this
|
|
49
|
+
# byte-identical canonical hook. The .conf defines:
|
|
50
|
+
# EXTERNAL_COMMS_EVALUATOR_ID — short id (risk, voice-tone)
|
|
51
|
+
# EXTERNAL_COMMS_SUBAGENT_TYPE — subagent to delegate to (wr-<plugin>:external-comms)
|
|
52
|
+
# EXTERNAL_COMMS_VERDICT_PREFIX — structured-output prefix the mark hook parses
|
|
53
|
+
# EXTERNAL_COMMS_ASSESS_SKILL — on-demand skill path for manual delegation
|
|
54
|
+
# EXTERNAL_COMMS_POLICY_FILE — policy doc whose absence triggers advisory-only
|
|
55
|
+
# EXTERNAL_COMMS_LEAK_PREFILTER — yes|no — whether to run leak-detect pre-filter
|
|
56
|
+
# Fail-closed if absent: this hook cannot operate without a configured evaluator.
|
|
57
|
+
CONF_FILE="$SCRIPT_DIR/external-comms-evaluator.conf"
|
|
58
|
+
if [ ! -f "$CONF_FILE" ]; then
|
|
59
|
+
echo "ERROR: external-comms-gate.sh requires $CONF_FILE (ADR-028 amended 2026-05-14)" >&2
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
# shellcheck source=/dev/null
|
|
63
|
+
source "$CONF_FILE"
|
|
64
|
+
: "${EXTERNAL_COMMS_EVALUATOR_ID:?evaluator id missing from $CONF_FILE}"
|
|
65
|
+
: "${EXTERNAL_COMMS_SUBAGENT_TYPE:?subagent type missing from $CONF_FILE}"
|
|
66
|
+
: "${EXTERNAL_COMMS_ASSESS_SKILL:?assess-skill missing from $CONF_FILE}"
|
|
67
|
+
EXTERNAL_COMMS_POLICY_FILE="${EXTERNAL_COMMS_POLICY_FILE:-RISK-POLICY.md}"
|
|
68
|
+
EXTERNAL_COMMS_LEAK_PREFILTER="${EXTERNAL_COMMS_LEAK_PREFILTER:-yes}"
|
|
69
|
+
|
|
70
|
+
# ---------- Bypass ----------
|
|
71
|
+
if [ "${BYPASS_RISK_GATE:-0}" = "1" ]; then
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
INPUT=$(cat)
|
|
76
|
+
|
|
77
|
+
# Extract tool name + tool_input via python3 (consistent with sibling hooks).
|
|
78
|
+
TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "
|
|
79
|
+
import sys, json
|
|
80
|
+
try:
|
|
81
|
+
print(json.load(sys.stdin).get('tool_name', ''))
|
|
82
|
+
except Exception:
|
|
83
|
+
print('')
|
|
84
|
+
" 2>/dev/null || echo "")
|
|
85
|
+
|
|
86
|
+
SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "
|
|
87
|
+
import sys, json
|
|
88
|
+
try:
|
|
89
|
+
print(json.load(sys.stdin).get('session_id', ''))
|
|
90
|
+
except Exception:
|
|
91
|
+
print('')
|
|
92
|
+
" 2>/dev/null || echo "")
|
|
93
|
+
|
|
94
|
+
# Permit silently when session_id is absent; the gate cannot key a marker.
|
|
95
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
96
|
+
|
|
97
|
+
# ---------- Surface detection ----------
|
|
98
|
+
SURFACE=""
|
|
99
|
+
DRAFT=""
|
|
100
|
+
|
|
101
|
+
case "$TOOL_NAME" in
|
|
102
|
+
Bash)
|
|
103
|
+
COMMAND=$(printf '%s' "$INPUT" | python3 -c "
|
|
104
|
+
import sys, json
|
|
105
|
+
try:
|
|
106
|
+
print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
|
|
107
|
+
except Exception:
|
|
108
|
+
print('')
|
|
109
|
+
" 2>/dev/null || echo "")
|
|
110
|
+
|
|
111
|
+
# Surface match — most-specific first.
|
|
112
|
+
if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue create(\s|$)'; then
|
|
113
|
+
SURFACE="gh-issue-create"
|
|
114
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue comment(\s|$)'; then
|
|
115
|
+
SURFACE="gh-issue-comment"
|
|
116
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue edit(\s|$)'; then
|
|
117
|
+
SURFACE="gh-issue-edit"
|
|
118
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr create(\s|$)'; then
|
|
119
|
+
SURFACE="gh-pr-create"
|
|
120
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr comment(\s|$)'; then
|
|
121
|
+
SURFACE="gh-pr-comment"
|
|
122
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr edit(\s|$)'; then
|
|
123
|
+
SURFACE="gh-pr-edit"
|
|
124
|
+
elif echo "$COMMAND" | grep -qE 'gh api .*security-advisories'; then
|
|
125
|
+
SURFACE="gh-api-security-advisories"
|
|
126
|
+
elif echo "$COMMAND" | grep -qE 'gh api .*/comments'; then
|
|
127
|
+
SURFACE="gh-api-comments"
|
|
128
|
+
elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm publish(\s|$)'; then
|
|
129
|
+
SURFACE="npm-publish"
|
|
130
|
+
else
|
|
131
|
+
exit 0
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# Best-effort body extraction: --body 'TEXT' or --body "TEXT" or --field summary='TEXT'.
|
|
135
|
+
# When absent (npm publish, --body-file), DRAFT="" is acceptable: the agent will
|
|
136
|
+
# be invoked with command context and read whatever body source the call uses.
|
|
137
|
+
DRAFT=$(printf '%s' "$COMMAND" | python3 -c "
|
|
138
|
+
import sys, re
|
|
139
|
+
cmd = sys.stdin.read()
|
|
140
|
+
# Match --body '...' or --body \"...\" or --field summary='...'
|
|
141
|
+
for pat in [r\"--body[= ]'([^']*)'\", r'--body[= ]\"([^\"]*)\"',
|
|
142
|
+
r\"--field [a-zA-Z_]+='([^']*)'\", r'--field [a-zA-Z_]+=\"([^\"]*)\"']:
|
|
143
|
+
m = re.search(pat, cmd)
|
|
144
|
+
if m:
|
|
145
|
+
print(m.group(1))
|
|
146
|
+
break
|
|
147
|
+
" 2>/dev/null || echo "")
|
|
148
|
+
;;
|
|
149
|
+
|
|
150
|
+
Write|Edit)
|
|
151
|
+
FILE_PATH=$(printf '%s' "$INPUT" | python3 -c "
|
|
152
|
+
import sys, json
|
|
153
|
+
try:
|
|
154
|
+
ti = json.load(sys.stdin).get('tool_input', {})
|
|
155
|
+
print(ti.get('file_path', ti.get('path', '')))
|
|
156
|
+
except Exception:
|
|
157
|
+
print('')
|
|
158
|
+
" 2>/dev/null || echo "")
|
|
159
|
+
|
|
160
|
+
case "$FILE_PATH" in
|
|
161
|
+
*.changeset/*.md|*/.changeset/*.md|.changeset/*.md)
|
|
162
|
+
SURFACE="changeset-author"
|
|
163
|
+
;;
|
|
164
|
+
*)
|
|
165
|
+
exit 0
|
|
166
|
+
;;
|
|
167
|
+
esac
|
|
168
|
+
|
|
169
|
+
DRAFT=$(printf '%s' "$INPUT" | python3 -c "
|
|
170
|
+
import sys, json
|
|
171
|
+
try:
|
|
172
|
+
ti = json.load(sys.stdin).get('tool_input', {})
|
|
173
|
+
print(ti.get('content', '') + ti.get('new_string', ''))
|
|
174
|
+
except Exception:
|
|
175
|
+
print('')
|
|
176
|
+
" 2>/dev/null || echo "")
|
|
177
|
+
;;
|
|
178
|
+
|
|
179
|
+
*)
|
|
180
|
+
exit 0
|
|
181
|
+
;;
|
|
182
|
+
esac
|
|
183
|
+
|
|
184
|
+
# ---------- Helpers ----------
|
|
185
|
+
deny_with_reason() {
|
|
186
|
+
local reason="$1"
|
|
187
|
+
python3 -c "
|
|
188
|
+
import json, sys
|
|
189
|
+
print(json.dumps({
|
|
190
|
+
'hookSpecificOutput': {
|
|
191
|
+
'hookEventName': 'PreToolUse',
|
|
192
|
+
'permissionDecision': 'deny',
|
|
193
|
+
'permissionDecisionReason': sys.argv[1]
|
|
194
|
+
}
|
|
195
|
+
}))
|
|
196
|
+
" "$reason"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
permit_with_advisory() {
|
|
200
|
+
local msg="$1"
|
|
201
|
+
python3 -c "
|
|
202
|
+
import json, sys
|
|
203
|
+
print(json.dumps({'systemMessage': sys.argv[1]}))
|
|
204
|
+
" "$msg"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# ---------- Advisory-only fallback when policy file is absent ----------
|
|
208
|
+
if [ ! -f "$EXTERNAL_COMMS_POLICY_FILE" ]; then
|
|
209
|
+
permit_with_advisory "$EXTERNAL_COMMS_POLICY_FILE not found — $EXTERNAL_COMMS_SUBAGENT_TYPE gate is advisory-only on $SURFACE."
|
|
210
|
+
exit 0
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# ---------- Hard-fail leak-pattern pre-filter (risk evaluator only) ----------
|
|
214
|
+
# Voice-tone evaluator skips this — leak detection is the risk evaluator's
|
|
215
|
+
# concern. Each per-package external-comms-evaluator.conf sets
|
|
216
|
+
# EXTERNAL_COMMS_LEAK_PREFILTER=yes (risk) or =no (voice-tone).
|
|
217
|
+
if [ "$EXTERNAL_COMMS_LEAK_PREFILTER" = "yes" ]; then
|
|
218
|
+
if ! leak_detect_scan "$DRAFT"; then
|
|
219
|
+
REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s on %s. Remove the leak before retrying. Override only if intentional: BYPASS_RISK_GATE=1.' \
|
|
220
|
+
"$EXTERNAL_COMMS_EVALUATOR_ID" "$LEAK_DETECT_REASON" "$SURFACE")
|
|
221
|
+
deny_with_reason "$REASON"
|
|
222
|
+
exit 0
|
|
223
|
+
fi
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
# ---------- Marker-based gate (per-evaluator marker per ADR-028 amended 2026-05-14) ----------
|
|
227
|
+
SESSION_DIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
|
|
228
|
+
mkdir -p "$SESSION_DIR"
|
|
229
|
+
KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
230
|
+
MARKER="${SESSION_DIR}/external-comms-${EXTERNAL_COMMS_EVALUATOR_ID}-reviewed-${KEY}"
|
|
231
|
+
|
|
232
|
+
if [ -f "$MARKER" ]; then
|
|
233
|
+
exit 0
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
# Marker absent — deny + delegate.
|
|
237
|
+
VERDICT_PREFIX="${EXTERNAL_COMMS_VERDICT_PREFIX:-EXTERNAL_COMMS_${EXTERNAL_COMMS_EVALUATOR_ID^^}}"
|
|
238
|
+
REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with the draft body for review. The PostToolUse hook will mark this draft reviewed when the subagent emits %s_VERDICT: PASS. Use %s for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
|
|
239
|
+
"$EXTERNAL_COMMS_EVALUATOR_ID" "$SURFACE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$VERDICT_PREFIX" "$EXTERNAL_COMMS_ASSESS_SKILL")
|
|
240
|
+
deny_with_reason "$REASON"
|
|
241
|
+
exit 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse:Agent hook for the wr-voice-tone:external-comms subagent.
|
|
3
|
+
# Parses structured verdict from agent output and writes the per-evaluator
|
|
4
|
+
# marker that the canonical external-comms-gate.sh checks (P038 / ADR-028
|
|
5
|
+
# amended 2026-05-14).
|
|
6
|
+
#
|
|
7
|
+
# Marker filename: external-comms-voice-tone-reviewed-<KEY>
|
|
8
|
+
# Marker location: ${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}/
|
|
9
|
+
#
|
|
10
|
+
# The risk-scorer evaluator writes its own per-evaluator marker
|
|
11
|
+
# (external-comms-risk-reviewed-<KEY>) from packages/risk-scorer/hooks/
|
|
12
|
+
# risk-score-mark.sh. When both plugins installed, both gates fire on the
|
|
13
|
+
# same PreToolUse event; both deny until both per-evaluator markers exist.
|
|
14
|
+
# Gates compose at firing level — no shared composite marker.
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
|
|
22
|
+
TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "
|
|
23
|
+
import sys, json
|
|
24
|
+
try:
|
|
25
|
+
print(json.load(sys.stdin).get('tool_name', ''))
|
|
26
|
+
except Exception:
|
|
27
|
+
print('')
|
|
28
|
+
" 2>/dev/null || echo "")
|
|
29
|
+
[ "$TOOL_NAME" = "Agent" ] || exit 0
|
|
30
|
+
|
|
31
|
+
SUBAGENT=$(printf '%s' "$INPUT" | python3 -c "
|
|
32
|
+
import sys, json
|
|
33
|
+
try:
|
|
34
|
+
print(json.load(sys.stdin).get('tool_input', {}).get('subagent_type', ''))
|
|
35
|
+
except Exception:
|
|
36
|
+
print('')
|
|
37
|
+
" 2>/dev/null || echo "")
|
|
38
|
+
|
|
39
|
+
SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "
|
|
40
|
+
import sys, json
|
|
41
|
+
try:
|
|
42
|
+
print(json.load(sys.stdin).get('session_id', ''))
|
|
43
|
+
except Exception:
|
|
44
|
+
print('')
|
|
45
|
+
" 2>/dev/null || echo "")
|
|
46
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
47
|
+
|
|
48
|
+
# Only handle the voice-tone external-comms subagent.
|
|
49
|
+
case "$SUBAGENT" in
|
|
50
|
+
*voice-tone*external-comms*|*wr-voice-tone:external-comms*) ;;
|
|
51
|
+
*) exit 0 ;;
|
|
52
|
+
esac
|
|
53
|
+
|
|
54
|
+
AGENT_OUTPUT=$(printf '%s' "$INPUT" | python3 -c "
|
|
55
|
+
import sys, json
|
|
56
|
+
try:
|
|
57
|
+
data = json.load(sys.stdin)
|
|
58
|
+
out = data.get('tool_response', {}).get('output', '')
|
|
59
|
+
if not out:
|
|
60
|
+
out = data.get('tool_response', '')
|
|
61
|
+
print(out if isinstance(out, str) else json.dumps(out))
|
|
62
|
+
except Exception:
|
|
63
|
+
print('')
|
|
64
|
+
" 2>/dev/null || echo "")
|
|
65
|
+
|
|
66
|
+
RDIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
|
|
67
|
+
mkdir -p "$RDIR"
|
|
68
|
+
|
|
69
|
+
VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_VOICE_TONE_VERDICT:' | tail -1) || true
|
|
70
|
+
KEY_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_VOICE_TONE_KEY:' | tail -1) || true
|
|
71
|
+
VERDICT=$(echo "$VERDICT_LINE" | sed 's/^EXTERNAL_COMMS_VOICE_TONE_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
|
|
72
|
+
KEY=$(echo "$KEY_LINE" | sed 's/^EXTERNAL_COMMS_VOICE_TONE_KEY:[[:space:]]*//' | tr -d '[:space:]')
|
|
73
|
+
|
|
74
|
+
# Validate key: 64 hex chars (sha256 output). Reject anything else.
|
|
75
|
+
if echo "$KEY" | grep -qE '^[0-9a-f]{64}$'; then
|
|
76
|
+
case "$VERDICT" in
|
|
77
|
+
PASS) touch "${RDIR}/external-comms-voice-tone-reviewed-${KEY}" ;;
|
|
78
|
+
FAIL) ;; # Do NOT create marker — draft must be revised
|
|
79
|
+
*) ;; # Unknown verdict — fail closed
|
|
80
|
+
esac
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
exit 0
|
package/hooks/hooks.json
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-eval.sh" }] }
|
|
5
5
|
],
|
|
6
6
|
"PreToolUse": [
|
|
7
|
-
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-enforce-edit.sh" }] }
|
|
7
|
+
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-enforce-edit.sh" }] },
|
|
8
|
+
{ "matcher": "Bash|Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/external-comms-gate.sh" }] }
|
|
8
9
|
],
|
|
9
10
|
"PostToolUse": [
|
|
10
11
|
{ "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-mark-reviewed.sh" }] },
|
|
12
|
+
{ "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/external-comms-mark-reviewed.sh" }] },
|
|
11
13
|
{ "matcher": "Agent|Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-slide-marker.sh" }] }
|
|
12
14
|
]
|
|
13
15
|
}
|
|
@@ -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,200 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
# Tests for packages/voice-tone/hooks/external-comms-gate.sh
|
|
3
|
+
# (P038 / ADR-028 amended 2026-05-14).
|
|
4
|
+
#
|
|
5
|
+
# Behavioural: the gate denies outbound prose tool calls until the
|
|
6
|
+
# wr-voice-tone:external-comms subagent has reviewed the draft and the
|
|
7
|
+
# per-evaluator marker `external-comms-voice-tone-reviewed-<KEY>` has been
|
|
8
|
+
# written. Voice-tone evaluator does NOT run the leak-pattern pre-filter
|
|
9
|
+
# (EXTERNAL_COMMS_LEAK_PREFILTER=no in external-comms-evaluator.conf).
|
|
10
|
+
# Composition with the risk-scorer evaluator happens at firing level —
|
|
11
|
+
# both gates fire on the same PreToolUse event when both plugins installed.
|
|
12
|
+
|
|
13
|
+
setup() {
|
|
14
|
+
HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
15
|
+
HOOK="$HOOKS_DIR/external-comms-gate.sh"
|
|
16
|
+
|
|
17
|
+
TEST_SESSION="bats-vt-extcomms-gate-$$-${BATS_TEST_NUMBER}"
|
|
18
|
+
RDIR="${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
|
|
19
|
+
rm -rf "$RDIR"
|
|
20
|
+
mkdir -p "$RDIR"
|
|
21
|
+
|
|
22
|
+
# Voice-tone evaluator's policy file is docs/VOICE-AND-TONE.md per the .conf.
|
|
23
|
+
TEST_PROJECT_DIR="$(mktemp -d)"
|
|
24
|
+
mkdir -p "$TEST_PROJECT_DIR/docs"
|
|
25
|
+
printf "## Voice principles\n- Direct\n- No hedging\n## Banned patterns\n- 'happy to help further'\n" \
|
|
26
|
+
> "$TEST_PROJECT_DIR/docs/VOICE-AND-TONE.md"
|
|
27
|
+
|
|
28
|
+
unset BYPASS_RISK_GATE
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
teardown() {
|
|
32
|
+
rm -rf "$RDIR"
|
|
33
|
+
rm -rf "$TEST_PROJECT_DIR"
|
|
34
|
+
unset BYPASS_RISK_GATE
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# ---------- Helpers ----------
|
|
38
|
+
|
|
39
|
+
build_bash_input() {
|
|
40
|
+
local cmd="$1"
|
|
41
|
+
python3 -c "
|
|
42
|
+
import json, sys
|
|
43
|
+
print(json.dumps({
|
|
44
|
+
'session_id': '$TEST_SESSION',
|
|
45
|
+
'tool_name': 'Bash',
|
|
46
|
+
'tool_input': {'command': sys.argv[1]},
|
|
47
|
+
}))
|
|
48
|
+
" "$cmd"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
build_write_input() {
|
|
52
|
+
local file_path="$1"
|
|
53
|
+
local content="$2"
|
|
54
|
+
python3 -c "
|
|
55
|
+
import json, sys
|
|
56
|
+
print(json.dumps({
|
|
57
|
+
'session_id': '$TEST_SESSION',
|
|
58
|
+
'tool_name': 'Write',
|
|
59
|
+
'tool_input': {'file_path': sys.argv[1], 'content': sys.argv[2]},
|
|
60
|
+
}))
|
|
61
|
+
" "$file_path" "$content"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
run_hook() {
|
|
65
|
+
local input="$1"
|
|
66
|
+
run bash -c "cd '$TEST_PROJECT_DIR' && printf '%s' \"\$1\" | '$HOOK'" _ "$input"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# ---------- Tests ----------
|
|
70
|
+
|
|
71
|
+
@test "non-matching Bash command (ls) is allowed silently" {
|
|
72
|
+
INPUT=$(build_bash_input "ls -la")
|
|
73
|
+
run_hook "$INPUT"
|
|
74
|
+
[ "$status" -eq 0 ]
|
|
75
|
+
[ -z "$output" ]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@test "gh issue create with clean draft denies and prompts wr-voice-tone:external-comms delegation (no marker yet)" {
|
|
79
|
+
INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure on Node 20'")
|
|
80
|
+
run_hook "$INPUT"
|
|
81
|
+
[ "$status" -eq 0 ]
|
|
82
|
+
[[ "$output" == *"permissionDecision"* ]]
|
|
83
|
+
[[ "$output" == *"deny"* ]]
|
|
84
|
+
[[ "$output" == *"wr-voice-tone:external-comms"* ]]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@test "voice-tone evaluator skips leak pre-filter (EXTERNAL_COMMS_LEAK_PREFILTER=no)" {
|
|
88
|
+
# A draft with leak-shaped content (revenue figure with business context) would
|
|
89
|
+
# hard-fail in the risk evaluator. The voice-tone gate must NOT hard-fail; leak
|
|
90
|
+
# detection is the risk evaluator's concern. Voice-tone deny-and-delegates for
|
|
91
|
+
# subagent review, same as any clean draft.
|
|
92
|
+
INPUT=$(build_bash_input "gh issue comment 42 --body 'Acme Corp 2.4M ARR is a real concern'")
|
|
93
|
+
run_hook "$INPUT"
|
|
94
|
+
[ "$status" -eq 0 ]
|
|
95
|
+
[[ "$output" == *"deny"* ]]
|
|
96
|
+
[[ "$output" == *"wr-voice-tone:external-comms"* ]]
|
|
97
|
+
# Must NOT name a leak class.
|
|
98
|
+
[[ "$output" != *"credential"* ]]
|
|
99
|
+
[[ "$output" != *"financial"* ]]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@test "BYPASS_RISK_GATE=1 short-circuits the deny" {
|
|
103
|
+
INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure'")
|
|
104
|
+
run bash -c "cd '$TEST_PROJECT_DIR' && BYPASS_RISK_GATE=1 printf '%s' \"\$1\" | BYPASS_RISK_GATE=1 '$HOOK'" _ "$INPUT"
|
|
105
|
+
[ "$status" -eq 0 ]
|
|
106
|
+
[ -z "$output" ]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@test "per-evaluator marker (external-comms-voice-tone-reviewed-<KEY>) allows the call" {
|
|
110
|
+
DRAFT="we observed a build failure on Node 20"
|
|
111
|
+
SURFACE="gh-issue-create"
|
|
112
|
+
KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
113
|
+
touch "${RDIR}/external-comms-voice-tone-reviewed-${KEY}"
|
|
114
|
+
|
|
115
|
+
INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
|
|
116
|
+
run_hook "$INPUT"
|
|
117
|
+
[ "$status" -eq 0 ]
|
|
118
|
+
[ -z "$output" ]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@test "risk-scorer marker (external-comms-risk-reviewed-<KEY>) does NOT satisfy the voice-tone gate" {
|
|
122
|
+
# Independent per-evaluator markers: a risk-evaluator PASS marker does not
|
|
123
|
+
# imply voice-tone has been reviewed.
|
|
124
|
+
DRAFT="we observed a build failure on Node 20"
|
|
125
|
+
SURFACE="gh-issue-create"
|
|
126
|
+
KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
127
|
+
touch "${RDIR}/external-comms-risk-reviewed-${KEY}"
|
|
128
|
+
|
|
129
|
+
INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
|
|
130
|
+
run_hook "$INPUT"
|
|
131
|
+
[ "$status" -eq 0 ]
|
|
132
|
+
[[ "$output" == *"deny"* ]]
|
|
133
|
+
[[ "$output" == *"wr-voice-tone:external-comms"* ]]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@test "docs/VOICE-AND-TONE.md absent yields advisory-only mode (permits)" {
|
|
137
|
+
rm -f "$TEST_PROJECT_DIR/docs/VOICE-AND-TONE.md"
|
|
138
|
+
INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a failure'")
|
|
139
|
+
run_hook "$INPUT"
|
|
140
|
+
[ "$status" -eq 0 ]
|
|
141
|
+
# Must NOT deny when policy file is absent.
|
|
142
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
143
|
+
[[ "$output" != *"\"permissionDecision\":\"deny\""* ]]
|
|
144
|
+
# Must surface the advisory systemMessage.
|
|
145
|
+
[[ "$output" == *"docs/VOICE-AND-TONE.md not found"* ]]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@test "PreToolUse:Write on .changeset/*.md triggers deny+delegate" {
|
|
149
|
+
INPUT=$(build_write_input ".changeset/test.md" "Add some feature. Happy to help further with details.")
|
|
150
|
+
run_hook "$INPUT"
|
|
151
|
+
[ "$status" -eq 0 ]
|
|
152
|
+
[[ "$output" == *"deny"* ]]
|
|
153
|
+
[[ "$output" == *"wr-voice-tone:external-comms"* ]]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@test "PreToolUse:Write on a non-changeset path is ignored" {
|
|
157
|
+
INPUT=$(build_write_input "src/foo.ts" "happy to help further")
|
|
158
|
+
run_hook "$INPUT"
|
|
159
|
+
[ "$status" -eq 0 ]
|
|
160
|
+
[ -z "$output" ]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@test "gh api security-advisories triggers the gate" {
|
|
164
|
+
INPUT=$(build_bash_input "gh api repos/foo/bar/security-advisories --method POST --field summary='vulnerability detail'")
|
|
165
|
+
run_hook "$INPUT"
|
|
166
|
+
[ "$status" -eq 0 ]
|
|
167
|
+
[[ "$output" == *"deny"* ]]
|
|
168
|
+
[[ "$output" == *"wr-voice-tone:external-comms"* ]]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@test "npm publish triggers the gate" {
|
|
172
|
+
INPUT=$(build_bash_input "npm publish")
|
|
173
|
+
run_hook "$INPUT"
|
|
174
|
+
[ "$status" -eq 0 ]
|
|
175
|
+
[[ "$output" == *"deny"* ]]
|
|
176
|
+
[[ "$output" == *"wr-voice-tone:external-comms"* ]]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@test "deny message references the on-demand skill (/wr-voice-tone:assess-external-comms)" {
|
|
180
|
+
INPUT=$(build_bash_input "gh issue comment 42 --body 'a draft'")
|
|
181
|
+
run_hook "$INPUT"
|
|
182
|
+
[ "$status" -eq 0 ]
|
|
183
|
+
[[ "$output" == *"/wr-voice-tone:assess-external-comms"* ]]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@test "marker name uses evaluator id from external-comms-evaluator.conf (voice-tone, not risk)" {
|
|
187
|
+
# Regression: the canonical hook sources the .conf and uses its EVALUATOR_ID
|
|
188
|
+
# in the marker filename. The risk-scorer's marker name does NOT satisfy the
|
|
189
|
+
# voice-tone gate even if the KEY matches.
|
|
190
|
+
DRAFT="some draft body"
|
|
191
|
+
SURFACE="gh-issue-create"
|
|
192
|
+
KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
193
|
+
# Pre-amendment combined marker — should NOT satisfy the new voice-tone gate.
|
|
194
|
+
touch "${RDIR}/external-comms-reviewed-${KEY}"
|
|
195
|
+
|
|
196
|
+
INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
|
|
197
|
+
run_hook "$INPUT"
|
|
198
|
+
[ "$status" -eq 0 ]
|
|
199
|
+
[[ "$output" == *"deny"* ]]
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wr-voice-tone:assess-external-comms
|
|
3
|
+
description: On-demand external-comms voice & tone review. Reviews a draft of an outbound prose tool call (gh issue/pr body, security advisory, npm publish content, or .changeset/*.md body) against docs/VOICE-AND-TONE.md. Delegates to wr-voice-tone: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 Voice & Tone Assessment Skill
|
|
8
|
+
|
|
9
|
+
Run a voice & tone 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 (`external-comms-mark-reviewed.sh`) after the subagent completes — the skill never writes to `${TMPDIR:-/tmp}/claude-risk-*` directly.
|
|
12
|
+
|
|
13
|
+
This is the voice-tone half of the external-comms gate. The risk/leak half is handled by `/wr-risk-scorer:assess-external-comms`. When both plugins are installed, both evaluators must PASS independently before the gate permits the tool call.
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- Before drafting a `gh issue create` / `gh pr create` / `gh issue comment` / `gh pr comment` to a third-party repo.
|
|
18
|
+
- Before drafting a `gh api .../security-advisories` body for a vendor private channel.
|
|
19
|
+
- Before authoring a `.changeset/*.md` body that will land in CHANGELOG.md and every published npm tarball (P073).
|
|
20
|
+
- Before `npm publish` when the README diff is non-trivial.
|
|
21
|
+
- After hitting the external-comms gate's deny-and-delegate prompt: this skill is the structured walkthrough that closes the voice-tone loop.
|
|
22
|
+
|
|
23
|
+
## Steps
|
|
24
|
+
|
|
25
|
+
### 1. Parse arguments
|
|
26
|
+
|
|
27
|
+
Read `$ARGUMENTS` for either:
|
|
28
|
+
|
|
29
|
+
- A draft body verbatim (e.g. the user pastes the prose they're about to post).
|
|
30
|
+
- 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`).
|
|
31
|
+
- A destination hint (`anthropics/claude-code#52831`, `vendor private channel`, `npm public registry`).
|
|
32
|
+
|
|
33
|
+
If both draft and surface are present, proceed to step 3. If either is missing, step 2.
|
|
34
|
+
|
|
35
|
+
### 2. Resolve missing context
|
|
36
|
+
|
|
37
|
+
If the draft is missing, use `AskUserQuestion`:
|
|
38
|
+
|
|
39
|
+
> "What draft do you want me to review? Paste the body verbatim — I will pass it to the external-comms voice-tone reviewer."
|
|
40
|
+
|
|
41
|
+
If the surface is missing AND cannot be inferred from context (e.g. user just said "before I post this comment"), use `AskUserQuestion`:
|
|
42
|
+
|
|
43
|
+
- header: "Target surface"
|
|
44
|
+
- options:
|
|
45
|
+
1. `gh issue create` (public third-party repo)
|
|
46
|
+
2. `gh issue comment` (public third-party repo)
|
|
47
|
+
3. `gh pr create` / `gh pr comment` (public third-party repo)
|
|
48
|
+
4. `gh api .../security-advisories` (vendor private channel)
|
|
49
|
+
5. `npm publish` (permanently published artefact)
|
|
50
|
+
6. `.changeset/*.md` (lands in CHANGELOG + Release PR + every npm tarball)
|
|
51
|
+
|
|
52
|
+
Do not ask if the surface is obvious from the conversation context.
|
|
53
|
+
|
|
54
|
+
### 3. Construct the review prompt
|
|
55
|
+
|
|
56
|
+
Build a self-contained prompt for the `wr-voice-tone:external-comms` subagent that includes:
|
|
57
|
+
|
|
58
|
+
- The **draft body** verbatim (between explicit `<draft>...</draft>` markers so the agent's substring extraction is unambiguous).
|
|
59
|
+
- The **target surface** (one of the canonical strings above).
|
|
60
|
+
- The **destination** when known.
|
|
61
|
+
- A reminder to compute `EXTERNAL_COMMS_VOICE_TONE_KEY = sha256(draft + '\n' + surface)`.
|
|
62
|
+
|
|
63
|
+
### 4. Delegate to wr-voice-tone:external-comms
|
|
64
|
+
|
|
65
|
+
Invoke the subagent via the `Skill` tool:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
subagent_type: wr-voice-tone:external-comms
|
|
69
|
+
prompt: <constructed review prompt from step 3>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Wait for the subagent to complete. The subagent will output a structured verdict block (`EXTERNAL_COMMS_VOICE_TONE_VERDICT: PASS|FAIL` + `EXTERNAL_COMMS_VOICE_TONE_KEY: <sha>` + optional `EXTERNAL_COMMS_VOICE_TONE_REASON: ...`). The `PostToolUse:Agent` hook (`external-comms-mark-reviewed.sh`) reads that output and writes the per-evaluator marker automatically.
|
|
73
|
+
|
|
74
|
+
**Do not write to `${TMPDIR:-/tmp}/claude-risk-*` yourself.** The hook is the only correct mechanism.
|
|
75
|
+
|
|
76
|
+
### 5. Present results
|
|
77
|
+
|
|
78
|
+
Present the full review report to the user. Highlight:
|
|
79
|
+
|
|
80
|
+
- The verdict (PASS / FAIL).
|
|
81
|
+
- Each `docs/VOICE-AND-TONE.md` section / principle / banned-pattern entry the draft violated (FAIL only).
|
|
82
|
+
- The exact substrings that triggered each finding (FAIL only).
|
|
83
|
+
- Whether the voice-tone 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 past the voice-tone evaluator without re-prompting."
|
|
84
|
+
|
|
85
|
+
When both evaluators are required (voice-tone + risk-scorer both installed), remind the user that the risk-scorer evaluator may still need its own delegation (run `/wr-risk-scorer:assess-external-comms`) before the gate fully permits.
|
|
86
|
+
|
|
87
|
+
### 6. Above-appetite handling (ADR-013 Rule 6)
|
|
88
|
+
|
|
89
|
+
If the verdict is FAIL, do NOT auto-rewrite the draft. Use `AskUserQuestion`:
|
|
90
|
+
|
|
91
|
+
- header: "Voice/tone violation — next step"
|
|
92
|
+
- options:
|
|
93
|
+
1. `Rewrite the draft and re-review` — return to step 1 with the rewritten body.
|
|
94
|
+
2. `Override anyway` — set `BYPASS_RISK_GATE=1` for the next gated tool call. Reserved for cases where the user has confirmed the content is acceptable as-drafted (e.g. an explicitly informal context the guide doesn't cover).
|
|
95
|
+
3. `Cancel` — abandon the post.
|
|
96
|
+
|
|
97
|
+
Do not make the decision unilaterally — per ADR-013 Rule 1, all voice-tone judgement calls outside hard rules belong to the user.
|
|
98
|
+
|
|
99
|
+
$ARGUMENTS
|