Always-on PHI guardrail for Claude Code plus a covered BAA/ZDR delegate lane

An always-on PHI guardrail for Claude Code, plus a covered lane for the work that has to touch protected health information.
Your everyday Claude Code session runs on a consumer subscription login that is not covered by a BAA. phi-delegate is a Claude Code plugin with three parts:
delegate tool instead. It reports class names and counts, never the matched text.phi-delegate skill and scripts/): anything that could touch PHI runs in a separate, headless claude -p process authenticated with an API key from an Anthropic organization that has a signed BAA and zero data retention (ZDR) enabled. Nothing it reads or writes comes back except a PHI-scanned handoff summary, git diff --stat, and a scan verdict for the committed diff.scripts/guard-hook.sh, a PreToolUse settings hook that blocks reads of delegate artifacts on clients where mods do not run.You never have to log out of your subscription to do PHI work. The mod is new; everything that worked before still works the same way, with or without it (see Without the plugin).
| Layer | Mechanism |
|---|---|
| Credentials | Delegate runs with ANTHROPIC_API_KEY set to PHI_DELEGATE_API_KEY only for that process. Every other credential (OAuth token, auth token, Bedrock/Vertex/Foundry switches, WIF, profiles) is unset. |
| Config | Delegate uses its own CLAUDE_CONFIG_DIR (~/.phi-delegate/claude), so it never sees ~/.claude settings, hooks, or the subscription login. Runs fail if an OAuth login appears there. |
| Endpoint | ANTHROPIC_BASE_URL is forced to https://api.anthropic.com via both the environment and --settings, which outranks the target repo's .claude/settings.json. |
| Egress | --strict-mcp-config disables every MCP server; WebSearch and WebFetch are disallowed; telemetry and error reporting are off. |
| Model | Default claude-opus-5. Fable and Mythos class models are refused because they are not offered under ZDR. |
| Output | The raw transcript goes to a temp file, is checked for permission denials, and is securely deleted when the run ends (opt in to keeping it with PHI_DELEGATE_KEEP_LOG=1, mode 600, humans only). The delegate writes a handoff that is moved out of the tree and scanned by phi-scan.sh: clean handoffs are shown, flagged ones are deleted unread. |
| Residue | Each run gets its own CLAUDE_CONFIG_DIR subdirectory, deleted afterwards, so no history or debug logs survive. collect.sh --merge and --reject delete the handoff, the spec, its private input sidecar, any kept log, and the run records. After a task closes, the only PHI-adjacent thing left is the git branch itself, which is what the human reviews. |
| Orchestrator | SKILL.md forbids reading delegate artifacts. The plugin registers guard-hook.sh as a PreToolUse hook (or install.sh --with-guard adds it to user settings), which mechanically blocks Read/Bash/Grep/Glob calls referencing .phi-worktrees/, handoff copies, *.private.md sidecars, or --full-diff. |
| Mod | Function hooks in the orchestrator session: a tool.call guard (the rules above, plus collect.sh --merge), a PHI-source command deny, prompt interception, output scrubbing for the commands and tools that can reach records, the delegate tool, and a review pane whose buttons are the only way to merge. Every blocking hook fails closed. It stands down inside the delegate, which phi-claude.sh marks with PHI_DELEGATE_SESSION=1. |
| Specs | Specs must be PHI-free. Identifiers go in a private input sidecar the mod writes from a prompt you stage, or in a ## Private input section you fill in your own editor; the orchestrator never reads either back. |
scripts/phi-patterns.tsv is the one pattern source. phi-scan.sh reads it with grep -E, and the mod reads a generated TypeScript copy (scripts/gen-mod-data.sh writes it; bats and CI fail when it drifts). A parity test holds the two to identical per-class counts on every fixture in tests/fixtures/.
The classes are SSN, phone, email, date, and ISO date shapes, street addresses, long digit runs (the identifier classes), and DOB, identifier, patient-name, and clinical keywords (the keyword classes). Output is counts only. It is a tripwire, not a substitute for the delegate following its handoff instructions or for the human reviewing the full diff.
Precision options: --profile diff drops git metadata lines (diff --git, index, file headers, hunk headers, and Author:, Signed-off-by:, Co-Authored-By: at column 0 or the 4-space commit message indent) so author emails stop counting. Only lines outside a hunk are dropped: from an @@ line to the next diff --git or commit header every line is content and scans, even when it looks like metadata. --profile prose is for a human scanning documentation that talks about PHI: it skips the dob-keyword, identifier-keyword, and clinical-keyword classes and scans every other class; delegate.sh and collect.sh never use it, so handoff scans keep the strict default. --only and --skip take class names (comma separated, repeatable) and compose with either profile; --allow <file> reads an allowlist, conventionally .phi-allow, one regex per line, applied to matched lines. The allowlist is never loaded implicitly. The synthetic corpus in tests/fixtures/ measures it: 7 clean fixtures pass, 11 dirty fixtures flagged, and edge- fixtures pin behavior the script and the mod must share (bats tests/phi_scan_fixtures.bats). The scanner runs in the C locale, so results are the same on macOS, Linux, and in the mod; non-ASCII letters are not case-folded. Prose about the scanner itself trips the keyword classes under the default profile; use --profile prose for it.
git, bash, curl; gh for PRs; node for install.sh --with-guardThe plugin is the documented path. This repo is its own marketplace:
git clone https://github.com/brianleach/phi-delegate ~/code/phi-delegate
claude plugin marketplace add ~/code/phi-delegate
claude plugin install phi-delegate@phi-delegate
cd ~/code/phi-delegate && cp .env.example .env # then edit
scripts/check-env.sh
Or, at the prompt of a terminal session:
/plugin install phi-delegate --marketplace brianleach/phi-delegate
Because the marketplace is a local folder listing the plugin at ./, the plugin is read in place: pull the repo and run /reload-plugins.
.env (gitignored, at this repo's root):
PHI_DELEGATE_API_KEY=sk-ant-...
PHI_DELEGATE_ZDR_ATTESTED=1
# PHI_DELEGATE_MODEL=claude-opus-5
# PHI_DELEGATE_CONFIG_DIR=$HOME/.phi-delegate/claude
PHI_DELEGATE_ZDR_ATTESTED=1 is a deliberate manual step: there is no API that proves a key belongs to a ZDR org, so the operator confirms it in the Console and attests. Runs refuse to start without it.
Set with the /config rows (a change there reloads the mod at once), /plugin configure phi-delegate@phi-delegate, or claude plugin install --config key=value (the shell commands take effect in the next session):
| Option | Default | What it does |
|---|---|---|
guardrail | auto | auto turns the mod on, except for a skill-only install.sh setup (a phi-delegate symlink in ~/.claude/skills), where it stays off until you choose on. off turns it off anywhere. The PHI_DELEGATE_GUARDRAIL=on or off environment variable overrides it. The covered delegate always stands down. |
prompt_keyword_classes | false | Also scan prompts and tool output for the keyword classes. Off because talking about schemas trips them. |
allowlist_file | empty | A file of extended regexes, one per line, applied to matched lines (for example your company email domain). |
allowlist | none | The same, as a list in the plugin's options, added to the file's entries. |
phi_sources | snowsql, psql against a *PROD* variable | JavaScript regexes for Bash commands that reach PHI. A repo adds its own in a .phi-sources file at its root, same format. A pattern that does not compile, or a .phi-sources that cannot be read, blocks Bash until it is fixed. |
scrub_tool_output | true | Replace flagged tool results with a counts-only notice. |
scrub_scope | data | data scrubs only output that can carry records: Bash commands matching scrub_commands or phi_sources, Read of files matching scrub_files, tools matching scrub_tools, and background command output. Ordinary work (gh, git, reading repo files, browser tools) is not scrubbed. all scrubs every Bash, Read, Grep, and MCP result. A list entry that does not compile widens the scope to all. |
scrub_commands | DB clients, rails c/runner, kubectl exec/logs, aws ecs execute-command/logs, docker exec/logs, heroku run, curl/wget, log CLIs | Regexes for data commands. |
scrub_tools | MCP tools for Sentry, Datadog, and SQL or warehouse databases | Regexes for tool names. |
scrub_files | .csv, .tsv, .log, .sql, .dump, .jsonl, .xlsx, .parquet, .hl7, .dcm and similar | Regexes for data files read with Read. |
The skill-only install still works and is unchanged:
./install.sh # symlink the skill into ~/.claude/skills
./install.sh --with-guard # also add guard-hook.sh to ~/.claude/settings.json
The symlink makes Claude Code load this folder as a plugin (phi-delegate@skills-dir), but the mod stays off there by default, so pulling this release changes nothing for an existing install: the skill drives delegate.sh and collect.sh through Bash, the guard hook blocks what it always blocked, and you approve merges in the conversation, as before. To try the guardrail on that setup, turn it on:
echo '{"guardrail":"on"}' | claude plugin configure phi-delegate@skills-dir --values-stdin
or set PHI_DELEGATE_GUARDRAIL=on for one session. Do not combine the symlink with a marketplace install: the skill would load twice, and with the symlink present the marketplace copy also defaults to off.
In any repo, tell Claude Code "this touches PHI, delegate it" (or invoke the phi-delegate skill). Claude will:
scripts/check-env.sh.phi-tasks/<nn>-<slug>.mddelegate tool on it (or, without the mod, run scripts/delegate.sh <spec> --pr)/phi-review) with Merge, Reject, and Open PR buttons that run collect.sh. The model cannot press them, and the mod blocks it from running collect.sh --merge itself. Review the full diff on the PR, or with scripts/collect.sh <name> --full-diff in your own terminal, first.The status line under the prompt reads PHI shield on · N flagged while the guardrail is active, and PHI shield off when you turned it off on purpose. A skill-only install that never opted in shows nothing.
/phi-guard shows the state; /phi-guard on and /phi-guard off change it for the rest of the session. Turning it off asks you in a dialog first, so a model that runs the command cannot switch the guardrail off by itself.
The scanner masks GitHub URLs (run and job IDs are long digit runs) and timestamps (a date in 2000 or later with a time other than midnight) before it scans. A date of birth stored as a datetime prints as midnight, and a 19xx date is never masked, so both still count.
When the task needs an identifier, type it in a prompt. The mod flags it and asks:
.phi-tasks/<name>.private.md (mode 600), and sends the model only a note that private input is staged for <name>. delegate.sh appends the sidecar to the delegate's copy of the spec as its ## Private input section; merge or reject deletes it.A dismissed question drops the prompt. Messages nobody typed (background task notifications, peers) that match are held back without asking. Without the mod, leave a ## Private input section in the spec and fill it in your own editor.
To watch and approve each step yourself instead, ask for interactive mode. Claude runs scripts/interactive.sh <spec>, which prints a command; you run it in your own terminal and get the same isolated session with permission prompts. A staged sidecar is named to that session as the spec's Private input. The handoff lands at .phi-handoff.md in the repo, unscanned, for you to read (scripts/phi-scan.sh .phi-handoff.md first) and delete.
Nothing PHI-bearing is left behind: the transcript and the delegate's session state are deleted when the run ends, and merge or reject deletes the spec, its sidecar, and the handoff. Secure deletion is best effort (shred or rm -P); on APFS and SSDs full-disk encryption is the real control.
Runs that end abnormally, and interactive sessions whose handoff was never deleted, do leave residue. scripts/cleanup.sh finds it (stray .phi-handoff*.md, .phi-tasks/ and the sidecars in it, .phi-worktrees/, phi/* branches, delegate session state) and lists it by name; --apply deletes it, --all sweeps every repo under ~/code, --branches also drops merged phi/* branches, and --sessions empties the delegate config dir. A branch counts as merged when it is an ancestor of HEAD or when gh reports a merged PR for it, so squash-merged delegate PRs qualify. Unmerged branches, and branches whose PR is open or was closed without merging, are never deleted.
To put the guardrail on every machine, deploy it as an organization mod. Have device management copy this repo to the same absolute path on every machine (writable only by administrators), then add managed settings:
{
"extraKnownMarketplaces": {
"phi-delegate": {
"source": { "source": "directory", "path": "/opt/phi-delegate" }
}
},
"enabledPlugins": { "phi-delegate@phi-delegate": true },
"prependPlugins": ["phi-delegate@phi-delegate", "sec-default@builtin"],
"pluginConfigs": {
"phi-delegate@phi-delegate": {
"options": { "guardrail": "on", "prompt_keyword_classes": false, "allowlist_file": "/opt/phi-delegate-allow" }
}
},
"disableSideloadFlags": true
}
prependPlugins.prependPlugins replaces the default, so name sec-default@builtin to keep the built-in guard."guardrail": "on" keeps the mod on even for a developer who also has the old install.sh symlink, where auto would leave it off.disableSideloadFlags rejects --plugin-dir (and --plugin-url, --agents, --mcp-config) so a session cannot be started around the policy that way.claude --debug: the line for phi-delegate@phi-delegate should say tier prepend.Caveats:
claude --safe-mode starts a session with no installed mods, this one included. Only guard-hook.sh (a settings hook) still runs there./reload-plugins or a new session.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS switch.phi-claude.sh exports PHI_DELEGATE_SESSION=1. That marker is honor system, not a security boundary: a user who sets it in the orchestrator turns the guardrail off.This tool reduces the surface through which PHI can reach an uncovered session; it does not by itself make a workflow HIPAA compliant. You still need the BAA, ZDR enabled on the org, access controls on the databases the delegate reaches, and human review of every change. The mod is a set of heuristics in front of the model, not a sandbox: a pattern it does not know passes.
The guards match what a tool call says, not what it touches. They stop the mistakes that matter in practice (reading a delegate's worktree, globbing the private input folder, a recursive grep that walks into it, running a known PHI source), but the session runs as you, with your file access, so a command spelled in a way the guards do not recognize can still reach those files. The output scrubber is the backstop for identifier-shaped values that come back from data commands and tools (all tools with scrub_scope: all); output from a data path it does not know, and names or free text with no identifier shape, can pass it. Treat the guardrail as protection against accidents, not against a session trying to get around it. Local artifacts the delegate creates on your machine are deleted after each task, but the overwrite is best effort, so FileVault or equivalent disk encryption is still required.
shellcheck scripts/*.sh install.sh tests/helpers.bash
scripts/gen-mod-data.sh # after editing phi-patterns.tsv or fixtures
claude plugin validate .
claude plugin test .
bats tests/
claude --plugin-dir . # try the mod from this checkout
Not built yet, and out of scope for the first version of the mod:
ui.render)MIT
hooks/register.tsx 521 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { PhiDelegateReview } from '../types'
5import { compileSources, guardReason, isSelfRepo, searchReason, taskName } from './guard'
6import { PATTERNS_TSV } from './patterns.generated'
7import { classNames, describe, parseAllow, parsePatterns, scan } from './scan'
8import type { ScanProfile, ScanResult } from './scan'
9
10const PANE = 'phi-review'
11const DELEGATE_TOOL = 'mcp__phi-delegate__delegate'
12const STAGE = 'Stage as private input'
13const SEND = 'Send anyway (no PHI)'
14const CANCEL = 'Cancel'
15const NEW_TASK = 'New task'
16// Origins a person typed; anything else that trips the scan is dropped unasked.
17const TYPED_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
18// Every tool whose result the scrubber may read; scrub_scope narrows it.
19const SCRUB_TOOLS = /^(Bash|BashOutput|TaskOutput|Read|Grep|mcp__.+)$/
20const GUARD_OFF = 'Turn it off'
21const GUARD_KEEP = 'Keep it on'
22// A mod's own tool.call hooks nest, and a failure in an inner one is answered
23// by the outermost one's .catch, which would replay the chain without the
24// failed hook. So every gating catch here denies, whether or not next ran.
25const CHECK_FAILED = 'phi-delegate: blocked, because a PHI check on this call failed. Any output was withheld.'
26const DELEGATE_HINT =
27 'If this data may hold PHI, write a PHI-free spec in .phi-tasks/ and run it with the delegate tool.'
28
29const flagged = atom({ plugin: 'phi-delegate', key: 'flagged' } as const, 0)
30const reviews = atom({ plugin: 'phi-delegate', key: 'reviews' } as const, [] as PhiDelegateReview[])
31// /phi-guard's choice for this session, over the option and the environment.
32const sessionGuard = atom({ plugin: 'phi-delegate', key: 'sessionGuard' } as const, 'default' as 'default' | 'on' | 'off')
33
34const patterns = parsePatterns(PATTERNS_TSV)
35
36type Config = {
37 guardrail: 'auto' | 'on' | 'off'
38 scanClasses: string[]
39 allowFile: string
40 allowInline: readonly string[]
41 phiSources: readonly string[]
42 scrubOutput: boolean
43 scrubScope: 'data' | 'all'
44 scrubCommands: readonly string[]
45 scrubTools: readonly string[]
46 scrubFiles: readonly string[]
47}
48
49// Set by register and read by the hooks. Module state starts over on every
50// load, an options change included, so each cache is per load.
51let config: Config = {
52 guardrail: 'auto',
53 scanClasses: [],
54 allowFile: '',
55 allowInline: [],
56 phiSources: [],
57 scrubOutput: true,
58 scrubScope: 'data',
59 scrubCommands: [],
60 scrubTools: [],
61 scrubFiles: [],
62}
63let covered: Promise<boolean> | undefined
64let baseState: Promise<GuardState> | undefined
65let scrubRules: { commands: RegExp[]; tools: RegExp[]; files: RegExp[]; invalid: number } | undefined
66let root: Promise<string> | undefined
67let selfRepo: Promise<string | undefined> | undefined
68let allow: Promise<string[]> | undefined
69
70const readConfig = (options: PluginOptions): Config => ({
71 guardrail: options.guardrail === 'on' || options.guardrail === 'off' ? options.guardrail : 'auto',
72 scanClasses: [
73 ...classNames(patterns, 'identifier'),
74 ...(options.prompt_keyword_classes === true ? classNames(patterns, 'keyword') : []),
75 ],
76 allowFile: typeof options.allowlist_file === 'string' ? options.allowlist_file : '',
77 allowInline: Array.isArray(options.allowlist) ? options.allowlist : [],
78 phiSources: Array.isArray(options.phi_sources) ? options.phi_sources : [],
79 scrubOutput: options.scrub_tool_output !== false,
80 scrubScope: options.scrub_scope === 'all' ? 'all' : 'data',
81 scrubCommands: Array.isArray(options.scrub_commands) ? options.scrub_commands : [],
82 scrubTools: Array.isArray(options.scrub_tools) ? options.scrub_tools : [],
83 scrubFiles: Array.isArray(options.scrub_files) ? options.scrub_files : [],
84})
85
86// covered: inside the covered delegate (phi-claude.sh sets the marker, and
87// managed mods load there too), where everything stands down. on: active.
88// off: turned off on purpose (/phi-guard, the option, or the environment),
89// shown on the status line. auto-off: the default for a skill-only install.sh
90// setup, whose symlink in the skills folder is what loads this plugin, so
91// pulling a release never turns the guardrail on for someone who did not ask.
92type GuardState = 'covered' | 'on' | 'off' | 'auto-off'
93
94async function isCovered($: EngineInterface): Promise<boolean> {
95 covered ??= $.env.get('PHI_DELEGATE_SESSION').then(value => value === '1')
96 return covered
97}
98
99// Without /phi-guard: PHI_DELEGATE_GUARDRAIL=on|off, then the option, then
100// auto. Read once per load; an option change reloads the module.
101async function startingState($: EngineInterface): Promise<GuardState> {
102 baseState ??= (async (): Promise<GuardState> => {
103 const forced = await $.env.get('PHI_DELEGATE_GUARDRAIL')
104 if (forced === 'on' || forced === 'off') return forced
105 if (config.guardrail !== 'auto') return config.guardrail
106 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
107 return (await $.fs.exists(`${configDir}/skills/phi-delegate`)) ? 'auto-off' : 'on'
108 })()
109 return baseState
110}
111
112async function guardState($: EngineInterface): Promise<GuardState> {
113 if (await isCovered($)) return 'covered'
114 const chosen = await read($, sessionGuard)
115 return chosen === 'default' ? startingState($) : chosen
116}
117
118async function isOff($: EngineInterface): Promise<boolean> {
119 return (await guardState($)) !== 'on'
120}
121
122async function repoRoot($: EngineInterface): Promise<string> {
123 root ??= (async () => {
124 const cwd = await $.session.cwd()
125 const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
126 return git.exitCode === 0 ? git.stdout.trim() : cwd
127 })()
128 return root
129}
130
131// Whether this repo has delegate state a recursive search would walk into.
132async function holdsQuarantine($: EngineInterface): Promise<boolean> {
133 const repo = await repoRoot($)
134 return (await $.fs.exists(`${repo}/.phi-tasks`)) || (await $.fs.exists(`${repo}/.phi-worktrees`))
135}
136
137async function realPath($: EngineInterface, path: string): Promise<string | undefined> {
138 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
139 return stat?.realPath
140}
141
142async function allowEntries($: EngineInterface): Promise<string[]> {
143 const file = config.allowFile
144 allow ??= (
145 file === ''
146 ? Promise.resolve([])
147 : $.fs.read(file).then(parseAllow, () => {
148 $.ui.log(`phi-delegate: allowlist ${file} could not be read; scanning without it`, { to: 'debug' })
149 return []
150 })
151 ).then(entries => [...entries, ...parseAllow(config.allowInline.join('\n'))])
152 return allow
153}
154
155async function scanText($: EngineInterface, text: string, profile: ScanProfile): Promise<ScanResult> {
156 return scan(patterns, text, { profile, only: config.scanClasses, allow: await allowEntries($) })
157}
158
159async function flag($: EngineInterface): Promise<void> {
160 const n = await update($, flagged, count => count + 1)
161 $.ui.status(`PHI shield on · ${n} flagged`)
162}
163
164async function setReview($: EngineInterface, review: PhiDelegateReview): Promise<void> {
165 await update($, reviews, list => [...list.filter(r => r.name !== review.name), review])
166}
167
168// Anything a script printed is shown whole when clean, else counts only.
169async function screened($: EngineInterface, text: string): Promise<string> {
170 const result = await scanText($, text, 'default')
171 if (result.total === 0) return text
172 await flag($)
173 return `Output withheld: it matched PHI patterns (${describe(result)}). A human can run the same command in their own terminal.`
174}
175
176// The review pane's buttons. A press is the person's own act; the model has
177// no way to raise one, which is what makes Merge the approval gate.
178async function runCollect(
179 $: EngineInterface,
180 review: PhiDelegateReview,
181 mode: '--merge' | '--reject' | '--pr',
182): Promise<void> {
183 await setReview($, { ...review, status: 'running' })
184 const ran = await $.process.run([`${$.plugin.root}/scripts/collect.sh`, review.name, mode], {
185 cwd: await repoRoot($),
186 timeoutMs: 600_000,
187 })
188 const shown = await screened($, `${ran.stdout}${ran.stderr}`)
189 const status = ran.exitCode !== 0 || mode === '--pr' ? 'ready' : mode === '--merge' ? 'merged' : 'rejected'
190 await setReview($, { name: review.name, status, output: `${review.output}\n\n${shown}` })
191 $.ui.toast(`${review.name}: collect.sh ${mode} ${ran.exitCode === 0 ? 'done' : 'failed'}`)
192}
193
194async function stagePrivateInput($: EngineInterface, text: string): Promise<string | undefined> {
195 const repo = await repoRoot($)
196 const dir = `${repo}/.phi-tasks`
197 const specs = (await $.fs.list(dir).catch(() => []))
198 .map(entry => entry.name)
199 .filter(name => name.endsWith('.md') && !name.endsWith('.private.md'))
200 .map(name => name.replace(/\.md$/, ''))
201 .slice(-3)
202 const picked = await $.ui.ask('Which task is this private input for?', {
203 header: 'Task',
204 options: specs.length > 0 ? [...specs, NEW_TASK] : [NEW_TASK, CANCEL],
205 })
206 if (picked === CANCEL) return undefined
207 // A typed name reaches the model, so it must itself be clean, checked as
208 // typed: cleanup would turn an email's @ into a dash and hide it.
209 const isClean = picked !== NEW_TASK && (await scanText($, picked, 'default')).total === 0
210 let name = isClean ? taskName(picked, picked) : ''
211 if (name === '') {
212 name = `staged-${(await $.clock.now()).toString(36)}`
213 }
214 const file = `${dir}/${name}.private.md`
215 // $.fs.write takes no mode, so the script creates the file 600 (its folder
216 // 700, refusing symlinks, excluded from git) before any text lands in it.
217 const prepared = await $.process.run([`${$.plugin.root}/scripts/prepare-sidecar.sh`, repo, name], { cwd: repo })
218 if (prepared.exitCode !== 0) throw new Error('could not prepare the private input sidecar')
219 const existing = await $.fs.read(file).catch(() => '')
220 await $.fs.write(file, existing === '' ? `${text}\n` : `${existing}\n${text}\n`)
221 return name
222}
223
224// Every string, number, and key in a tool's record, for a result with no
225// text: unlike its JSON, a newline inside still separates lines.
226function stringsOf(value: unknown): string[] {
227 if (typeof value === 'string') return [value]
228 if (typeof value === 'number' || typeof value === 'bigint') return [String(value)]
229 if (Array.isArray(value)) return value.flatMap(stringsOf)
230 if (value !== null && typeof value === 'object') {
231 return Object.entries(value).flatMap(([key, inner]) => [key, ...stringsOf(inner)])
232 }
233 return []
234}
235
236// Git's history output carries author metadata the diff profile drops; all
237// other output is scanned whole, so a data line shaped like a trailer still
238// counts. Both must hold: a single git log, show, or diff with no <rev>:<path>
239// (which prints a file's raw contents), and output that opens as history.
240const GIT_HISTORY = /^\s*git\s+(?:-C\s+[^\s:]+\s+)?(?:log|show|diff)\b[^;&|`$<>()\n:]*$/
241const HISTORY_OUTPUT = /^(?:commit [0-9a-f]{7,}|diff --(?:git|cc|combined) )/
242
243// Built once per load. A rule that does not compile widens the scope to
244// every tool rather than narrowing it: the safe direction.
245function scrubScope(): NonNullable<typeof scrubRules> {
246 scrubRules ??= (() => {
247 let invalid = 0
248 const compile = (sources: readonly string[], flags = '') =>
249 sources.flatMap(source => {
250 try {
251 return [new RegExp(source, flags)]
252 } catch {
253 invalid += 1
254 return []
255 }
256 })
257 return {
258 commands: compile([...config.scrubCommands, ...config.phiSources]),
259 tools: compile(config.scrubTools),
260 files: compile(config.scrubFiles, 'i'),
261 invalid,
262 }
263 })()
264 return scrubRules
265}
266
267// What the scrubber reads. "all" is every tool SCRUB_TOOLS names. "data" is
268// what can reach records: Bash commands that match scrub_commands (or a PHI
269// source), Read of files that match scrub_files, tools that match
270// scrub_tools, and background output, whose command is not known.
271function isScrubbed(e: { tool: string; command?: unknown; file_path?: unknown }): boolean {
272 const tool = String(e.tool)
273 if (!config.scrubOutput || tool === DELEGATE_TOOL || !SCRUB_TOOLS.test(tool)) return false
274 const scope = scrubScope()
275 if (config.scrubScope === 'all' || scope.invalid > 0) return true
276 if (tool === 'BashOutput' || tool === 'TaskOutput') return true
277 if (tool === 'Bash') return typeof e.command === 'string' && scope.commands.some(re => re.test(e.command as string))
278 if (tool === 'Read') return typeof e.file_path === 'string' && scope.files.some(re => re.test(e.file_path as string))
279 return scope.tools.some(re => re.test(tool))
280}
281
282// Turns the guardrail on mid-session: the tool and command session.start
283// registers only for an active guardrail, and the status line.
284async function activate($: EngineInterface): Promise<void> {
285 await $.tool.register({
286 name: 'delegate',
287 description:
288 'Run a PHI-free task spec in the covered BAA/zero-data-retention delegate session (scripts/delegate.sh). ' +
289 'Returns only the diff stat, the PHI scan verdicts, and the scanned handoff, then opens a review pane ' +
290 'where the human merges, rejects, or opens a PR. Runs up to 30 minutes. Never merge yourself.',
291 inputSchema: {
292 type: 'object',
293 properties: {
294 spec: { type: 'string', description: 'Path to the spec, conventionally .phi-tasks/<nn>-<slug>.md' },
295 name: { type: 'string', description: 'Worktree and branch name; defaults to the spec file name' },
296 pr: { type: 'boolean', description: 'Push the branch and open a draft PR (needs gh and an origin)' },
297 },
298 required: ['spec'],
299 },
300 })
301 await $.command.register({ name: 'phi-review', description: 'Open the phi-delegate review pane' })
302 $.ui.status(`PHI shield on · ${await read($, flagged)} flagged`)
303}
304
305export const register: Register = (on, options) => {
306 config = readConfig(options)
307 covered = undefined
308 baseState = undefined
309 scrubRules = undefined
310 root = undefined
311 selfRepo = undefined
312 allow = undefined
313
314 on('session.start', async ($, e, next) => {
315 const state = await guardState($)
316 if (state === 'covered') return next(e)
317 await $.command.register({
318 name: 'phi-guard',
319 description: 'Show the phi-delegate guardrail, or turn it on or off for this session',
320 argumentHint: '[on|off]',
321 })
322 if (state === 'on') await activate($)
323 else if (state === 'off') $.ui.status('PHI shield off')
324 return next(e)
325 })
326
327 // Turning it off asks the person, so a model that runs the command cannot
328 // switch the guardrail off by itself; turning it on needs no question.
329 on('command.run', { command: 'phi-guard' }, async ($, e) => {
330 const state = await guardState($)
331 const wanted = e.args.trim()
332 if (wanted === 'on') {
333 if (state === 'on') return { text: 'phi-delegate guardrail: already on.' }
334 await update($, sessionGuard, () => 'on')
335 await activate($)
336 return { text: 'phi-delegate guardrail: on for this session.' }
337 }
338 if (wanted === 'off') {
339 if (state !== 'on') return { text: 'phi-delegate guardrail: already off.' }
340 const answer = await $.ui
341 .ask('Turn the PHI guardrail off for the rest of this session? Prompts, tool output, and commands stop being checked.', {
342 header: 'PHI guard',
343 options: [GUARD_OFF, GUARD_KEEP],
344 })
345 .catch(() => GUARD_KEEP)
346 if (answer !== GUARD_OFF) return { text: 'phi-delegate guardrail: kept on.' }
347 await update($, sessionGuard, () => 'off')
348 $.ui.status('PHI shield off')
349 return { text: 'phi-delegate guardrail: off for this session. /phi-guard on turns it back on.' }
350 }
351 const shown = state === 'on' ? 'on' : state === 'auto-off' ? 'off (skill-only install; set the guardrail option to on)' : 'off'
352 return { text: `phi-delegate guardrail: ${shown}. Use /phi-guard on or /phi-guard off for this session.` }
353 })
354
355 on('command.run', { command: 'phi-review' }, async $ => {
356 await $.ui.open({ id: PANE, title: 'phi-delegate review' })
357 return { text: 'phi-delegate review pane opened.' }
358 })
359
360 // The guard-hook.sh port: quarantined paths, --full-diff, sidecars, the
361 // delegate config dir, and merging, which only the review pane does.
362 on('tool.call', { tool: /^(Read|Edit|Write|Bash|Grep|Glob|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
363 if (await isOff($)) return next(e)
364 const payload = JSON.stringify(e)
365 selfRepo ??= realPath($, $.plugin.root)
366 const cwd = await realPath($, await $.session.cwd())
367 if (isSelfRepo(await selfRepo, cwd, payload, e.tool === 'Bash')) return next(e)
368 const reason =
369 guardReason(payload, e.tool === 'Bash' ? e.command : undefined, String(e.tool)) ??
370 (e.tool === 'Bash' && (await holdsQuarantine($)) ? searchReason(e.command) : undefined)
371 if (reason === undefined) return next(e)
372 await flag($)
373 return {
374 deny: `phi-delegate guard: blocked. ${reason}. Read the scanned handoff with scripts/collect.sh <name> instead, or ask the human to inspect it outside this session.`,
375 }
376 }).catch(() => ({ deny: CHECK_FAILED }))
377
378 // Commands that reach a known PHI source go to the delegate instead.
379 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
380 if (await isOff($)) return next(e)
381 // Only a missing file means no repo patterns; a file that is there but
382 // cannot be read fails the check, and the call is denied.
383 const sourcesFile = `${await repoRoot($)}/.phi-sources`
384 const repoFile = (await $.fs.exists(sourcesFile)) ? await $.fs.read(sourcesFile) : ''
385 const { regexes, invalid } = compileSources([...config.phiSources, ...repoFile.split('\n')])
386 // A pattern that does not compile is protection that silently went
387 // missing, so Bash stays denied until it is fixed.
388 if (invalid > 0) {
389 return {
390 deny: `phi-delegate: ${invalid} PHI source pattern(s) do not compile, so Bash is blocked until they are fixed (the phi_sources option or .phi-sources at the repo root).`,
391 }
392 }
393 if (!regexes.some(re => re.test(e.command))) return next(e)
394 await flag($)
395 return {
396 deny: 'phi-delegate: this command matches a configured PHI source, so it cannot run in this session. Write a PHI-free spec in .phi-tasks/ that says what to query and how, then run it with the delegate tool.',
397 }
398 }).catch(() => ({ deny: CHECK_FAILED }))
399
400 // Last line of defense, for the tools isScrubbed names: a result that
401 // matches is replaced before the model reads it. Bash keeps its record
402 // shape; every other tool's becomes a deny.
403 on('tool.call', { tool: SCRUB_TOOLS }, async ($, e, next) => {
404 const tool = String(e.tool)
405 if (!isScrubbed(e) || (await isOff($))) return next(e)
406 const ran = await next(e)
407 if (ran.deny !== undefined) return ran
408 const text = ran.text ?? stringsOf(ran.result).join('\n')
409 const isHistory = e.tool === 'Bash' && GIT_HISTORY.test(e.command) && HISTORY_OUTPUT.test(text)
410 const profile = isHistory ? 'diff' : 'default'
411 const result = await scanText($, text, profile)
412 if (result.total === 0) return ran
413 await flag($)
414 const notice = `phi-delegate: ${tool} output withheld, it matched PHI patterns (${describe(result)}). ${DELEGATE_HINT}`
415 return tool === 'Bash' ? { result: { stdout: notice, stderr: '', interrupted: false } } : { deny: notice }
416 }).catch(() => ({ deny: CHECK_FAILED }))
417
418 // The delegate tool. delegate.sh can run 30 minutes, past $.process.run's
419 // ten, so it is spawned; interrupting the turn ends the child.
420 on('tool.call', { tool: DELEGATE_TOOL }, async ($, e, next) => {
421 if (await isOff($)) return { deny: 'The delegate tool is not available: the phi-delegate guardrail is off in this session.' }
422 const spec = typeof e.spec === 'string' ? e.spec : ''
423 if (!spec.endsWith('.md') || spec.endsWith('.private.md') || spec.includes('\0')) {
424 return { deny: 'delegate: spec must be the path of a .md task spec, not a .private.md sidecar.' }
425 }
426 const name = taskName(spec, typeof e.name === 'string' && e.name !== '' ? e.name : undefined)
427 const argv = [`${$.plugin.root}/scripts/delegate.sh`, spec, '--name', name, ...(e.pr === true ? ['--pr'] : [])]
428 await setReview($, { name, status: 'running', output: '' })
429 $.ui.status(`PHI shield on · delegate ${name} running`)
430 const child = $.process.spawn({ argv, cwd: await repoRoot($) })
431 let out = ''
432 let step = await child.next()
433 while (step.done !== true) {
434 out += step.value.text
435 step = await child.next()
436 }
437 const shown = await screened($, out)
438 await setReview($, { name, status: step.value.code === 0 ? 'ready' : 'failed', output: shown })
439 $.ui.status(`PHI shield on · ${await read($, flagged)} flagged`)
440 await $.ui.open({ id: PANE, title: 'phi-delegate review' })
441 return {
442 result: `${shown}\n\nThe review pane (/phi-review) is open for the human, with Merge, Reject, and Open PR buttons. Do not merge or reject yourself.`,
443 }
444 }).catch(() => ({ deny: CHECK_FAILED }))
445
446 // The tool's own calls need no Bash classifier: its argv is fixed.
447 on('tool.check', { tool: DELEGATE_TOOL }, async ($, e, next) =>
448 (await isOff($)) ? next(e) : { decision: 'allow' },
449 ).catch(() => ({ decision: 'deny', reason: CHECK_FAILED }))
450
451 // Prompts scan the identifier-shaped classes; keyword classes are opt-in
452 // because schema talk trips them.
453 on('prompt.submit', async ($, e, next) => {
454 if (await isOff($)) return next(e)
455 const result = await scanText($, [e.text, ...(e.context ?? [])].join('\n'), 'default')
456 if (result.total === 0) return next(e)
457 await flag($)
458 const what = describe(result)
459 if (!TYPED_ORIGINS.has(e.origin.kind)) {
460 return { drop: `phi-delegate: a ${e.origin.kind} message was held back because it matched PHI patterns (${what}).` }
461 }
462 // Dismissed, or nobody to ask (a headless run): the prompt does not go.
463 const answer = await $.ui
464 .ask(`This prompt matched PHI patterns (${what}). What should happen to it?`, {
465 header: 'PHI',
466 options: [STAGE, SEND, CANCEL],
467 })
468 .catch(() => undefined)
469 if (answer === undefined) {
470 return { drop: `phi-delegate: the prompt matched PHI patterns (${what}) and got no answer, so it was not sent.` }
471 }
472 if (answer === SEND) return next(e)
473 const name = answer === STAGE ? await stagePrivateInput($, e.text) : undefined
474 if (name === undefined) return { drop: 'phi-delegate: prompt cancelled; it was not sent to the model.' }
475 // The prompt is replaced, not passed on: the model reads only this note.
476 $.ui.toast(`Staged as private input for ${name}; the model was told only the task name.`)
477 return next({
478 ...e,
479 text: `phi-delegate: I staged private input for task ${name} (my prompt was withheld because it matched PHI patterns). Write the PHI-free spec at .phi-tasks/${name}.md and run it with the delegate tool; the delegate receives the private input as the spec's Private input section. Never read .phi-tasks/${name}.private.md.`,
480 context: [],
481 })
482 }).catch(() => ({ drop: 'phi-delegate: the PHI check failed, so the prompt was not sent.' }))
483
484 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
485 const { Box, Text, Button, Code } = $.ui.resolve(e)
486 const list = await read($, reviews)
487 return (
488 <Box flexDirection="column" gap={1}>
489 {list.length === 0 && <Text dimColor>No delegate runs this session.</Text>}
490 {list.map(review => (
491 <Box key={`review-${review.name}`} flexDirection="column">
492 <Text bold>
493 {review.name} · {review.status}
494 </Text>
495 {review.output !== '' && <Code source={review.output.slice(-9_000)} language="text" />}
496 {review.status === 'ready' && (
497 <Text dimColor>
498 Review the full diff on the PR, or in your own terminal: scripts/collect.sh {review.name} --full-diff
499 </Text>
500 )}
501 {(review.status === 'ready' || review.status === 'failed') && (
502 <Box gap={1}>
503 {review.status === 'ready' && (
504 <Button
505 key={`merge-${review.name}`}
506 label="Merge"
507 variant="primary"
508 onPress={() => void runCollect($, review, '--merge')}
509 />
510 )}
511 <Button key={`reject-${review.name}`} label="Reject" onPress={() => void runCollect($, review, '--reject')} />
512 <Button key={`pr-${review.name}`} label="Open PR" onPress={() => void runCollect($, review, '--pr')} />
513 </Box>
514 )}
515 </Box>
516 ))}
517 </Box>
518 )
519 })
520}
521hooks/guard.ts 105 lines1// Pure rules the mod's hooks apply, kept apart from `$` so tests reach them
2// directly. guardReason mirrors scripts/guard-hook.sh, which stays registered
3// as the fallback for clients where mods do not run.
4
5// The calls that may name .phi-tasks/ from Bash: one plain run of a
6// phi-delegate script (a path of plain characters, then plain or quoted
7// arguments), or creating the folder. No globs, redirections, pipes, or
8// chains, since any of those can read a sidecar without naming it.
9const PLAIN_ARG = `(?:[^\\s;&|\`$<>(){}*?[\\]~\\\\'"]+|"[^"$\`\\\\]*"|'[^']*')`
10const SCRIPT = '(?:delegate|interactive|collect|cleanup)\\.sh'
11const SCRIPT_PATH = `(?:(?:[A-Za-z0-9_./~-]*/)?${SCRIPT}|"(?:[^"$\`\\\\]*/)?${SCRIPT}"|'(?:[^']*/)?${SCRIPT}')`
12const TASKS_ALLOWED = [
13 new RegExp(`^\\s*(?:bash\\s+)?${SCRIPT_PATH}(?:\\s+${PLAIN_ARG})*\\s*$`),
14 /^\s*mkdir\s+-p\s+\.phi-tasks\/?\s*$/,
15]
16
17export const guardReason = (
18 payload: string,
19 bashCommand?: string,
20 tool?: string,
21): string | undefined => {
22 if (/\.phi-worktrees/.test(payload)) {
23 return '.phi-worktrees/ holds delegate worktrees and raw transcripts that may contain PHI'
24 }
25 if (/\.phi-handoff|\.phi-task\.md/.test(payload)) {
26 return 'delegate-side task and handoff copies live inside the worktree and may contain PHI'
27 }
28 if (/--full-diff/.test(payload)) {
29 return 'collect.sh --full-diff prints delegate output verbatim and is reserved for the human'
30 }
31 if (/\.private\.md/.test(payload)) {
32 return '*.private.md sidecars hold private input staged for a delegate'
33 }
34 // .phi-task with no s: a bracket glob (.phi-task[s]) still names it.
35 if (/\.phi-task/.test(payload)) {
36 const isPlain = bashCommand !== undefined && TASKS_ALLOWED.some(re => re.test(bashCommand))
37 if (tool === 'Grep' || (bashCommand !== undefined && !isPlain)) {
38 return '.phi-tasks/ holds private input sidecars; from Bash, name it only in a plain run of a phi-delegate script, and write specs with the Write tool'
39 }
40 }
41 if (/\.phi-delegate\/claude/.test(payload)) {
42 return 'the delegate CLAUDE_CONFIG_DIR holds its own session state'
43 }
44 if (bashCommand !== undefined && /collect\.sh\b[^;&|\n]*\s--merge\b/.test(bashCommand)) {
45 return 'merging a delegate branch is the human approval step, done with the Merge button in the review pane (/phi-review)'
46 }
47 return undefined
48}
49
50// guard-hook.sh's developer exemption: calls whose session cwd is inside this
51// plugin's own checkout, or file tool calls that name its resolved path. A
52// Bash command naming the path is not exempt: running the scripts by their
53// full path is how every session calls them. Not a security boundary; it
54// assumes an honest orchestrator.
55export const isSelfRepo = (
56 repo: string | undefined,
57 cwd: string | undefined,
58 payload: string,
59 isBash: boolean,
60): boolean =>
61 repo !== undefined &&
62 ((cwd !== undefined && (cwd === repo || cwd.startsWith(`${repo}/`))) || (!isBash && payload.includes(repo)))
63
64// One regex per line; blank and # lines ignored. A line that does not
65// compile is skipped and reported by count, never by content.
66export const compileSources = (lines: readonly string[]): { regexes: RegExp[]; invalid: number } => {
67 const regexes: RegExp[] = []
68 let invalid = 0
69 for (const line of lines) {
70 if (/^\s*(#|$)/.test(line)) continue
71 try {
72 regexes.push(new RegExp(line))
73 } catch {
74 invalid += 1
75 }
76 }
77 return { regexes, invalid }
78}
79
80// delegate.sh's own name rule, so the pane and the script agree on <name>.
81export const taskName = (spec: string, name?: string): string => {
82 const base = name ?? (spec.split('/').pop() ?? '').replace(/\.md$/, '')
83 return base.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^-+|-+$/g, '')
84}
85
86// A recursive grep walks into .phi-tasks/ and .phi-worktrees/ without
87// naming them (git-excluded paths are not skipped by grep), so in a repo
88// that has them it must exclude both. rg, git grep, and the Grep tool honor
89// the git excludes, unless rg is told not to.
90const RECURSIVE_GREP = /(?:^|[\s;&|(])(?:e|f)?grep\s+(?:[^;&|]*\s)?(?:-[A-Za-z]*[rR][A-Za-z]*|--recursive|--dereference-recursive|-d\s*recurse|--directories=recurse)\b/
91const UNIGNORED_RG = /(?:^|[\s;&|(])rg\s+(?:[^;&|]*\s)?(?:-[A-Za-z]*u[A-Za-z]*|--no-ignore\S*|--hidden)\b/
92const EXCLUDES_BOTH = (command: string) =>
93 /--exclude-dir=["']?\.phi-(?:\*|tasks\b)/.test(command) &&
94 /--exclude-dir=["']?\.phi-(?:\*|worktrees\b)/.test(command)
95
96export const searchReason = (command: string): string | undefined => {
97 if (RECURSIVE_GREP.test(command) && !EXCLUDES_BOTH(command)) {
98 return 'a recursive grep here would read .phi-tasks/ and .phi-worktrees/, which hold private input and delegate output; use rg, git grep, or the Grep tool (they skip git-excluded paths), or add --exclude-dir=\'.phi-*\''
99 }
100 if (UNIGNORED_RG.test(command)) {
101 return 'rg with -u, --no-ignore, or --hidden here would read .phi-tasks/ and .phi-worktrees/; drop that flag'
102 }
103 return undefined
104}
105hooks/patterns.generated.ts 3 lines1// Generated by scripts/gen-mod-data.sh from scripts/phi-patterns.tsv. Do not edit.
2export const PATTERNS_TSV = "# phi-delegate pattern source. Read by scripts/phi-scan.sh (grep -E / sed -E)\n# and by the mod (hooks/scan.ts, via the generated hooks/patterns.generated.ts).\n# Run scripts/gen-mod-data.sh after editing; bats fails on drift.\n#\n# Columns are tab separated, and an empty field is written \"-\":\n# kind class: a scan class, reported in this order\n# mask: rewritten before scanning (infrastructure ids, GitHub\n# URLs, and timestamps, which are not PHI). A timestamp is a\n# date in 2000 or later with a time other than midnight: a\n# date of birth stored as a datetime prints as midnight, and\n# a 19xx date is never masked\n# drop: line dropped under --profile diff (git metadata), only\n# outside a hunk; every line inside a hunk is content\n# hunk: \"start\" and \"end\" mark a hunk for --profile diff: it runs\n# from an @@ (or a merge diff's @@@) line to the next diff\n# or commit header\n# name the class or rule name\n# flags \"i\" for case-insensitive, else \"-\"\n# tags comma list: identifier (value-shaped, scanned in prompts by\n# default), keyword (opt-in for prompts), prose-skip (skipped by\n# --profile prose)\n# regex POSIX extended regex, also valid JavaScript (and awk, for hunk\n# rows); no \"#\" in mask rows; drop rows start with ^\n# repl mask rows only: the replacement, \\1 for the first group\nmask\tarn\t-\t-\tarn:aws:[A-Za-z0-9:/_.-]+\t[ARN]\nmask\tecr\t-\t-\t(^|[^0-9])[0-9]{12}\\.dkr\\.ecr\\.[a-z0-9-]+\\.amazonaws\\.com\t\\1[ECR]\nmask\tgithub-url\t-\t-\thttps?://((www|api|gist)\\.)?github\\.com/[^[:space:])>\"'`]*\t[GITHUB-URL]\nmask\ttimestamp-iso\t-\t-\t20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])[T ]((0?[1-9]|1[0-9]|2[0-3]):[0-5][0-9]|0?0:(0[1-9]|[1-5][0-9]))\t[TIMESTAMP]\nmask\ttimestamp-us\t-\t-\t(0?[1-9]|1[0-2])/(0?[1-9]|[12][0-9]|3[01])/(20[0-9]{2}|[0-9]{2})[ ,T]+((0?[1-9]|1[0-9]|2[0-3]):[0-5][0-9]|0?0:(0[1-9]|[1-5][0-9]))\t[TIMESTAMP]\nhunk\tstart\t-\t-\t^@@@*[ ]\nhunk\tend\t-\t-\t^(diff --(git|cc|combined) |commit [0-9a-f]+( |$))\ndrop\tgit-headers\ti\t-\t^(diff --(git|cc|combined) |index [0-9a-f,]+\\.\\.[0-9a-f]+|--- (a/|/dev/null)|\\+\\+\\+ (b/|/dev/null)|@@@* )\ndrop\tgit-trailers\ti\t-\t^( {4})?(Author|Signed-off-by|Co-Authored-By):\nclass\tssn-shaped\t-\tidentifier\t\\b[0-9]{3}-[0-9]{2}-[0-9]{4}\\b\nclass\tphone-shaped\t-\tidentifier\t(\\(|\\b)[0-9]{3}(\\) ?|[-. ])[0-9]{3}[-. ][0-9]{4}\\b\nclass\temail-address\t-\tidentifier\t[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\nclass\tdate-shaped\t-\tidentifier\t\\b(0?[1-9]|1[0-2])[/-](0?[1-9]|[12][0-9]|3[01])[/-]([0-9]{2}|[0-9]{4})\\b\nclass\tiso-date\t-\tidentifier\t\\b(19|20)[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])\\b\nclass\tdob-keyword\ti\tkeyword,prose-skip\t\\b(dob|date of birth|birth ?date)\\b\nclass\tidentifier-keyword\ti\tkeyword,prose-skip\t\\b(mrn|medical record|patient id|member id|policy number|ssn|social security)\\b\nclass\tpatient-name-keyword\ti\tkeyword\t\\b(patient name|first_name|last_name|full_name)\\s*[:=]\nclass\tclinical-keyword\ti\tkeyword,prose-skip\t\\b(diagnos(is|es)|icd-?10|rx|prescription|dosage)\\b\nclass\tstreet-address\ti\tidentifier\t\\b[0-9]{1,6} [A-Za-z0-9 .]+ (street|st|avenue|ave|road|rd|blvd|lane|ln|drive|dr)\\b\\.?\nclass\tlong-digit-run\t-\tidentifier\t\\b[0-9]{9,}\\b\n"
3hooks/scan.ts 188 lines1// The mod's port of scripts/phi-scan.sh. Both read scripts/phi-patterns.tsv;
2// the parity test holds them to identical per-class counts on every fixture.
3// Like the script, it counts matching lines and never returns their text.
4
5export type ScanProfile = 'default' | 'diff' | 'prose'
6
7export type ScanOptions = {
8 profile?: ScanProfile
9 only?: readonly string[]
10 skip?: readonly string[]
11 // Allowlist entries, one extended regex each; blank and # lines ignored.
12 allow?: readonly string[]
13}
14
15export type ScanCount = { name: string; lines: number }
16
17export type ScanResult = { total: number; counts: ScanCount[] }
18
19type Rule = { name: string; regex: RegExp; tags: string[]; repl: string }
20
21export type Patterns = {
22 classes: Rule[]
23 masks: Rule[]
24 drops: Rule[]
25 hunkStart?: Rule
26 hunkEnd?: Rule
27}
28
29// The script runs grep -E in the C locale, which matches bytes. To agree
30// with it, the mod matches the UTF-8 bytes of text and patterns alike, one
31// byte per character, so ".", ranges, and lengths count what grep counts.
32export const toBytes = (text: string): string => {
33 let out = ''
34 for (const byte of new TextEncoder().encode(text)) out += String.fromCharCode(byte)
35 return out
36}
37
38const POSIX_CLASSES: Record<string, string> = {
39 space: '\\t\\n\\v\\f\\r ',
40 blank: '\\t ',
41 digit: '0-9',
42 alpha: 'A-Za-z',
43 alnum: 'A-Za-z0-9',
44 upper: 'A-Z',
45 lower: 'a-z',
46 xdigit: '0-9A-Fa-f',
47}
48
49// An extended regex as grep reads it in the C locale, spelled for
50// JavaScript: \s and \S as ASCII whitespace; inside a bracket expression a
51// backslash is literal, a leading ] is a member, and the POSIX classes above
52// expand. A construct with no faithful spelling throws.
53export const toGrepRegex = (source: string): string => {
54 let out = ''
55 let i = 0
56 while (i < source.length) {
57 const ch = source[i] ?? ''
58 if (ch === '\\' && i + 1 < source.length) {
59 const after = source[i + 1] ?? ''
60 out += after === 's' ? '[\\t\\n\\v\\f\\r ]' : after === 'S' ? '[^\\t\\n\\v\\f\\r ]' : ch + after
61 i += 2
62 continue
63 }
64 if (ch !== '[') {
65 out += ch
66 i += 1
67 continue
68 }
69 let j = i + 1
70 let body = ''
71 if (source[j] === '^') {
72 body += '^'
73 j += 1
74 }
75 if (source[j] === ']') {
76 body += '\\]'
77 j += 1
78 }
79 for (;;) {
80 const c = source[j]
81 if (c === undefined) throw new Error('unterminated bracket expression')
82 if (c === ']') break
83 if (c === '[' && source[j + 1] === ':') {
84 const close = source.indexOf(':]', j + 2)
85 const expansion = close < 0 ? undefined : POSIX_CLASSES[source.slice(j + 2, close)]
86 if (expansion === undefined) throw new Error('unsupported bracket class')
87 body += expansion
88 j = close + 2
89 continue
90 }
91 if (c === '[' && (source[j + 1] === '.' || source[j + 1] === '=')) throw new Error('unsupported collating element')
92 body += c === '\\' ? '\\\\' : c
93 j += 1
94 }
95 out += `[${body}]`
96 i = j + 1
97 }
98 return toBytes(out)
99}
100
101export const parsePatterns = (tsv: string): Patterns => {
102 const patterns: Patterns = { classes: [], masks: [], drops: [] }
103 for (const line of tsv.split('\n')) {
104 const [kind, name, flags, tags, regex, repl] = line.split('\t')
105 if (name === undefined || regex === undefined) continue
106 // Applied to one line at a time, as sed and grep -E read them. No "m"
107 // flag: in JavaScript it would also anchor ^ after a carriage return.
108 const rule: Rule = {
109 name,
110 regex: new RegExp(toGrepRegex(regex), `g${flags === 'i' ? 'i' : ''}`),
111 tags: tags === '-' || tags === undefined ? [] : tags.split(','),
112 repl: (repl ?? '').replace(/\\(\d)/g, '$$$1'),
113 }
114 if (kind === 'class') patterns.classes.push(rule)
115 else if (kind === 'mask') patterns.masks.push(rule)
116 else if (kind === 'drop') patterns.drops.push(rule)
117 else if (kind === 'hunk' && name === 'start') patterns.hunkStart = rule
118 else if (kind === 'hunk' && name === 'end') patterns.hunkEnd = rule
119 }
120 return patterns
121}
122
123export const classNames = (p: Patterns, tag?: string): string[] =>
124 p.classes.filter(c => tag === undefined || c.tags.includes(tag)).map(c => c.name)
125
126const testLine = (re: RegExp, line: string): boolean => {
127 re.lastIndex = 0
128 return re.test(line)
129}
130
131export const parseAllow = (text: string): string[] =>
132 text.split('\n').filter(line => !/^\s*(#|$)/.test(line))
133
134export const scan = (p: Patterns, text: string, options: ScanOptions = {}): ScanResult => {
135 const profile = options.profile ?? 'default'
136 const skip = new Set(options.skip ?? [])
137 if (profile === 'prose') for (const name of classNames(p, 'prose-skip')) skip.add(name)
138 const only = options.only === undefined ? undefined : new Set(options.only)
139 // An allowlist entry with no faithful spelling is left out: one fewer
140 // exemption can only make the scan stricter than the script's.
141 const allow = (options.allow ?? []).flatMap(entry => {
142 try {
143 return [new RegExp(toGrepRegex(entry))]
144 } catch {
145 return []
146 }
147 })
148
149 // grep counts lines; a trailing newline ends the last line, it adds none.
150 let lines = toBytes(text).split('\n')
151 if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
152 lines = lines.map(line => p.masks.reduce((out, mask) => out.replace(mask.regex, mask.repl), line))
153 if (profile === 'diff') {
154 // As the script does: drop rules reach only lines outside a hunk, which
155 // runs from an @@ line to the next diff --git or commit header.
156 const { hunkStart, hunkEnd } = p
157 if (hunkStart === undefined || hunkEnd === undefined) throw new Error('pattern file has no hunk rows')
158 let inHunk = false
159 lines = lines.filter(line => {
160 if (testLine(hunkEnd.regex, line)) inHunk = false
161 if (testLine(hunkStart.regex, line)) inHunk = true
162 else if (inHunk) return true
163 return !p.drops.some(drop => testLine(drop.regex, line))
164 })
165 }
166
167 const counts: ScanCount[] = []
168 let total = 0
169 for (const rule of p.classes) {
170 if (only !== undefined && !only.has(rule.name)) continue
171 if (skip.has(rule.name)) continue
172 const n = lines.filter(
173 line => testLine(rule.regex, line) && !allow.some(re => re.test(line)),
174 ).length
175 if (n > 0) {
176 counts.push({ name: rule.name, lines: n })
177 total += n
178 }
179 }
180 return { total, counts }
181}
182
183// Class names and line counts on one line, for notices and drop reasons.
184export const describe = (result: ScanResult): string =>
185 result.total === 0
186 ? 'phi-scan: clean'
187 : `${result.counts.map(c => `${c.name} ${c.lines}`).join(', ')}; text withheld`
188types/index.d.ts 16 lines1export type PhiDelegateReview = {
2 name: string
3 status: 'running' | 'ready' | 'merged' | 'rejected' | 'failed'
4 output: string
5}
6
7declare module 'claude-code' {
8 interface PluginState {
9 'phi-delegate': {
10 flagged: number
11 reviews: PhiDelegateReview[]
12 sessionGuard: 'default' | 'on' | 'off'
13 }
14 }
15}
16