SLOPSHOPPER

phi-delegate

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

newpaneguardcommandtoaststatus
v0.2.0MITupdated 2026-10-10brianleach/phi-delegate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · phi-delegate
│ ┃ phi-delegate review ✕ › fix the failing auth test and add an audit log call │ ┃ No delegate runs this session. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /phi-guard │ ⎿ phi-delegate: phi-delegate guardrail: on. Use /phi-guard on or / │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ phi-delegate: PHI shield on · 0 flagged

Draws

Pane · phi-delegate review
No delegate runs this session.
README

phi-delegate

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:

  • The guardrail mod (always on): function hooks inside the session that scan what you type and what tools return, block reads of delegate artifacts and commands that reach known PHI sources, and give the model a delegate tool instead. It reports class names and counts, never the matched text.
  • The covered lane (the 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.
  • The guard hook (fallback): 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).

How isolation works

LayerMechanism
CredentialsDelegate 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.
ConfigDelegate 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.
EndpointANTHROPIC_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.
ModelDefault claude-opus-5. Fable and Mythos class models are refused because they are not offered under ZDR.
OutputThe 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.
ResidueEach 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.
OrchestratorSKILL.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.
ModFunction 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.
SpecsSpecs 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.

The scanner

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.

Requirements

  • Claude Code CLI 2.1.287 or newer for the mod (2.1 or newer for the skill alone)
  • An Anthropic API key from an organization with a BAA and ZDR enabled
  • git, bash, curl; gh for PRs; node for install.sh --with-guard

Install

The 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.

Options

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):

OptionDefaultWhat it does
guardrailautoauto 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_classesfalseAlso scan prompts and tool output for the keyword classes. Off because talking about schemas trips them.
allowlist_fileemptyA file of extended regexes, one per line, applied to matched lines (for example your company email domain).
allowlistnoneThe same, as a list in the plugin's options, added to the file's entries.
phi_sourcessnowsql, psql against a *PROD* variableJavaScript 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_outputtrueReplace flagged tool results with a counts-only notice.
scrub_scopedatadata 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_commandsDB clients, rails c/runner, kubectl exec/logs, aws ecs execute-command/logs, docker exec/logs, heroku run, curl/wget, log CLIsRegexes for data commands.
scrub_toolsMCP tools for Sentry, Datadog, and SQL or warehouse databasesRegexes for tool names.
scrub_files.csv, .tsv, .log, .sql, .dump, .jsonl, .xlsx, .parquet, .hl7, .dcm and similarRegexes for data files read with Read.

Without the plugin

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.

Usage

In any repo, tell Claude Code "this touches PHI, delegate it" (or invoke the phi-delegate skill). Claude will:

  1. run scripts/check-env.sh
  2. write a PHI-free spec to .phi-tasks/<nn>-<slug>.md
  3. call the delegate tool on it (or, without the mod, run scripts/delegate.sh <spec> --pr)
  4. show you the diff stat, scan verdicts, and the clean handoff
  5. leave the decision to you: with the mod, a review pane opens (reopen it with /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.

Status and the session switch

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.

Private input

When the task needs an identifier, type it in a prompt. The mod flags it and asks:

  • Stage as private input: asks which task it is for, writes the prompt to .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.
  • Send anyway (no PHI): for a false positive, such as a format example.
  • Cancel: the prompt is dropped.

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.

Interactive mode

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.

Cleanup

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.

Organization deployment

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
}
  • The marketplace must be a directory listing the plugin by relative path so the plugin is read in place and counts as the organization's. A copy from a GitHub, git, URL, or npm source counts as a user's and is skipped by 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.
  • Confirm with 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.
  • If the worker that runs installed mods crashes three times, Claude Code unloads every non-built-in mod for that session until /reload-plugins or a new session.
  • CLI versions older than 2.1.287 do not load the mod. Mods are on by default from 2.1.286, and 2.1.287 ignores the old early-access CLAUDE_CODE_ENABLE_FUNCTION_HOOKS switch.
  • The mod covers Claude Code only (terminal, desktop, IDE). It does not see claude.ai chats, other tools, or programs the session starts outside its tool calls.
  • Managed mods also load inside the covered delegate. The mod and the guard hook stand down there because 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.

Compliance notes

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.

Development

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

Roadmap

Not built yet, and out of scope for the first version of the mod:

  • A reversible pseudonymization vault, so the model can work with stable placeholders that map back to real values only in the covered lane
  • Display masking of flagged text in the transcript (ui.render)
  • Heartbeat and audit posting to a central monitoring service
  • A local NER or trained detection sidecar beside the regex classes
  • A network backstop proxy

License

MIT

Source 5 files
hooks/register.tsx 521 lines
1import { 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}
521
hooks/guard.ts 105 lines
1// 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}
105
hooks/patterns.generated.ts 3 lines
1// 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"
3
hooks/scan.ts 188 lines
1// 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`
188
types/index.d.ts 16 lines
1export 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