SLOPSHOPPER

ato-guard

In the ATO repository only: masks TFNs, ABNs, bank details and Xero/Bearer tokens in tool results, blocks Xero API and SBR/lodgement writes, refuses other mods…

newguardprocess
★ 1v?no licenseupdated 2026-10-06CleanExpo/Pi-Dev-Ops/mods/ato-guard
A shopper browsing a rack in a slop shop
README

ato-guard

Claude Code mod that guards taxpayer data in sessions on the ATO repository. The real repository is CleanExpo/ATO. The mod also treats any repository whose name has ato as a whole word (e.g. ato-ai, ATO-portal) as in scope; names that merely contain the letters (pi-ceo-operator-mcp, SC-Generator, …-Calculator) are not. Everywhere else it does nothing.

Linear: RA-7908 · Mods docs: docs/reference/claude-mods/

What it does (in the ATO repository only)

Mask (hooks/mask.ts)Every tool result is scanned before Claude reads it. TFNs (8–9 digits, or ddd ddd ddd), ABNs (11 digits, or dd ddd ddd ddd), BSB ddd-ddd + account number (6–10 digits) and Bearer … / xero…token=… values become [masked:TFN], [masked:ABN], [masked:BANK], [masked:TOKEN]. Numbers inside longer runs, ISO dates, phone numbers and invoice numbers (INV-000123) are left alone.
Block (hooks/guard.ts)Refused without running: Bash that writes to api.xero.com (`-X/--request POST\PUT\PATCH\DELETE, -d, --data*, --json, -F); any mcp__*xero* tool whose last name segment does not start with get, list, search or read; Bash or MCP calls that mention SBR or lodgement together with a write verb (post, put, patch, submit, send, create, delete, lodge). Reads pass through untouched. Bash segments run by local text tools (git, grep, cat, ls, sed`, …) are not checked, so commits and searches that mention lodgement still work.
Audit (hooks/audit.ts)One JSON line per masked or denied call in ~/.ato-guard/audit.jsonl: { at, session, tool, decision, counts }, where decision is masked:<n> or denied. Never the tool's input, output or matched text. Past 3 MiB the file moves to audit-<date>.jsonl. A failed audit write never affects the tool call.

No network calls, no credentials. One optional setting: ATO_GUARD_ALLOW_MODS (below).

Refuse tool-rewriting mods (in the ATO repository only)

hooks/policy.ts, hooked on plugin.register (fires once for each hooks module about to load; e.uses.events lists what it hooks; a hook returns { refuse: reason }). In the ATO repository, ato-guard refuses every other non-built-in mod — any tier: user, prepend or append — whose events include any of:

Event(s)Why
tool.call, tool.check, tool.describecan rewrite, answer, approve or re-describe tool calls, so could undo the masking or the write blocks
prompt.* (prompt.submit, prompt.section, prompt.context, …)can rewrite what Claude reads
classic.PreToolUse, classic.PostToolUsesettings-hook events on tool calls
engine.createcan change the mods API other mods receive

Wildcards are expanded: *, tool.*, classic.*, engine.* and prompt.* all count. Everything else (session.*, agent.spawn, ui.render, turn.*, …) loads. Built-in mods always load. The user's debug log names the refusal as <mod>: refused by ato-guard: in the ATO repository ….

mc-lane. mc-lane hooks tool.call (to read tool names and timings), so the rule refuses it — on purpose — unless it is on the allow list. ATO_GUARD_ALLOW_MODS is a comma list of plugin names or <name>@<marketplace> ids; unset, it is mc-lane. Set it to an empty string to refuse mc-lane too. A refusal is only { refuse: reason }, so the docs offer no allow list of their own; this one is ato-guard's, and it exempts a mod from the tool.call rule only: an allowed mod that also hooks tool.check, prompt.*, *, … is still refused. plugin.register gives a mod's name and id as the mod itself states them, so the allow list trusts the name; put ATO_GUARD_ALLOW_MODS in managed env (below) so a user settings file cannot widen it.

Fails closed in scope. If the check throws or times out while the session is known to be in the ATO repository, the mod being checked is refused (.catch handler). Outside it, or when the repository cannot be read, the check passes everything.

What plugin.register can see — read before deploying

The docs and the types Claude Code 2.1.289 writes say a module's judges are "the plugins admitted before it and the binary's". A hook on plugin.register therefore sees only the mods that load after it; a mod already admitted is never re-judged. The order is (events → "The order mods run in"): built-in guard and managed prependPlugins, then other organization mods, then mods users install, then appendPlugins, then other built-in mods.

So ato-guard installed from the pi-dev-ops-mods marketplace — a GitHub source, which the docs count as a user's mod even when managed enabledPlugins turns it on — is best effort only: it judges just the user mods that happen to load after it, and prependPlugins skips it. To make the rule airtight ato-guard must count as the organization's and be first in managed prependPlugins.

Deploy so it runs first and cannot be bypassed (managed settings — not applied anywhere yet)

The docs' conditions for an organization's mod: managed enabledPlugins sets it true; managed settings name its marketplace as a directory on the machine by absolute path (extraKnownMarketplaces); the marketplace lists it by relative path so it loads in place. Device management copies a marketplace directory, writable only by an administrator, to every machine, e.g. /Library/Application Support/ClaudeCode/unite-group-managed/:

unite-group-managed/
├── .claude-plugin/marketplace.json   # "name": "unite-group-managed", plugins: ato-guard, mc-lane → ./plugins/<name>
└── plugins/
    ├── ato-guard/                    # copy of mods/ato-guard
    └── mc-lane/                      # copy of mods/mc-lane

Then /Library/Application Support/ClaudeCode/managed-settings.json (macOS):

{
  "extraKnownMarketplaces": {
    "unite-group-managed": {
      "source": { "source": "directory", "path": "/Library/Application Support/ClaudeCode/unite-group-managed" }
    }
  },
  "enabledPlugins": {
    "ato-guard@unite-group-managed": true,
    "mc-lane@unite-group-managed": true
  },
  "prependPlugins": ["ato-guard@unite-group-managed", "sec-default@builtin"],
  "pluginConfigs": {
    "cc-plugin-sec-default@builtin": {
      "options": { "allowManagedModsOnly": true }
    }
  },
  "disableSideloadFlags": true,
  "env": { "ATO_GUARD_ALLOW_MODS": "mc-lane" }
}

What each key does (per docs/reference/claude-mods/admin.md):

  • extraKnownMarketplaces + enabledPlugins: make ato-guard (and mc-lane) the organization's mods.
  • prependPlugins: ato-guard first, the built-in guard second. Setting this list replaces the default, so sec-default@builtin must be named or the built-in guard does not load. Repository settings can never set it.
  • pluginConfigs → allowManagedModsOnly (only under the id cc-plugin-sec-default@builtin): the built-in guard refuses every mod that is not the organization's or built in. Note this also refuses the GitHub-installed copies of mc-lane, fleet-guard and synthex-main-status on that machine; ship any you still want through the managed directory as well.
  • disableSideloadFlags: rejects --plugin-dir / --plugin-url, so a mod cannot be side-loaded.

Check on a test machine with claude --debug: the debug log line hooks module ato-guard@unite-group-managed loaded must say tier prepend. tier user plus prependPlugins names … which is not an enabled managed plugin with a hooks module; skipped means the directory conditions are not met.

Residual gaps the docs state: claude --safe-mode runs with no installed mods (ato-guard's masking included), and if the hooks worker crashes three times every non-built-in mod is off for that session. Neither lets another mod run while ato-guard is off.

Check and test

claude plugin validate mods/ato-guard
claude plugin test mods/ato-guard

Install

From the pi-dev-ops-mods marketplace in this repository (see mods/mc-lane/README.md for adding the marketplace and turning on auto-update):

claude plugin install ato-guard@pi-dev-ops-mods

It is safe to install on every machine: outside the ATO repository it is inert.

Try it in one session

cd ~/path/to/ATO && claude --plugin-dir /path/to/Pi-Dev-Ops/mods/ato-guard
Source 5 files
hooks/register.ts 131 lines
1// ato-guard — taxpayer-data guard for sessions in the ATO repository.
2//
3// Inert everywhere else: it acts only when $.session.repo()'s remote names the
4// ATO repository (CleanExpo/ATO, or a name with "ato" as a whole word, such as
5// ato-ai). In scope it does three things on every tool call:
6//   1. Mask: lets the tool run, then masks TFNs, ABNs, BSB + account numbers and
7//      Bearer/Xero tokens in its result before the model reads it (hooks/mask.ts).
8//   2. Block: refuses Xero API writes and SBR/lodgement writes without running
9//      them (hooks/guard.ts). Reads pass through untouched.
10//   3. Audit: appends one line per masked or denied call to
11//      ~/.ato-guard/audit.jsonl — names and counts only (hooks/audit.ts).
12// No network, no credentials. An audit failure never breaks the tool call.
13//
14// It also judges other mods as they load (`plugin.register`, hooks/policy.ts):
15// in the ATO repository a non-built-in mod that hooks tool.call, tool.check,
16// tool.describe, prompt.*, classic.PreToolUse/PostToolUse or engine.create is
17// refused, except `tool.call` alone for mods named in ATO_GUARD_ALLOW_MODS
18// (default mc-lane). A hook sees only the mods admitted after it, so this is
19// only airtight when ato-guard is first in managed `prependPlugins` (README).
20//
21// Functions that take `$` are top-level declarations: the engine scans the
22// module before loading it and refuses `$` handed to anything else.
23
24import type { EngineInterface, Register } from 'claude-code'
25import { AUDIT_DIR, AUDIT_FILE, auditLine, needsRoll, rollName } from './audit'
26import { decide, isAtoRemote } from './guard'
27import { maskValue, total, type Counts } from './mask'
28import { parseAllow, refuseReason } from './policy'
29
30// Module state. A reload starts it over, and scope is looked up again.
31const st = {
32  active: undefined as boolean | undefined,
33  session: 'unknown',
34}
35
36async function inScope($: EngineInterface): Promise<boolean> {
37  if (st.active !== undefined) return st.active
38  let remote: string | null | undefined
39  try {
40    remote = (await $.session.repo())?.remote
41  } catch {
42    return false // not cached: the next call asks again
43  }
44  st.active = isAtoRemote(remote)
45  if (st.active) {
46    try {
47      st.session = await $.session.id()
48    } catch {
49      st.session = 'unknown'
50    }
51  }
52  return st.active
53}
54
55// Writes `text`, creating the audit directory the first time.
56async function writeFile($: EngineInterface, dir: string, path: string, text: string): Promise<void> {
57  try {
58    await $.fs.write(path, text)
59  } catch {
60    await $.process.run(['mkdir', '-p', dir])
61    await $.fs.write(path, text)
62  }
63}
64
65// read → append → write; past ROLL_BYTES the old file moves to audit-<date>.jsonl.
66async function audit($: EngineInterface, tool: string, decision: string, counts: Counts): Promise<void> {
67  try {
68    const home = await $.env.get('HOME')
69    if (!home) return
70    const dir = `${home.replace(/\/+$/, '')}/${AUDIT_DIR}`
71    const path = `${dir}/${AUDIT_FILE}`
72    const now = await $.clock.now()
73    const line = auditLine({ at: new Date(now).toISOString(), session: st.session, tool, decision, counts })
74    let current = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
75    if (needsRoll(current, line)) {
76      let rolled = `${dir}/${rollName(now, false)}`
77      if (await $.fs.exists(rolled)) rolled = `${dir}/${rollName(now, true)}`
78      await $.fs.write(rolled, current)
79      current = ''
80    }
81    await writeFile($, dir, path, current + line)
82  } catch {
83    // The audit is best-effort; the tool call it describes goes on regardless.
84  }
85}
86
87// plugin.register: refuse a mod that could rewrite what Claude runs or reads.
88async function judgeMod(
89  $: EngineInterface,
90  e: { name: string; provenance: string; tier: string; uses: { events: readonly string[] } },
91): Promise<string | null> {
92  if (e.tier === 'builtin' || !(await inScope($))) return null
93  const allow = parseAllow(await $.env.get('ATO_GUARD_ALLOW_MODS'))
94  return refuseReason({ name: e.name, provenance: e.provenance, tier: e.tier, events: e.uses.events }, allow)
95}
96
97export const register: Register = on => {
98  on('plugin.register', async ($, e, next) => {
99    const reason = await judgeMod($, e)
100    return reason === null ? next(e) : { refuse: reason }
101  }).catch(async ($, e, next) => {
102    // Fail closed, but only where the guard is known to be in scope.
103    if (e.tier === 'builtin' || st.active !== true) return next(e)
104    return { refuse: 'the ATO repository mod check failed, so this mod was not loaded' }
105  })
106
107  on('session.start', async ($, e, next) => {
108    st.active = undefined
109    return next(e)
110  })
111
112  on('tool.call', async ($, e, next) => {
113    if (!(await inScope($))) return next(e)
114    const tool = typeof e.tool === 'string' ? e.tool : ''
115    const reason = decide(tool, e as unknown as Record<string, unknown>)
116    if (reason !== null) {
117      await audit($, tool, 'denied', {})
118      return { deny: `ato-guard: ${reason}` }
119    }
120    const ran = await next(e)
121    // A refused call never ran, so there is nothing to mask.
122    if (ran.deny !== undefined || ran.result === undefined) return ran
123    const counts: Counts = {}
124    const result = maskValue(ran.result, counts)
125    const n = total(counts)
126    if (n === 0) return ran
127    await audit($, tool, `masked:${n}`, counts)
128    return { ...ran, result } as typeof ran
129  })
130}
131
hooks/audit.ts 65 lines
1// Pure audit-line helpers for ato-guard: no `$`, so they unit-test without the kit.
2//
3// WHAT IS WRITTEN. One JSON line per guarded decision: when, which session,
4// which tool, the decision (`masked:<n>` or `denied`) and how many of each kind
5// were masked. Never the tool's input, its output or the matched text — the
6// audit file must be safe to keep, copy and show.
7
8import type { Counts, Kind } from './mask'
9
10/** `$.fs.read`/`$.fs.write` refuse files over 4 MiB, so roll well before that. */
11export const ROLL_BYTES = 3 * 1024 * 1024
12
13export const AUDIT_DIR = '.ato-guard'
14export const AUDIT_FILE = 'audit.jsonl'
15
16export type AuditEntry = {
17  at: string
18  session: string
19  tool: string
20  decision: string
21  counts: Counts
22}
23
24const TOOL_NAME = /^[A-Za-z0-9_.:-]{1,128}$/
25const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
26const KINDS: readonly Kind[] = ['TFN', 'ABN', 'BANK', 'TOKEN']
27
28/** A tool's name as the audit may hold it; anything that is not a name becomes 'unknown'. */
29export function toolName(raw: unknown): string {
30  return typeof raw === 'string' && TOOL_NAME.test(raw) ? raw : 'unknown'
31}
32
33/** The line to append, newline included. Only known fields, and only whole numbers in counts. */
34export function auditLine(entry: AuditEntry): string {
35  const counts: Counts = {}
36  for (const k of KINDS) {
37    const n = entry.counts[k]
38    if (typeof n === 'number' && Number.isInteger(n) && n > 0) counts[k] = n
39  }
40  return JSON.stringify({
41    at: entry.at,
42    session: SESSION_ID.test(entry.session) ? entry.session : 'unknown',
43    tool: toolName(entry.tool),
44    decision: /^(?:denied|masked:\d+)$/.test(entry.decision) ? entry.decision : 'unknown',
45    counts,
46  }) + '\n'
47}
48
49/** UTF-8 size of a string, which is what the 4 MiB limit counts. */
50export function byteLength(text: string): number {
51  return new TextEncoder().encode(text).length
52}
53
54/** True when appending `line` would take the file past ROLL_BYTES. */
55export function needsRoll(current: string, line: string): boolean {
56  return current.length > 0 && byteLength(current) + byteLength(line) > ROLL_BYTES
57}
58
59/** `audit-2026-10-06.jsonl`, or with the time too when that day's file already exists. */
60export function rollName(nowMs: number, withTime: boolean): string {
61  const iso = new Date(nowMs).toISOString()
62  const stamp = withTime ? iso.slice(0, 19).replace(/:/g, '-') : iso.slice(0, 10)
63  return `audit-${stamp}.jsonl`
64}
65
hooks/guard.ts 102 lines
1// Pure deny rules and scope for ato-guard: no `$`, so they unit-test without the kit.
2//
3// decide() returns a reason to refuse a tool call, or null to let it run. It
4// refuses writes only; every read passes untouched. Rules:
5//   xero-http   Bash that sends a write to api.xero.com (-X/--request POST|PUT|
6//               PATCH|DELETE, or a body via -d/--data*/--json/-F/--form)
7//   xero-mcp    an mcp__*xero* tool whose final name segment does not start
8//               with get|list|search|read
9//   lodgement   Bash, or an MCP tool name / target field, that mentions SBR or
10//               lodging together with a write verb (post, put, patch, submit,
11//               send, create, delete, or "lodge" itself)
12// Matching is case-insensitive, except curl's short flags (`-d` writes a body,
13// `-D` dumps headers; `-F` is a form, `-f` is fail-fast).
14//
15// Bash is checked one command segment at a time (split on ; && || | & and
16// newlines). A segment whose program only reads or edits local text (git, grep,
17// cat, …) is skipped, so `git commit -m "fix lodgement submit"` or
18// `grep -rn "api.xero.com" -d skip` in the ATO repository still run.
19
20const READ_PREFIX = /^(?:get|list|search|read)/i
21const XERO_HOST = /api\.xero\.com/i
22const WRITE_METHOD = /(?:^|\s)(?:-X|--request|--method)(?:\s+|=)?['"]?(?:POST|PUT|PATCH|DELETE)\b/i
23const HTTPIE_WRITE = /(?:^|\s)(?:http|https|xh)\s+(?:POST|PUT|PATCH|DELETE)\b/i
24const DATA_FLAG = /(?:^|\s)(?:-d|--data(?:-raw|-binary|-urlencode|-ascii)?|--json|-F|--form|--post-data|--post-file)(?=$|[\s='"@{])/
25const MENTION = /(?<![a-z])(?:sbr2?|lodge|lodges|lodged|lodging|lodgements?|lodgments?)(?![a-z])/i
26const WRITE_VERB = /(?<![a-z])(?:post|put|patch|submit|send|create|delete|lodge)(?![a-z])/i
27const LOCAL_PROGRAMS = new Set([
28  'git', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'cat', 'bat', 'less', 'more', 'head', 'tail',
29  'ls', 'tree', 'wc', 'echo', 'printf', 'cd', 'pwd', 'diff', 'sed',
30])
31const TARGET_FIELDS = ['url', 'endpoint', 'method', 'action', 'operation', 'path', 'uri']
32
33/** "submitLodgement" → "submit Lodgement", so word boundaries see camelCase. */
34function words(text: string): string {
35  return text.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
36}
37
38function program(segment: string): string {
39  const tokens = segment.trim().split(/\s+/).filter(t => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(t))
40  const first = tokens[0] === 'sudo' || tokens[0] === 'env' ? tokens[1] : tokens[0]
41  return (first ?? '').replace(/^.*\//, '').replace(/^['"(]+/, '')
42}
43
44function writes(text: string): boolean {
45  return WRITE_METHOD.test(text) || HTTPIE_WRITE.test(text) || DATA_FLAG.test(text)
46}
47
48function bashReason(command: string): string | null {
49  for (const segment of command.split(/\|\||&&|[;|\n]|&(?=\s|$)/)) {
50    // A command substitution can run anything, so it never counts as local.
51    if (LOCAL_PROGRAMS.has(program(segment)) && !/\$\(|`/.test(segment)) continue
52    if (XERO_HOST.test(segment) && writes(segment)) {
53      return 'writes to the Xero API are blocked in the ATO repository. Read-only (GET) calls are allowed; ask the user to make this change in Xero themselves.'
54    }
55    const w = words(segment)
56    if (MENTION.test(w) && (WRITE_VERB.test(w) || writes(segment))) {
57      return 'SBR / ATO lodgement writes are blocked in the ATO repository. Nothing may be lodged or submitted from a Claude Code session; ask the user to lodge it themselves.'
58    }
59  }
60  return null
61}
62
63function mcpReason(tool: string, input: Record<string, unknown>): string | null {
64  const final = tool.split('__').pop() ?? ''
65  if (/xero/i.test(tool) && !READ_PREFIX.test(final)) {
66    return `${tool} is not a read (get/list/search/read), and Xero writes are blocked in the ATO repository. Ask the user to make this change in Xero themselves.`
67  }
68  const name = words(tool)
69  const nameHit = MENTION.test(name) && WRITE_VERB.test(name) && !READ_PREFIX.test(final)
70  const fields = TARGET_FIELDS.map(k => input[k]).filter((v): v is string => typeof v === 'string').join(' ')
71  const target = words(fields)
72  const fieldHit = MENTION.test(target) && (WRITE_VERB.test(target) || writes(fields))
73  if (nameHit || fieldHit) {
74    return 'SBR / ATO lodgement writes are blocked in the ATO repository. Nothing may be lodged or submitted from a Claude Code session; ask the user to lodge it themselves.'
75  }
76  return null
77}
78
79/** Why this call must not run, or null. `input` is the tool call event (arguments are its fields). */
80export function decide(tool: string, input: Record<string, unknown>): string | null {
81  if (tool === 'Bash') return typeof input.command === 'string' ? bashReason(input.command) : null
82  if (tool.startsWith('mcp__')) return mcpReason(tool, input)
83  return null
84}
85
86/** The repository name from a git remote URL (`ATO` from …/CleanExpo/ATO.git), or undefined. */
87export function repoName(remote: string | null | undefined): string | undefined {
88  if (!remote) return undefined
89  const m = /[:/]([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(remote.trim())
90  return m ? m[1] : undefined
91}
92
93/**
94 * True for the ATO repository (CleanExpo/ATO) and its namesakes (ato-ai, ATO-…).
95 * "ato" must be a whole word of the name, so Calculator, operator and Generator
96 * repositories are not in scope.
97 */
98export function isAtoRemote(remote: string | null | undefined): boolean {
99  const name = repoName(remote)
100  return name !== undefined && /(?:^|[^a-z0-9])ato(?:[^a-z0-9]|$)/i.test(name)
101}
102
hooks/mask.ts 67 lines
1// Pure masking for ato-guard: no `$`, so it unit-tests without the kit.
2//
3// Every string a tool returns is passed through MASKS, in order, before the
4// model reads it. Order matters: tokens and bank details first (they contain
5// digit runs of their own), then ABN (11 digits) before TFN (8–9), so an ABN is
6// never half-masked as a TFN.
7//
8// Boundaries. A number only matches when it is not part of a longer run: no
9// letter, digit, `_`, `.`, `-` or `/` directly before it, and no letter or digit
10// (or `.`/`-`/`/` + digit) directly after it. That keeps invoice numbers
11// (INV-00012345), hashes, versions, ISO dates and phone numbers out. Grouped
12// forms also refuse a neighbouring `<digit><space>`, so `0412 345 678` and
13// `+61 2 9876 5432` are left alone.
14
15export type Kind = 'TFN' | 'ABN' | 'BANK' | 'TOKEN'
16export type Counts = Partial<Record<Kind, number>>
17
18const B = String.raw`(?<![\w./-])(?<!\d )`
19const E = String.raw`(?![\w])(?![./-]\d)(?! \d)`
20
21export const MASKS: ReadonlyArray<readonly [Kind, RegExp]> = [
22  // `Bearer <token>`: 16+ token characters, so prose like "Bearer tokens" stays.
23  ['TOKEN', /\bBearer\s+[\w\-.~+/]{16,}=*/gi],
24  // XERO_ACCESS_TOKEN=…, MY_XERO_TOKEN=…, xero_refresh_token: "…", "xeroToken": "…"
25  ['TOKEN', /xero[_-]?[a-z_]*token[a-z_]*["']?\s*[=:]\s*["']?[^\s"'&,;]+/gi],
26  // BSB ddd-ddd, then the account number (6–10 digits), optionally labelled.
27  ['BANK', new RegExp(
28    B + String.raw`\d{3}-\d{3}[\s,:/-]+(?:(?:acc(?:ount)?|a/c)\.?(?:\s*(?:no\.?|number|#))?[\s:#.-]*)?\d{6,10}` + E,
29    'gi',
30  )],
31  ['ABN', new RegExp(B + String.raw`(?:\d{2} \d{3} \d{3} \d{3}|\d{11})` + E, 'g')],
32  ['TFN', new RegExp(B + String.raw`(?:\d{3} \d{3} \d{3}|\d{8,9})` + E, 'g')],
33]
34
35/** Masks one string, adding what it replaced to `counts`. */
36export function maskText(text: string, counts: Counts): string {
37  let out = text
38  for (const [kind, re] of MASKS) {
39    out = out.replace(re, () => {
40      counts[kind] = (counts[kind] ?? 0) + 1
41      return `[masked:${kind}]`
42    })
43  }
44  return out
45}
46
47const MAX_DEPTH = 32
48
49/** Masks every string inside a tool result: strings, arrays and plain objects. */
50export function maskValue(value: unknown, counts: Counts, depth = 0): unknown {
51  if (typeof value === 'string') return maskText(value, counts)
52  if (depth >= MAX_DEPTH || value === null || typeof value !== 'object') return value
53  if (Array.isArray(value)) return value.map(v => maskValue(v, counts, depth + 1))
54  const proto = Object.getPrototypeOf(value)
55  if (proto !== Object.prototype && proto !== null) return value
56  const out: Record<string, unknown> = {}
57  for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = maskValue(v, counts, depth + 1)
58  return out
59}
60
61/** Total replacements across kinds. */
62export function total(counts: Counts): number {
63  let n = 0
64  for (const v of Object.values(counts)) n += v ?? 0
65  return n
66}
67
hooks/policy.ts 79 lines
1// Pure mod-load policy for ato-guard: no `$`, so it unit-tests without the kit.
2//
3// In the ATO repository, ato-guard refuses (at `plugin.register`) any other
4// non-built-in mod that hooks an event able to rewrite what Claude runs or
5// reads, before that mod loads:
6//   tool.call, tool.check, tool.describe   rewrite, approve or re-describe tool calls
7//   prompt.*                               rewrite prompts, system prompt, context
8//   classic.PreToolUse, classic.PostToolUse  settings-hook events on tool calls
9//   engine.create                          change the mods API other mods receive
10// `e.uses.events` holds the patterns as the mod wrote them (`tool.call`, `*`,
11// `classic.*`, `prompt.*`, `!tool.describe`), so wildcards are expanded here.
12//
13// ATO_GUARD_ALLOW_MODS (comma list of plugin names or `<name>@<marketplace>` ids,
14// default `mc-lane`) exempts a mod from the `tool.call` rule only. mc-lane hooks
15// tool.call to read tool names and timings; an allowed mod that hooks any other
16// listed event is still refused.
17
18const EXACT = ['tool.call', 'tool.check', 'tool.describe', 'classic.PreToolUse', 'classic.PostToolUse', 'engine.create']
19const PREFIX = 'prompt.'
20
21/** What ATO_GUARD_ALLOW_MODS names. Unset → the default; set but empty → nobody. */
22export const DEFAULT_ALLOW = ['mc-lane']
23
24export function parseAllow(raw: string | undefined): string[] {
25  if (raw === undefined) return [...DEFAULT_ALLOW]
26  return raw.split(',').map(s => s.trim()).filter(s => s.length > 0)
27}
28
29function forbidden(event: string): boolean {
30  return EXACT.includes(event) || event.startsWith(PREFIX)
31}
32
33/**
34 * The rewriting events one `on(...)` pattern reaches: itself when it is one,
35 * every listed event for `*`, those under the prefix for `noun.*`. A `!`
36 * exclusion hooks nothing. A matcher written in braces (`tool.call{tool=Bash}`)
37 * still hooks the event.
38 */
39export function rewritingHits(pattern: string): string[] {
40  const p = pattern.replace(/\{.*\}$/, '').trim()
41  if (p === '' || p.startsWith('!')) return []
42  if (p === '*') return [...EXACT, 'prompt.*']
43  if (p.endsWith('.*')) {
44    const stem = p.slice(0, -1) // keeps the dot: 'classic.'
45    if (PREFIX.startsWith(stem) || stem.startsWith(PREFIX)) return [p]
46    return EXACT.filter(ev => ev.startsWith(stem))
47  }
48  return forbidden(p) ? [p] : []
49}
50
51export type ModFacts = {
52  name: string
53  provenance: string
54  tier: string
55  events: readonly string[]
56}
57
58/**
59 * Why this mod must not load in the ATO repository, or null to let it load.
60 * Built-in mods always load. An allowed mod may still hook `tool.call` (and
61 * nothing else on the list).
62 */
63export function refuseReason(mod: ModFacts, allow: readonly string[]): string | null {
64  if (mod.tier === 'builtin') return null
65  const allowed = allow.includes(mod.name) || allow.includes(mod.provenance)
66  const hits = new Set<string>()
67  for (const pattern of mod.events) {
68    for (const hit of rewritingHits(pattern)) {
69      if (!(allowed && hit === 'tool.call')) hits.add(hit)
70    }
71  }
72  if (hits.size === 0) return null
73  return (
74    `in the ATO repository mods may not rewrite tool calls, prompts or the mods API; ` +
75    `${mod.name} hooks ${[...hits].join(', ')}. ` +
76    'A mod that only reads tool calls can be allowed by name in ATO_GUARD_ALLOW_MODS (tool.call only).'
77  )
78}
79