SLOPSHOPPER

sasy-guard-mod

SASY policy enforcement for Claude Code as a mod: checks every tool call against the policy from inside Claude Code and shows each decision in the session

newbandguardcommandtoaststatus
★ 3v0.1.0no licenseupdated 2026-10-07sasy-labs/sasy-guard/plugins/sasy-guard-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sasy-guard-mod
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ sasy-guard-mod │ ⏺ Read(src/auth.ts) │ sasy-guard: the SASY daemon did not start; │ ⎿ Read 6 lines │ tool calls will be blocked (unless │ ⏺ Update(src/auth.ts) │ SASY_FAIL_OPEN=true with the hook-auth │ ⎿ 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 › /guard ⎿ sasy-guard-mod: daemon: http://127.0.0.1:51711/healthz gave no HTTP status ⎿ sasy-guard-mod: this session: 0 checked · 0 denied · 0 asked ⎿ sasy-guard-mod: no denials or approval requests yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ sasy-guard-mod: 0 checked · 0 denied · 0 asked
README

sasy-guard-mod

SASY policy enforcement for Claude Code as a mod: plugin code that runs inside Claude Code. It is an alternative to the sasy-guard plugin, which does the same job with settings hooks (separate programs Claude Code starts for each event). Install one or the other: with both, every call is checked twice.

Both talk to the same local sasy-watch daemon and policy engine, installed by sasy-guard install (the sasy-guard PyPI package).

What it does

  • Session start: starts the daemon if it is down (found as the hook plugin's lib.sh finds it: an executable SASY_WATCH_BIN, the installed binary, then a development checkout's build or source), registers the session (/v1/session/start), and tells the model that denials carry a [SASY] reason to relay.
  • Each tool call (classic.PreToolUse): sends the daemon the same request the hook plugin's PreToolUse hook would (/v1/pretooluse) and denies the call, asks the user, or lets it go on to any other settings hooks. A subagent's call carries the subagent's id, type and folder, which the mod learns from agent.spawn.
  • Session history (session.append): every row Claude Code keeps is buffered and sent to the daemon (/v1/session/append) just before the next check, with the folder it ran in and the structured result of the tool call it reports. A daemon without that route (the released one) reads the transcript instead; the mod asks once and stops. A push the daemon refuses, or cannot take even after one sasy-watch ensure, blocks the check and is retried.
  • After each call (classic.PostToolUse): the daemon's post-tool signal (/v1/posttooluse), as the hook plugin sends it.
  • Session end: ends the session at the daemon (/v1/session/end).
  • In the session: a status entry with checked / denied / asked totals, a band above the prompt explaining the latest [SASY] denial or ask (with a Dismiss button), and /guard, which reports the daemon's /healthz and recent decisions without a model turn.

One-time approvals. When the policy offers a one-time bypass (an ask rule, such as piping a download into a shell), a daemon that supports it sends the offer with its denial, and the mod asks you itself: it holds the tool call and shows the policy's question with Approve once and Deny (and, when the policy names a host, **Trust host for this session**). Your answer goes to the daemon (/v1/approval, bound to this call); on approval the mod checks the call again and the daemon lets it run once. Claude only learns the outcome. With a daemon that does not send the offer, the denial stands and Claude asks you through its own question tool, as with the hook plugin. Where nobody can be asked at all (claude -p), the call stays refused.

It fails closed. A call is denied when the daemon cannot be reached after one sasy-watch ensure (unless SASY_FAIL_OPEN=true and the daemon's hook-auth file is in place), when the daemon's answer is not one of its exact answer shapes or authentication is refused, and when the mod cannot name the call's caller: a subagent running in a git worktree of its own (given one, or entering one), a subagent that started before the mod loaded, an agent Claude Code runs itself (such as a preview of a suggested prompt), or any call in a session the mod did not see start. An Agent call asking for a remote (cloud) subagent is refused too: that subagent's calls run where neither the mod nor the local daemon sees them. A subagent whose own definition forces remote isolation cannot be told apart at spawn time, and its calls are not checked (as with the hook plugin).

Requirements and limits

  • Claude Code v2.1.289 or later. The status entry, band and /guard output are drawn in the terminal and the Desktop Code tab; the VS Code chat panel and claude -p draw nothing, but the mod still checks every call there.
  • Where an organization allows only its own mods (allowManagedModsOnly) the mod does not load and nothing is checked: use the sasy-guard plugin there. disableAllHooks turns off mods and settings hooks alike, so neither plugin checks anything under it.
  • The mod talks to the daemon with curl, which must be on PATH.

Develop

claude plugin validate plugins/sasy-guard-mod   # what the mod hooks and calls
claude plugin test plugins/sasy-guard-mod       # tests/*.test.ts
claude --plugin-dir plugins/sasy-guard-mod      # load it from this checkout
Source 7 files
hooks/register.tsx 999 lines
1// sasy-guard-mod: SASY policy enforcement for Claude Code, as a mod.
2//
3// A standalone alternative to the sasy-guard hook plugin (install one or the
4// other). Talking to the same local sasy-watch daemon, it does what that
5// plugin's settings hooks do, from inside Claude Code: at SessionStart it starts
6// the daemon if needed and registers the session; at classic.PreToolUse it
7// asks the daemon about each tool call (enforce.ts builds the request) and
8// denies it, asks the user, or lets it go on to any other settings hooks; at
9// PostToolUse it sends the daemon its post-tool signal; at SessionEnd it ends
10// the session. It fails closed: no answer from the daemon, or a call whose
11// caller it cannot name (agents.ts), is denied.
12//
13// It also draws the decisions: a status line with the session's counts, a band
14// above the prompt for the latest denial or approval request, and a `/guard`
15// command that answers at once, with no model turn.
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, PreToolUseResult, Register } from 'claude-code'
18
19import type { GuardDecision, GuardSessionInfo, GuardVerdict } from '../types'
20import type { AgentTable } from './agents'
21import type { Carried } from './carry'
22import { addSpawn, attribute, markUnattributable, isolatedWorktreeAgent } from './agents'
23import { MAX_DECISIONS, addCounts, joinDecisions } from './carry'
24import type { FeedBuffer, FeedOutcome, FeedRow } from './feed'
25import {
26  HISTORY_UNSENT,
27  MAX_PUSH_ROUNDS,
28  PUSH_DEADLINE_MS,
29  RESULT_WAIT_MS,
30  ResultTable,
31  RunningCalls,
32  afterPush,
33  agentsOf,
34  batches,
35  emptyBuffer,
36  enqueue,
37  isAcknowledged,
38  reportedCalls,
39  rowOf,
40  withResults,
41} from './feed'
42import type { BypassOffer, CheckAnswer, CheckInput, CheckReply, HostHeaders } from './enforce'
43import {
44  MARKER,
45  SESSION_NOTE,
46  choiceLabels,
47  cleanOffer,
48  bandLines,
49  cleanReason,
50  contextOf,
51  guardText,
52  healthLine,
53  shorten,
54  statusText,
55  targetOf,
56  verdictOf,
57} from './text'
58import type { DialogOutcome, DialogRecord } from './text'
59import {
60  CHECK_TIMEOUT_MS,
61  DEFAULT_PORT,
62  ENSURE_TIMEOUT_MS,
63  checkArgv,
64  combine,
65  denyWith,
66  parseAnswer,
67  SPAWN_FAILURE_MS,
68  UNREACHABLE_CURL_EXITS,
69  postArgv,
70  replyOf,
71  splitStatus,
72} from './enforce'
73
74const PLUGIN = 'sasy-guard'
75const COMMAND = 'guard'
76/** The most worktree ids seen before their spawn that the mod remembers. */
77const MAX_EARLY = 200
78const HEALTH_TIMEOUT_MS = 3000
79
80const counts = atom({ plugin: 'sasy-guard-mod', key: 'counts' } as const, {
81  checked: 0,
82  denied: 0,
83  asked: 0,
84})
85const decisions = atom({ plugin: 'sasy-guard-mod', key: 'decisions' } as const, [])
86const dismissedSeq = atom({ plugin: 'sasy-guard-mod', key: 'dismissedSeq' } as const, 0)
87const compactMark = atom({ plugin: 'sasy-guard-mod', key: 'compactMark' } as const, 0)
88const sessionInfo = atom(
89  { plugin: 'sasy-guard-mod', key: 'sessionInfo' } as const,
90  null as GuardSessionInfo | null,
91)
92
93async function sasyHome($: EngineInterface): Promise<string> {
94  return (await $.env.get('SASY_HOME')) || `${await $.env.get('HOME')}/.sasy`
95}
96
97/** The port the daemon listens on, or undefined when the setting is no port. */
98async function daemonPort($: EngineInterface): Promise<string | undefined> {
99  const port = (await $.env.get('SASY_WATCH_PORT')) || DEFAULT_PORT
100  return /^[0-9]{1,5}$/.test(port) ? port : undefined
101}
102
103/** The hook-auth header file a daemon that authenticates hooks writes (lib.sh). */
104async function authHeaderFile($: EngineInterface, port: string): Promise<string | undefined> {
105  const path = `${await sasyHome($)}/hook-auth-${port}.header`
106  try {
107    const st = await $.fs.stat(path)
108    return st.kind === 'file' && !st.isLink ? path : undefined
109  } catch {
110    return undefined
111  }
112}
113
114
115/** Claude Code's entrypoint and terminal program, for the daemon's headers. */
116async function hostHeaders($: EngineInterface): Promise<HostHeaders> {
117  return {
118    entrypoint: (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? '',
119    term: (await $.env.get('TERM_PROGRAM')) ?? '',
120  }
121}
122
123async function postCheck(
124  $: EngineInterface,
125  port: string,
126  input: CheckInput,
127): Promise<CheckReply> {
128  const argv = checkArgv(port, await authHeaderFile($, port), await hostHeaders($))
129  let ran: { exitCode: number; stdout: string }
130  try {
131    ran = await $.process.run(argv, { stdin: JSON.stringify(input), timeoutMs: CHECK_TIMEOUT_MS })
132  } catch {
133    // curl did not run at all: nothing is known about the daemon.
134    return { error: 'could not run curl', kind: 'answer' }
135  }
136  return replyOf(ran, port)
137}
138
139/** Whether `path` is a file (following a link). */
140async function isFile($: EngineInterface, path: string): Promise<boolean> {
141  try {
142    return (await $.fs.stat(path)).kind === 'file'
143  } catch {
144    return false
145  }
146}
147
148/**
149 * The commands that may run sasy-watch, in the order the hook plugin's
150 * `lib.sh` tries them: SASY_WATCH_BIN, the installed binary, a development
151 * checkout's compiled binary, then bun running its source. Only files that
152 * exist are listed.
153 */
154async function watchCommands($: EngineInterface): Promise<string[][]> {
155  const dev = `${$.plugin.root}/../../packages/claude-code`
156  const candidates: [string, string[]][] = [
157    [`${await sasyHome($)}/bin/sasy-watch`, []],
158    [`${dev}/dist/sasy-watch`, []],
159    [`${dev}/src/main.ts`, ['bun']],
160  ]
161  const explicit = await $.env.get('SASY_WATCH_BIN')
162  if (explicit) candidates.unshift([explicit, []])
163  const found: string[][] = []
164  for (const [path, runner] of candidates) {
165    if (await isFile($, path)) found.push([...runner, path])
166  }
167  return found
168}
169
170/**
171 * Starts the daemon if it is down, as the settings hook's lib.sh does: the
172 * first command that can be run at all is the one used, and if it runs and
173 * fails, that failure stands (the retry then fails closed). One that cannot
174 * be started (not executable, or bun missing) is passed over, as lib.sh
175 * passes over a binary that is not executable.
176 */
177async function ensureDaemon($: EngineInterface): Promise<void> {
178  for (const command of await watchCommands($)) {
179    const started = await $.clock.now()
180    try {
181      await $.process.run([...command, 'ensure', '--wait-ms', '6000'], { timeoutMs: ENSURE_TIMEOUT_MS })
182      return
183    } catch {
184      // A run that fails at once could not be started (not executable, or bun
185      // missing): try the next. One that ran until it was killed did start,
186      // and its failure stands.
187      if ((await $.clock.now()) - started >= SPAWN_FAILURE_MS) return
188    }
189  }
190}
191
192/** Whether an unreachable daemon lets calls through: SASY_FAIL_OPEN=true and,
193 *  as in the hook, the daemon's hook-auth file in place. */
194async function failsOpen($: EngineInterface): Promise<boolean> {
195  const port = await daemonPort($)
196  return (
197    port !== undefined &&
198    (await $.env.get('SASY_FAIL_OPEN')) === 'true' &&
199    (await authHeaderFile($, port)) !== undefined
200  )
201}
202
203async function checkCall($: EngineInterface, input: CheckInput): Promise<CheckAnswer> {
204  // Mirrors the hook plugin's pretooluse.sh, which this mod replaces.
205  const port = await daemonPort($)
206  let answer: CheckReply =
207    port === undefined
208      ? { error: 'SASY_WATCH_PORT is not a port number', kind: 'answer' }
209      : await postCheck($, port, input)
210  if ('error' in answer && answer.kind === 'unreachable' && port !== undefined) {
211    await ensureDaemon($)
212    answer = await postCheck($, port, input)
213  }
214  const parsed = 'body' in answer ? parseAnswer(answer.body) : undefined
215  if (parsed !== undefined) return parsed
216  // SASY_FAIL_OPEN covers an unreachable daemon only, and, as in the hook,
217  // only with the daemon's hook-auth file in place: never a refused or missing
218  // authentication, nor an answer that is no decision.
219  const isDown = 'error' in answer && answer.kind === 'unreachable'
220  if (isDown && (await failsOpen($))) return { result: {} }
221  const why = 'error' in answer ? answer.error : 'sasy-watch gave an answer that is not a decision'
222  return { result: { deny: `[SASY] security check unavailable (${why})` } }
223}
224
225/** Whether a person can be asked: some surface draws the session. */
226async function canAsk($: EngineInterface): Promise<boolean> {
227  return (await $.session.surfaces()).length > 0
228}
229
230
231/**
232 * The mod's own approval dialog for a one-time bypass the daemon offered. It
233 * holds the call while it asks, records the answer with the daemon
234 * (/v1/approval, bound to this call's tool_use_id and offer), and on approval
235 * checks the call again: the daemon then allows it once, unless what the
236 * decision rested on changed, in which case the new offer is shown once more.
237 * A dismissed dialog declines. Replaces the model-driven AskUserQuestion round
238 * trip; the model reads only the outcome.
239 */
240async function askForBypass(
241  $: EngineInterface,
242  input: CheckInput,
243  offer: BypassOffer,
244  check: (input: CheckInput) => Promise<CheckAnswer>,
245  attemptsLeft = 1,
246): Promise<DialogOutcome> {
247  return askAbout($, input, cleanOffer(offer), check, attemptsLeft)
248}
249
250async function askAbout(
251  $: EngineInterface,
252  input: CheckInput,
253  offer: BypassOffer,
254  check: (input: CheckInput) => Promise<CheckAnswer>,
255  attemptsLeft: number,
256): Promise<DialogOutcome> {
257  const labels = choiceLabels(offer)
258  const question = offer.question.replace(/\s*\[SASY-ALLOW:[0-9a-f]+\]\s*$/, '')
259  let answer = labels.decline ?? 'Deny'
260  try {
261    answer = await $.ui.ask(question, {
262      header: 'SASY',
263      options: offer.labels.map(label => labels[label] ?? label),
264    })
265  } catch {
266    // Dismissed: the call stays blocked.
267  }
268  const choice =
269    Object.entries(labels).find(([, label]) => label === answer)?.[0] ?? 'decline'
270  const recorded = await postBestEffort(
271    $,
272    '/v1/approval',
273    { session_id: input.session_id, tool_use_id: input.tool_use_id, choice },
274    5,
275  )
276  let isRecorded = false
277  try {
278    isRecorded = recorded !== undefined && (JSON.parse(recorded) as { ok?: unknown }).ok === true
279  } catch {
280    // Not the daemon's answer: nothing was recorded.
281  }
282  const policy = offer.policyReason.replace(MARKER, '').trim()
283  if (choice === 'decline') {
284    return {
285      result: {
286        deny:
287          '[SASY] The user declined a one-time bypass of this check. Follow the ' +
288          `suggested fix instead of retrying the same action.\n\n${policy}`,
289      },
290      record: { verdict: 'declined', reason: `${offer.reason} — you denied it` },
291    }
292  }
293  if (!isRecorded) {
294    // No answer is not proof that nothing changed: the daemon may have applied
295    // the choice before the connection failed, so say both.
296    const maybeTrusted =
297      choice === 'trust-domain'
298        ? [`The SASY daemon may have recorded the user's choice to trust ${offer.domain ?? 'this host'} for this session.`]
299        : []
300    return {
301      result: {
302        deny: `[SASY] The approval could not be confirmed, so the action stays blocked.\n\n${policy}`,
303        ...(maybeTrusted.length === 0 ? {} : { additionalContext: maybeTrusted }),
304      },
305      record: { verdict: 'declined', reason: `${offer.reason} — your choice could not be confirmed (it may still have taken effect)` },
306    }
307  }
308  // What the user chose here, kept in the record whatever follows: a trusted
309  // host stays trusted for the session even if the call is then blocked.
310  const chosen =
311    choice === 'trust-domain'
312      ? `${offer.reason} — you trusted ${offer.domain ?? 'the host'} for this session`
313      : `${offer.reason} — you approved it once`
314  const trustNote =
315    choice === 'trust-domain'
316      ? `The user chose in the SASY dialog to trust ${offer.domain ?? 'this host'} for the rest ` +
317        'of this session.'
318      : undefined
319  // Checked again with the history kept while the dialog was open.
320  const again = await check(input)
321  // The re-check's own verdict (a new ask, or a plain denial on new evidence),
322  // recorded after what the user chose, which the model is also told of.
323  const after = (verdict: GuardVerdict, text: string): DialogOutcome => ({
324    result: trustNote
325      ? { ...again.result, additionalContext: [...(again.result.additionalContext ?? []), trustNote] }
326      : again.result,
327    record: { verdict, reason: `${chosen}; then ${cleanReason(text.slice(Math.max(text.indexOf(MARKER), 0)).replace(MARKER, ''))}` },
328  })
329  // A new approval requirement: Claude Code asks the user, as for any ask.
330  if (again.result.ask !== undefined) return after('ask', again.result.ask)
331  if (again.result.deny !== undefined) {
332    // The decision's grounds changed since the question: ask about the new one.
333    if (again.offer !== undefined && attemptsLeft > 0) {
334      const later = await askForBypass($, input, again.offer, check, attemptsLeft - 1)
335      const context = [...(later.result.additionalContext ?? []), ...(trustNote ? [trustNote] : [])]
336      const laterReason = later.record?.reason ?? `blocked: ${cleanReason(later.result.deny ?? '')}`
337      return {
338        result: context.length === 0 ? later.result : { ...later.result, additionalContext: context },
339        record: { verdict: later.record?.verdict ?? 'declined', reason: `${chosen}; then ${laterReason}` },
340      }
341    }
342    if (again.offer !== undefined) {
343      // It changed again: stop asking, keep the call blocked, and say why.
344      // The newest offer is declined at the daemon, so no later question can
345      // approve it.
346      await postBestEffort(
347        $,
348        '/v1/approval',
349        { session_id: input.session_id, tool_use_id: input.tool_use_id, choice: 'decline' },
350        5,
351      )
352      const changed = cleanOffer(again.offer)
353      const fix = changed.policyReason.replace(MARKER, '').trim()
354      return {
355        result: {
356          deny:
357            '[SASY] The decision changed again after the user approved it, so the action ' +
358            `stays blocked.\n\n${fix}`,
359          ...(trustNote ? { additionalContext: [trustNote] } : {}),
360        },
361        record: {
362          verdict: 'declined',
363          reason: `${chosen}; then ${changed.reason} — changed again after your approval`,
364        },
365      }
366    }
367    // A plain denial now (new evidence): recorded as the denial it is.
368    return after('deny', again.result.deny)
369  }
370  const note = trustNote
371    ? `${trustNote} This action may proceed.`
372    : 'The user approved a one-time bypass of a SASY check for this action in the SASY dialog.'
373  return {
374    result: { ...again.result, additionalContext: [...(again.result.additionalContext ?? []), note] },
375    record: { verdict: 'approved', reason: chosen },
376  }
377}
378
379/**
380 * One line on the sasy-watch daemon, from its /healthz route.
381 *
382 * Read with curl so the request has a hard time and size limit: the port is
383 * plain HTTP on loopback, and anything holding it can answer. The answer is
384 * printed into the transcript, which the model reads, so only values in the
385 * daemon's own shapes are printed.
386 */
387async function daemonHealth($: EngineInterface): Promise<string> {
388  const port = await daemonPort($)
389  if (port === undefined) return `daemon: SASY_WATCH_PORT is not a port number`
390  const url = `http://127.0.0.1:${port}/healthz`
391  // --noproxy: loopback never goes via a proxy. --write-out appends the HTTP
392  // status on a line of its own; the daemon answers /healthz with 200.
393  const argv = [
394    'curl', '-sS', '--noproxy', '*', '--max-time', '2', '--max-filesize', '65536',
395    '--write-out', '\n%{http_code}', url,
396  ]
397  let ran: { exitCode: number; stdout: string }
398  try {
399    ran = await $.process.run(argv, { timeoutMs: HEALTH_TIMEOUT_MS })
400  } catch {
401    return `daemon: could not run curl to reach ${url}`
402  }
403  if (ran.exitCode !== 0) return `daemon: unreachable at ${url} (curl exit ${ran.exitCode})`
404  return healthLine(url, ran.stdout)
405}
406
407
408/** Counts one checked call and keeps it when it carries a [SASY] verdict, or
409 *  when the mod's own dialog asked the user about it. */
410async function record(
411  $: EngineInterface,
412  e: Readonly<Record<string, unknown>>,
413  result: PreToolUseResult,
414  dialog?: DialogRecord,
415): Promise<void> {
416  const found = dialog ?? verdictOf(result)
417  const total = await update($, counts, c => ({
418    checked: c.checked + 1,
419    denied: c.denied + (found?.verdict === 'deny' ? 1 : 0),
420    asked: c.asked + (found?.verdict === 'ask' || dialog !== undefined ? 1 : 0),
421  }))
422  $.ui.status(statusText(total))
423  if (found === null) return
424  const at = await $.clock.now()
425  await update($, decisions, list => {
426    const seq = (list[list.length - 1]?.seq ?? 0) + 1
427    const decision: GuardDecision = { seq, at, tool: String(e.tool), target: targetOf(e), ...found }
428    return [...list, decision].slice(-MAX_DECISIONS)
429  })
430}
431
432/**
433 * record(), never throwing: it runs after `next`, where a failure would hand
434 * the call to the .catch handler, which denies it.
435 */
436async function recordSafely(
437  $: EngineInterface,
438  e: unknown,
439  result: PreToolUseResult,
440  dialog?: DialogRecord,
441): Promise<void> {
442  try {
443    await record($, e as Readonly<Record<string, unknown>>, result, dialog)
444  } catch {
445    // The counts and the band miss one call; the decision stands.
446  }
447}
448
449
450/**
451 * Sends buffered history rows to the daemon (/v1/session/append), in pushes it
452 * takes, in order. `unsupported`: the daemon has no such route (a released
453 * daemon, which reads the transcript instead). `sent` counts the rows that
454 * reached it, so a failed push leaves the rest buffered.
455 */
456async function sendFeed(
457  $: EngineInterface,
458  base: { session_id: string; transcript_path?: string; cwd: string },
459  rows: FeedRow[],
460  agents: Record<string, { toolUseId: string; agentType: string }>,
461  gap: boolean,
462  deadline: number,
463): Promise<{ outcome: FeedOutcome; sent: number }> {
464  const port = await daemonPort($)
465  if (port === undefined) return { outcome: 'failed', sent: 0 }
466  const auth = await authHeaderFile($, port)
467  const host = await hostHeaders($)
468  let sent = 0
469  for (const batch of batches(rows).concat(rows.length === 0 && gap ? [[]] : [])) {
470    // One deadline for all of a check's pushing: each request gets only the
471    // time left, so a slow port holder cannot hold the call for longer.
472    const left = deadline - (await $.clock.now())
473    if (left < 1000) return { outcome: 'failed', sent }
474    const seconds = Math.min(10, Math.floor(left / 1000))
475    const ran = await $.process.run(postArgv(port, auth, '/v1/session/append', seconds, 0, host), {
476      stdin: JSON.stringify({ ...base, rows: batch, agents: agentsOf(batch, agents), gap: gap && sent === 0 }),
477      timeoutMs: seconds * 1000 + 500,
478    })
479    if (ran.exitCode !== 0) {
480      return { outcome: UNREACHABLE_CURL_EXITS.includes(ran.exitCode) ? 'unreachable' : 'failed', sent }
481    }
482    const { body, status } = splitStatus(ran.stdout)
483    if (status === '404') return { outcome: 'unsupported', sent }
484    if (!isAcknowledged(status, body)) return { outcome: 'failed', sent }
485    sent += batch.length
486  }
487  return { outcome: 'sent', sent }
488}
489
490/**
491 * One best-effort POST to a daemon route (session start and end, the post-tool
492 * signal), with the hook-auth header when the daemon wrote one. Resolves to the
493 * answer's body on HTTP 200, else undefined; never throws.
494 */
495async function postBestEffort(
496  $: EngineInterface,
497  route: string,
498  body: unknown,
499  maxSeconds: number,
500  retrySeconds = 0,
501): Promise<string | undefined> {
502  try {
503    const port = await daemonPort($)
504    if (port === undefined) return undefined
505    const argv = postArgv(port, await authHeaderFile($, port), route, maxSeconds, retrySeconds, await hostHeaders($))
506    const ran = await $.process.run(argv, {
507      stdin: JSON.stringify(body),
508      timeoutMs: (maxSeconds + retrySeconds + 3) * 1000,
509    })
510    if (ran.exitCode !== 0) return undefined
511    const { body: answer, status } = splitStatus(ran.stdout)
512    return status === '200' ? answer : undefined
513  } catch {
514    return undefined
515  }
516}
517
518
519/** Marks the mod's values and reads them, just before compaction. */
520async function snapshot($: EngineInterface): Promise<Carried> {
521  const mark = await $.clock.now()
522  await update($, compactMark, () => mark)
523  return {
524    counts: await read($, counts),
525    decisions: await read($, decisions),
526    dismissedSeq: await read($, dismissedSeq),
527    mark,
528  }
529}
530
531/**
532 * Puts back what compaction cleared, merged with anything recorded since: each
533 * write applies to the value as it then stands, so a concurrent record is
534 * kept. Nothing happens when the values were not cleared.
535 */
536async function restore($: EngineInterface, kept: Carried): Promise<void> {
537  // The mark is still there: compaction left the values alone.
538  if ((await read($, compactMark)) === kept.mark) return
539  const total = await update($, counts, now => addCounts(kept.counts, now))
540  await update($, decisions, now => joinDecisions(kept.decisions, now))
541  await update($, dismissedSeq, now => Math.max(now, kept.dismissedSeq))
542  $.ui.status(statusText(total))
543}
544
545export const register: Register = on => {
546  // Hooks that only observe carry no .catch: one that fails before next is
547  // skipped, and one that fails after next leaves next's result standing.
548  // Whether /guard registered; if another plugin owns the name, pass it on.
549  let ownsCommand = false
550  // The subagent behind each call in flight, by tool_use_id: tool.call knows
551  // it, classic.PreToolUse does not. And the Agent calls that asked for a
552  // worktree of their own, by tool_use_id, until their spawn is recorded.
553  const callerOf = new Map<string, string>()
554  const isolatedCalls = new Set<string>()
555  // The subagents seen this session (agents.ts), and the ids whose worktree
556  // appeared before their spawn finished. Held in this module's memory, which
557  // /clear, /resume and compaction leave alone (only a reload of the mod
558  // clears it, after which running subagents are denied as unknown), and
559  // written synchronously, so no other event can interleave with a write.
560  let agentTable: AgentTable = {}
561  let earlyWorktrees: string[] = []
562  // The session-history feed: rows Claude Code kept since the last push, the
563  // structured results of finished tool calls (sent with the rows reporting
564  // them), what each subagent's spawn said, and whether the daemon takes the
565  // feed at all (a released daemon does not; it reads the transcript).
566  // Each caller's permission mode ("" = the main thread, else the subagent's
567  // id), from the latest classic event that gives it (PreToolUse does not); a
568  // subagent's definition may set another mode than the session's.
569  const modes = new Map<string, string>()
570  const noteMode = (e: { permission_mode?: unknown; agent_id?: unknown }): void => {
571    if (typeof e.permission_mode !== 'string' || e.permission_mode === '') return
572    modes.set(typeof e.agent_id === 'string' ? e.agent_id : '', e.permission_mode)
573  }
574  // The session the buffered rows belong to: /clear, /resume and /branch move
575  // to another, whose history starts afresh.
576  let feedSession: string | undefined
577  // Bumped with each new session, so a push in flight across the change
578  // leaves the new buffer alone.
579  let feedGeneration = 0
580  // Until a session's start says otherwise, rows may have been lost (the mod
581  // reloaded mid-session): the first push then asks the daemon to catch up.
582  let feed: FeedBuffer = { ...emptyBuffer(), gap: 1 }
583  // The session's folder as last read (session start, checks, tool calls).
584  let knownCwd: string | undefined
585  let feedSupported = true
586  const toolResults = new ResultTable()
587  const running = new RunningCalls()
588  // The history push in flight, which the next check waits for.
589  let pushInFlight: Promise<void> | undefined
590  const spawns: Record<string, { toolUseId: string; agentType: string }> = {}
591  // The totals and decisions as they stood before the last compaction, put
592  // back by classic.SessionStart if compaction cleared them.
593  let carried: Carried | undefined
594
595  on('session.start', async ($, e, next) => {
596    $.ui.status(statusText(await read($, counts)))
597    try {
598      await $.command.register({
599        name: COMMAND,
600        description: 'Show sasy-guard daemon health and recent policy decisions',
601        immediate: true,
602      })
603      ownsCommand = true
604    } catch (error) {
605      // Another plugin may own the name; the status line and band still work.
606      const why = error instanceof Error ? error.message : String(error)
607      $.ui.toast(`sasy-guard: /${COMMAND} is unavailable (${shorten(why, 120)})`)
608    }
609    return next(e)
610  })
611
612  // Fires at startup and after /clear, /resume, /branch and compaction: as the
613  // hook plugin's session-start script, start the daemon if needed and
614  // register the session (a fresh registration after /clear). The resets also
615  // clear $.state without a new session.start: after compaction put back
616  // what was carried, re-pin the status line and keep what each check needs
617  // to say about the session.
618  on('classic.SessionStart', async ($, e, next) => {
619    // Another session's rows: after /clear, /resume or /branch, also when the
620    // mod first saw this one start there (it was enabled mid-session).
621    const isOther =
622      feedSession === undefined ? e.source !== 'startup' && e.source !== 'compact' : feedSession !== e.session_id
623    if (feedSession === undefined && e.source === 'startup') feed = { ...feed, gap: 0 }
624    if (isOther) {
625      feed = emptyBuffer()
626      feedSupported = true
627      toolResults.clear()
628      feedGeneration++
629      modes.clear() // the modes were the ended session's
630    }
631    noteMode(e)
632    feedSession = e.session_id
633    if (typeof e.cwd === 'string' && e.cwd !== '') knownCwd = e.cwd
634    const kept = carried
635    carried = undefined
636    if (kept !== undefined && e.source === 'compact') {
637      try {
638        await restore($, kept)
639      } catch {
640        // The totals and the band start over; enforcement is unaffected.
641      }
642    }
643    const path = typeof e.transcript_path === 'string' ? e.transcript_path : ''
644    const type = typeof e.agent_type === 'string' ? e.agent_type : ''
645    await update($, sessionInfo, () => ({
646      transcriptPath: path === '' ? null : path,
647      agentType: type === '' ? null : type,
648    }))
649    $.ui.status(statusText(await read($, counts)))
650    let isRegistered = (await postBestEffort($, '/v1/session/start', e, 10)) !== undefined
651    if (!isRegistered) {
652      // A daemon just started answers before its policy engine is ready:
653      // retry the registration for up to 20 seconds while the engine starts.
654      // Bounded so a port holder that never answers delays the start by at
655      // most about 45 seconds (10 + the daemon start + 20 + one attempt).
656      await ensureDaemon($)
657      isRegistered = (await postBestEffort($, '/v1/session/start', e, 5, 20)) !== undefined
658    }
659    if (!isRegistered) {
660      $.ui.toast(
661        'sasy-guard: the SASY daemon did not start; tool calls will be blocked ' +
662          '(unless SASY_FAIL_OPEN=true with the hook-auth file in place)',
663      )
664    }
665    const result = await next(e)
666    return { ...result, additionalContext: [...(result.additionalContext ?? []), SESSION_NOTE] }
667  })
668
669  on('classic.SubagentStart', async ($, e, next) => {
670    noteMode(e)
671    return next(e)
672  })
673
674  on('classic.UserPromptSubmit', async ($, e, next) => {
675    noteMode(e)
676    return next(e)
677  })
678
679  on('classic.PostToolUseFailure', async ($, e, next) => {
680    noteMode(e)
681    return next(e)
682  })
683
684  // The daemon's post-tool signal: the call ran (its approval recorder's
685  // evidence) and, for AskUserQuestion, the answer. Best effort, as the hook's.
686  on('classic.PostToolUse', async ($, e, next) => {
687    noteMode(e)
688    const context = contextOf(await postBestEffort($, '/v1/posttooluse', e, 5))
689    const result = await next(e)
690    if (context.length === 0) return result
691    return { ...result, additionalContext: [...(result.additionalContext ?? []), ...context] }
692  })
693
694  on('classic.PreCompact', async ($, e, next) => {
695    carried = await snapshot($).catch(() => carried)
696    return next(e)
697  })
698
699  on('classic.SessionEnd', async ($, e, next) => {
700    await postBestEffort($, '/v1/session/end', { session_id: e.session_id }, 1)
701    return next(e)
702  })
703
704  // Subagents: their type and folder when they start, and whether they run in
705  // a worktree of their own (asked by the Agent call, or seen at WorktreeCreate).
706  on('agent.spawn', async ($, e, next) => {
707    // A top-level subagent that names no folder runs in the session's folder
708    // as it is at the spawn, not as it is when the subagent later calls.
709    const cwd = e.cwd ?? (e.parentAgentId === undefined ? await $.session.cwd() : undefined)
710    const started = await next(e)
711    if (started.agentId === undefined) return started
712    // Recorded before anything else is awaited: the subagent has started.
713    const agentId = started.agentId
714    const isIsolated = isolatedCalls.has(e.tool_use_id) || earlyWorktrees.includes(agentId)
715    isolatedCalls.delete(e.tool_use_id)
716    // A teammate is named by its team name, as its checks name it.
717    const teammate = started.teammateId?.split('@')[0]
718    spawns[agentId] = { toolUseId: e.tool_use_id, agentType: teammate || e.subagentType }
719    agentTable = addSpawn(
720      agentTable,
721      {
722        agentId,
723        subagentType: e.subagentType,
724        ...(cwd === undefined ? {} : { cwd }),
725        ...(e.parentAgentId === undefined ? {} : { parentAgentId: e.parentAgentId }),
726        ...(e.isTeammate === true ? { isTeammate: true } : {}),
727        // A teammate's settings-hook events name it by its team name
728        // (`<name>` of `<name>@<team>`), not by its subagent type.
729        ...(started.teammateId === undefined
730          ? {}
731          : { teammateName: started.teammateId.split('@')[0] ?? started.teammateId }),
732      },
733      isIsolated,
734    )
735    return started
736  })
737
738  on('classic.WorktreeCreate', async ($, e, next) => {
739    // A worktree created for an isolated subagent is named `agent-<id>`: that
740    // subagent will run in a folder no mod event gives. If the creation fails,
741    // the subagent does not start.
742    const agentId = isolatedWorktreeAgent(e)
743    if (agentId !== undefined) {
744      if (agentTable[agentId] !== undefined) agentTable = markUnattributable(agentTable, agentId)
745      else earlyWorktrees = [...earlyWorktrees, agentId].slice(-MAX_EARLY)
746    }
747    return next(e)
748  })
749
750  // Each row Claude Code keeps, as stored, for the next push.
751  on('session.append', async ($, e, next) => {
752    const generation = feedGeneration
753    const stored = await next(e)
754    if (!feedSupported || generation !== feedGeneration) return stored // an ended session's row
755    // Queued at once (no await between storing and queueing), with the folder
756    // last seen for the session or the subagent's recorded one.
757    const cwd = e.agentId === undefined ? knownCwd : agentTable[e.agentId]?.cwd
758    feed = enqueue(feed, rowOf({ ...e, message: stored.message ?? e.message }, cwd))
759    return stored
760  })
761
762  // The tool's structured result, for the history row that reports it.
763  const noteResult = (id: string, result: { result?: unknown }): void => {
764    if (!feedSupported) return
765    // A refused or failed call has no structured result here (the transcript
766    // records its error string): its row is read from the transcript.
767    // A result too large or dropped is remembered by id: its row, whenever it
768    // comes, is read from the transcript instead. Past that memory, a gap.
769    const forgotten =
770      result.result === undefined || result.result === null
771        ? toolResults.unknown(id)
772        : toolResults.note(id, result.result)
773    if (forgotten > 0) feed = { ...feed, gap: feed.gap + forgotten }
774  }
775
776  on('tool.call', async ($, e, next) => {
777    const isIsolatedAgent =
778      e.tool === 'Agent' && (e as { isolation?: unknown }).isolation === 'worktree'
779    if (!isIsolatedAgent && e.agentId === undefined) {
780      running.start(e.tool_use_id)
781      try {
782        const result = await next(e)
783        noteResult(e.tool_use_id, result)
784        // The call may have moved the session (EnterWorktree, cd): the rows
785        // that follow carry the folder as it is now.
786        knownCwd = await $.session.cwd()
787        return result
788      } finally {
789        running.stop(e.tool_use_id)
790      }
791    }
792    if (isIsolatedAgent) isolatedCalls.add(e.tool_use_id)
793    if (e.agentId !== undefined) callerOf.set(e.tool_use_id, e.agentId)
794    running.start(e.tool_use_id)
795    try {
796      const result = await next(e)
797      noteResult(e.tool_use_id, result)
798      // A subagent that entered a worktree (created, or an existing one by its
799      // path) runs from now on in a folder no mod event gives.
800      const agentId = e.agentId
801      const hasEntered =
802        e.tool === 'EnterWorktree' && result.deny === undefined && result.isError !== true
803      if (agentId !== undefined && hasEntered) agentTable = markUnattributable(agentTable, agentId)
804      return result
805    } finally {
806      // The call is over: its spawn, if any, has been recorded.
807      isolatedCalls.delete(e.tool_use_id)
808      callerOf.delete(e.tool_use_id)
809      running.stop(e.tool_use_id)
810    }
811  })
812
813  // Enforcement. A failure before the daemon answered denies the call (fail
814  // closed); after `next`, the result `next` settled to stands.
815  on('classic.PreToolUse', async ($, e, next) => {
816    // The session this call is in: if /clear, /resume or /branch ends it
817    // while a check (or the re-check after an approval) waits or pushes, the
818    // check sends nothing more (the rows are the new session's) and is denied.
819    const generation = feedGeneration
820    const { tool, tool_use_id, ...args } = e as unknown as Record<string, unknown> & {
821      tool: string
822      tool_use_id: string
823    }
824    const info = await read($, sessionInfo)
825    const caller = attribute(agentTable, callerOf.get(tool_use_id))
826    let ours: PreToolUseResult
827    // When the mod's own dialog asked the user, what to record for the call.
828    let dialog: DialogRecord | undefined
829    if (info === null) {
830      ours = {
831        deny:
832          '[SASY] security check unavailable: sasy-guard-mod has not seen this session ' +
833          'start (it was enabled mid-session); start a new session',
834      }
835    } else if (String(tool) === 'Agent' && args.isolation === 'remote') {
836      // A remote (cloud) subagent's tool calls run where neither this mod nor
837      // the local daemon sees them, so its spawn would be an unchecked channel.
838      ours = {
839        deny:
840          '[SASY] sasy-guard-mod cannot check the tool calls of a remote (cloud) subagent; ' +
841          'run the agent locally instead',
842      }
843    } else if (caller.kind === 'unknown') {
844      ours = {
845        deny:
846          `[SASY] security check unavailable: this call comes from ${caller.why}, so its ` +
847          'folder and identity cannot be checked; use the sasy-guard hook plugin for this workflow',
848      }
849    } else {
850      const sessionCwd = await $.session.cwd()
851      knownCwd = sessionCwd
852      const cwd = caller.kind === 'agent' ? caller.cwd ?? sessionCwd : sessionCwd
853      const agentType = caller.kind === 'agent' ? caller.type : info.agentType
854      // A subagent's own definition may set another mode than the session's,
855      // so until one of its calls has finished its mode is not known.
856      const mode = modes.get(caller.kind === 'agent' ? caller.agentId : '')
857      const input: CheckInput = {
858        session_id: await $.session.id(),
859        tool_name: String(tool),
860        tool_input: args,
861        tool_use_id,
862        cwd,
863        ...(info.transcriptPath === null ? {} : { transcript_path: info.transcriptPath }),
864        ...(caller.kind === 'agent' ? { agent_id: caller.agentId } : {}),
865        ...(agentType === null ? {} : { agent_type: agentType }),
866        ...(mode === undefined ? {} : { permission_mode: mode }),
867        sasy_mod: true,
868      }
869      // Every check (and the re-check after an approval) first gives the daemon
870      // every row kept since the last push, so it decides on the whole history.
871      const check = async (checked: CheckInput): Promise<CheckAnswer> => {
872        let pushed: FeedOutcome = 'unsupported'
873        // All of a check's pushing, its wait for another's included, has one
874        // deadline: a stalled daemon cannot hold queued calls for longer.
875        const deadline = (await $.clock.now()) + PUSH_DEADLINE_MS
876        // One push at a time: a check waits for one in flight, then sends what
877        // is left (so two never deliver, or count, the same rows).
878        while (pushInFlight !== undefined) {
879          const left = deadline - (await $.clock.now())
880          if (left <= 0) return { result: { deny: HISTORY_UNSENT } }
881          await Promise.race([pushInFlight, $.clock.sleep(left)])
882        }
883        let release = (): void => {}
884        pushInFlight = new Promise<void>(resolve => (release = resolve))
885        try {
886          // Rows kept while a push ran go out before the check too (a few
887          // rounds at most); a session change while a push ran ends it.
888          for (let round = 0; round < MAX_PUSH_ROUNDS && feedSupported; round++) {
889            if (round > 0 && (pushed !== 'sent' || (feed.rows.length === 0 && feed.gap === 0))) break
890            // A row reporting a tool call still running waits, briefly, for
891            // the call to finish and its structured result to be known.
892            const waits = reportedCalls(feed.rows).flatMap(id => running.done(id) ?? [])
893            const waitMs = Math.min(RESULT_WAIT_MS, deadline - (await $.clock.now()))
894            if (waits.length > 0 && waitMs > 0) await Promise.race([Promise.all(waits), $.clock.sleep(waitMs)])
895            if (generation !== feedGeneration) break // the session changed: see below
896            const pending = { ...feed, rows: [...feed.rows] }
897            const base = {
898              session_id: checked.session_id,
899              cwd: sessionCwd,
900              ...(info.transcriptPath === null ? {} : { transcript_path: info.transcriptPath }),
901            }
902            // A row the mod cannot give whole goes marked: the daemon takes it
903            // from the transcript (waiting until it is written).
904            const sending = withResults(pending.rows, toolResults, id => running.has(id))
905            const gap = pending.gap > 0
906            let { outcome, sent } = await sendFeed($, base, sending, spawns, gap, deadline)
907            if (outcome === 'unreachable' && deadline - (await $.clock.now()) > ENSURE_TIMEOUT_MS + 1000) {
908              // As for a check: start the daemon once and send everything again
909              // (a new daemon may hold none of it; it skips rows it has).
910              await ensureDaemon($)
911              ;({ outcome, sent } = await sendFeed($, base, sending, spawns, gap, deadline))
912            }
913            pushed = outcome
914            if (generation !== feedGeneration) break // another session's buffer now
915            if (outcome === 'unsupported') {
916              feedSupported = false
917              feed = emptyBuffer()
918              toolResults.clear()
919            } else {
920              feed = afterPush(feed, pending, sent, sent > 0 || outcome === 'sent')
921              // The results those rows carried are delivered: no longer needed.
922              toolResults.forget(reportedCalls(sending.slice(0, sent)))
923            }
924          }
925        } finally {
926          pushInFlight = undefined
927          release()
928        }
929        // Undelivered history is never checked around: the daemon would decide
930        // without it (also rows still arriving after the last round). Only an
931        // unreachable daemon may fail open, as for a check.
932        if (pushed === 'sent' && feedSupported && (feed.rows.length > 0 || feed.gap > 0)) pushed = 'failed'
933        if (generation !== feedGeneration) pushed = 'failed' // the session changed while this check ran
934        if (pushed === 'sent' || pushed === 'unsupported') return checkCall($, checked)
935        if (pushed === 'unreachable' && (await failsOpen($))) return { result: {} }
936        return { result: { deny: HISTORY_UNSENT } }
937      }
938      const answer = await check(input)
939      // A one-time bypass on offer: ask the user here, holding the call, where
940      // someone can be asked; elsewhere the denial (with its model-driven
941      // AskUserQuestion instructions) stands, as with the hook plugin.
942      if (answer.offer !== undefined && (await canAsk($))) {
943        const outcome = await askForBypass($, input, answer.offer, check)
944        ours = outcome.result
945        dialog = outcome.record
946      } else {
947        ours = answer.result
948      }
949    }
950    // Any other settings hooks run whatever SASY answered.
951    const theirs = await next(e)
952    const result = ours.deny !== undefined ? denyWith(ours, theirs) : combine(ours, theirs)
953    // What SASY decided, not what another hook made of the call.
954    await recordSafely($, e, ours, dialog)
955    return result
956  }).catch(() =>
957    // Any failure denies, also after `next`: the engine refusing this mod's
958    // answer (an input rewrite its tool does not accept) must not let the
959    // call run as the other hooks left it.
960    ({ deny: '[SASY] security check failed inside sasy-guard-mod' }),
961  )
962
963  on('command.run', { command: COMMAND }, async ($, e, next) => {
964    if (!ownsCommand) return next(e)
965    return { text: guardText(await daemonHealth($), await read($, counts), await read($, decisions)) }
966  })
967
968  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
969    const latest = (await read($, decisions)).at(-1)
970    if (e.props.hasSurvey || latest === undefined) return next(e)
971    if (latest.seq <= (await read($, dismissedSeq))) return next(e)
972
973    const { Box, Button, Text } = $.ui.resolve(e)
974    const { verb, color, shown } = bandLines(latest, e.props.maxRows)
975
976    // Later mods share the band: keep what they draw below ours.
977    const theirs = await next(e)
978    return (
979      <Box flexDirection="column">
980        <Text color={color} bold>
981          sasy-guard {verb} {latest.tool}
982          {latest.target === '' ? '' : `: ${latest.target}`}
983        </Text>
984        {shown.map(line => (
985          <Text dimColor>{line}</Text>
986        ))}
987        <Box>
988          <Button
989            key="dismiss"
990            label="Dismiss"
991            onPress={() => update($, dismissedSeq, () => latest.seq)}
992          />
993        </Box>
994        {theirs}
995      </Box>
996    )
997  })
998}
999
hooks/agents.ts 102 lines
1// Which agent made a tool call, as the policy check needs to know it.
2//
3// A settings hook is handed the caller by Claude Code (`agent_id`,
4// `agent_type`, `cwd`); a mod's `classic.PreToolUse` is not. The mod rebuilds
5// it: `tool.call` names the subagent behind a call, and `agent.spawn` says each
6// subagent's type and folder when it starts. A subagent runs in the folder its
7// spawn named, else its parent's, else the session's current one (a `cd` inside
8// a subagent does not persist between its commands). A subagent given its own
9// git worktree runs where the mod cannot see, so its calls are unattributable.
10// Pure functions over plain data, so register.tsx can keep the table in $.state.
11
12/** What the mod knows about one subagent. */
13export type AgentRecord = {
14  /** The resolved agent type, as the hook payload's `agent_type`. */
15  type: string
16  /** The folder its spawn named, or null for the session's current folder. */
17  cwd: string | null
18  /** True when the mod cannot tell where it runs (worktree, unknown parent). */
19  isUnattributable: boolean
20}
21
22/** The subagents seen this session, by agent id. */
23export type AgentTable = Readonly<Record<string, AgentRecord>>
24
25/** What `agent.spawn` says about a subagent that started. */
26export type Spawn = {
27  agentId: string
28  subagentType: string
29  cwd?: string
30  parentAgentId?: string
31  isTeammate?: boolean
32  /** A teammate's name in its team, which its hook payloads give as `agent_type`. */
33  teammateName?: string
34}
35
36/** The caller of one tool call, or why the mod cannot name it. */
37/** Why a subagent in a worktree of its own cannot be checked. */
38export const WORKTREE_WHY = 'a subagent in its own worktree, whose folder the mod cannot see'
39
40export type Caller =
41  | { kind: 'main' }
42  | { kind: 'agent'; agentId: string; type: string; cwd: string | null }
43  | { kind: 'unknown'; why: string }
44
45/** The agent id in the name Claude Code gives a subagent's worktree. */
46export function worktreeAgentId(name: string): string | undefined {
47  const match = /^agent-([A-Za-z0-9]{1,64})$/.exec(name)
48  return match?.[1]
49}
50
51/**
52 * The isolated subagent a WorktreeCreate was made for, from its `agent-<id>`
53 * name. None when the event carries an `agent_id`: a subagent entering a
54 * worktree itself (EnterWorktree) is handled at that tool call, once it
55 * succeeds. None for the main thread's own worktree, which the session's
56 * folder follows.
57 */
58export function isolatedWorktreeAgent(event: { name?: unknown; agent_id?: unknown }): string | undefined {
59  if (typeof event.agent_id === 'string' && event.agent_id !== '') return undefined
60  return typeof event.name === 'string' ? worktreeAgentId(event.name) : undefined
61}
62
63/**
64 * The table with one more subagent. It inherits its parent's folder when its
65 * spawn named none, and is unattributable when it runs isolated or its parent
66 * is unknown or unattributable. A teammate runs in the folder its spawn named,
67 * never its parent's.
68 */
69export function addSpawn(table: AgentTable, spawn: Spawn, isIsolated: boolean): AgentTable {
70  const parent = spawn.parentAgentId === undefined ? undefined : table[spawn.parentAgentId]
71  const isParentUnknown =
72    spawn.parentAgentId !== undefined && (parent === undefined || parent.isUnattributable)
73  const inherited = parent?.cwd ?? null
74  const cwd = spawn.isTeammate === true ? spawn.cwd ?? null : spawn.cwd ?? inherited
75  const record: AgentRecord = {
76    type: spawn.teammateName ?? spawn.subagentType,
77    cwd,
78    isUnattributable: isIsolated || isParentUnknown,
79  }
80  return { ...table, [spawn.agentId]: record }
81}
82
83/** The table with one subagent marked unattributable (its worktree appeared). */
84export function markUnattributable(table: AgentTable, agentId: string): AgentTable {
85  const record = table[agentId]
86  if (record === undefined) return table
87  return { ...table, [agentId]: { ...record, isUnattributable: true } }
88}
89
90/** Who made a call, given the subagent `tool.call` named (none: the main thread). */
91export function attribute(table: AgentTable, agentId: string | undefined): Caller {
92  if (agentId === undefined) return { kind: 'main' }
93  const record = table[agentId]
94  if (record === undefined) {
95    return { kind: 'unknown', why: 'an agent this mod did not see start (one started before the mod loaded, or one Claude Code runs itself, such as a preview of a suggested prompt)' }
96  }
97  if (record.isUnattributable) {
98    return { kind: 'unknown', why: WORKTREE_WHY }
99  }
100  return { kind: 'agent', agentId, type: record.type, cwd: record.cwd }
101}
102
hooks/carry.ts 36 lines
1// What the mod puts back after compaction: Claude Code may clear a plugin's
2// session values ($.state) there without a new session.start, and the totals
3// and decisions describe the whole session. Pure, so the tests can hold it to
4// its rules. (Subagent records live in the hooks module's memory, which no
5// reset clears.)
6import type { GuardCounts, GuardDecision } from '../types'
7
8/** The most decisions the mod keeps. */
9export const MAX_DECISIONS = 50
10
11/** The mod's $.state values that compaction may clear, and the mark written
12 *  with them, whose absence afterwards says they were cleared. */
13export type Carried = {
14  counts: GuardCounts
15  decisions: GuardDecision[]
16  dismissedSeq: number
17  mark: number
18}
19
20/** The totals before the reset plus those recorded since. */
21export function addCounts(kept: GuardCounts, now: GuardCounts): GuardCounts {
22  return {
23    checked: kept.checked + now.checked,
24    denied: kept.denied + now.denied,
25    asked: kept.asked + now.asked,
26  }
27}
28
29/** The decisions before the reset, then those recorded since, renumbered after
30 *  them so each `seq` stays unique and growing. */
31export function joinDecisions(kept: GuardDecision[], now: GuardDecision[]): GuardDecision[] {
32  const last = kept[kept.length - 1]?.seq ?? 0
33  const since = now.map((d, i) => ({ ...d, seq: last + i + 1 }))
34  return [...kept, ...since].slice(-MAX_DECISIONS)
35}
36
hooks/feed.ts 323 lines
1// The session-history feed: every row Claude Code keeps (`session.append`),
2// buffered and sent to the daemon ahead of each check, so the daemon's
3// dependency graph holds every earlier row when it decides. Pure, so the
4// tests can hold it to its rules; the hooks module does the sending.
5
6/** One row as the daemon's /v1/session/append takes it. */
7export type FeedRow = {
8  uuid: string
9  agentId?: string
10  message: { type: string; name?: string; role?: string; content: unknown[] }
11  cwd?: string
12  model?: string
13  /** The tool's structured result: an object when it ran, a string when it
14   *  errored or was refused (the transcript's `toolUseResult`). */
15  toolUseResult?: unknown
16  /** The mod cannot give this row whole (its tool result is unknown here):
17   *  the daemon takes it from the transcript, waiting until it is written. */
18  fromTranscript?: true
19}
20
21/** Rows waiting to be sent, their serialized size, and how many losses of
22 *  rows the daemon has not yet been told of (a count, so a loss during a push
23 *  is not cleared by that push's delivery). */
24export type FeedBuffer = { rows: FeedRow[]; bytes: number; gap: number }
25
26/** At most this many rows, or serialized bytes, in one push (the daemon takes
27 *  2000 rows and a 4 MiB body). */
28export const MAX_BATCH_ROWS = 1000
29export const MAX_BATCH_BYTES = 3_000_000
30/** Past this much unsent history the buffer is dropped and the next push says
31 *  rows were lost, so the daemon reads the transcript again. */
32export const MAX_BUFFER_ROWS = 20_000
33export const MAX_BUFFER_BYTES = 32_000_000
34/** Tool results remembered for the rows that report them, by count and by
35 *  serialized size. */
36export const MAX_RESULTS = 500
37export const MAX_RESULT_BYTES = 32_000_000
38/** Ids of results dropped or too large, remembered for their rows. */
39const MAX_LOST = 10_000
40/** Kept in place of a result too large for any push. */
41export const TOO_LARGE: unique symbol = Symbol('too large')
42
43export const emptyBuffer = (): FeedBuffer => ({ rows: [], bytes: 0, gap: 0 })
44
45/** The UTF-8 size of a string, as the daemon counts a request body. */
46export function utf8Length(text: string): number {
47  let bytes = 0
48  for (let i = 0; i < text.length; i++) {
49    const c = text.charCodeAt(i)
50    if (c < 0x80) bytes += 1
51    else if (c < 0x800) bytes += 2
52    else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
53      const next = text.charCodeAt(i + 1)
54      if (next >= 0xdc00 && next <= 0xdfff) {
55        bytes += 4 // a surrogate pair: one code point
56        i++
57      } else bytes += 3
58    } else bytes += 3
59  }
60  return bytes
61}
62
63/** A result's serialized size. */
64export const resultSize = (value: unknown): number => utf8Length(JSON.stringify(value) ?? '')
65
66/** How deeply the daemon may find a row nested in a push body (its JSON
67 *  depth limit is 32; the body and row wrap each row in a few levels). */
68export const MAX_DEPTH = 24
69
70/** The nesting depth of a JSON value (a scalar is 0). */
71export function depthOf(value: unknown): number {
72  let deepest = 0
73  const stack: [unknown, number][] = [[value, 0]]
74  while (stack.length > 0) {
75    const [v, d] = stack.pop()!
76    if (v === null || typeof v !== 'object') continue
77    deepest = Math.max(deepest, d + 1)
78    for (const child of Object.values(v as Record<string, unknown>)) stack.push([child, d + 1])
79  }
80  return deepest
81}
82
83/** A row's size in a push. */
84const sizeOf = (row: FeedRow): number => utf8Length(JSON.stringify(row))
85
86/** The row for one `session.append` event, as stored. */
87export function rowOf(
88  e: {
89    uuid: string
90    agentId?: string
91    message: { type: string; name?: string; role?: string; content: readonly unknown[] }
92    origin?: { kind?: string; model?: unknown }
93  },
94  cwd: string | null | undefined,
95): FeedRow {
96  const { type, name, role, content } = e.message
97  const model = e.origin?.kind === 'model' && typeof e.origin.model === 'string' ? e.origin.model : undefined
98  return {
99    uuid: e.uuid,
100    ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
101    message: { type, ...(name === undefined ? {} : { name }), ...(role === undefined ? {} : { role }), content: [...content] },
102    ...(cwd ? { cwd } : {}),
103    ...(model === undefined ? {} : { model }),
104  }
105}
106
107/** The buffer with one more row (appended in place); past its bounds, empty
108 *  and marked lost. A row too large for any push is not kept: the buffer is
109 *  marked lost instead, so the daemon reads it from the transcript. */
110export function enqueue(buffer: FeedBuffer, row: FeedRow): FeedBuffer {
111  const size = sizeOf(row)
112  // Too large, or nested deeper than the daemon parses: left to the transcript.
113  if (size > MAX_BATCH_BYTES || depthOf(row) > MAX_DEPTH) return { ...buffer, gap: buffer.gap + 1 }
114  const bytes = buffer.bytes + size
115  if (buffer.rows.length >= MAX_BUFFER_ROWS || bytes > MAX_BUFFER_BYTES) {
116    return { rows: [], bytes: 0, gap: buffer.gap + 1 }
117  }
118  buffer.rows.push(row)
119  return { rows: buffer.rows, bytes, gap: buffer.gap }
120}
121
122/** The tool_use ids the rows' tool results report. */
123export function reportedCalls(rows: FeedRow[]): string[] {
124  return rows.flatMap(resultIds)
125}
126
127/** The tool_use ids a row's tool results answer. */
128function resultIds(row: FeedRow): string[] {
129  return row.message.content.flatMap(b => {
130    const block = b as { type?: unknown; tool_use_id?: unknown }
131    return block?.type === 'tool_result' && typeof block.tool_use_id === 'string' ? [block.tool_use_id] : []
132  })
133}
134
135/**
136 * Each row with the structured result of the tool call it reports, when the
137 * mod saw that call finish (the transcript's `toolUseResult`). A result that
138 * would make its row too large for a push is left off and `gap` set: the
139 * daemon then reads that row, result and all, from the transcript first.
140 */
141export function withResults(
142  rows: FeedRow[],
143  results: { get(id: string): unknown },
144  isRunning: (id: string) => boolean = () => false,
145): FeedRow[] {
146  const fromTranscript = (row: FeedRow): FeedRow => ({ ...row, fromTranscript: true })
147  return rows.map(row => {
148    if (row.toolUseResult !== undefined) return row
149    const ids = resultIds(row)
150    // One structured result describes one tool result: a row reporting
151    // several, or a call still finishing, comes from the transcript.
152    if (ids.length > 1 || ids.some(isRunning)) return fromTranscript(row)
153    if (ids.length === 0) return row
154    // A tool result the mod never saw (the mod reloaded meanwhile, say) comes
155    // from the transcript, as does one too large or deep to send.
156    const found = results.get(ids[0]!)
157    if (found === undefined || found === TOO_LARGE) return fromTranscript(row)
158    const enriched = { ...row, toolUseResult: found }
159    return sizeOf(enriched) <= MAX_BATCH_BYTES ? enriched : fromTranscript(row)
160  })
161}
162
163/** The rows split into pushes the daemon takes, in order (no row is larger
164 *  than a push: enqueue keeps none). */
165export function batches(rows: FeedRow[]): FeedRow[][] {
166  const out: FeedRow[][] = []
167  let current: FeedRow[] = []
168  let size = 0
169  for (const row of rows) {
170    const n = sizeOf(row)
171    if (current.length > 0 && (current.length >= MAX_BATCH_ROWS || size + n > MAX_BATCH_BYTES)) {
172      out.push(current)
173      current = []
174      size = 0
175    }
176    current.push(row)
177    size += n
178  }
179  if (current.length > 0) out.push(current)
180  return out
181}
182
183/**
184 * The buffer after a push of `pending` (a copy of the buffer taken when the
185 * push began) delivered its first `sent` rows. Rows kept while the push ran stay.
186 * `delivered`: the first push went through, so the daemon also learned of the
187 * losses `pending` held; any since stay counted. A buffer dropped while the push ran (too much history) stays as
188 * it is, marked lost.
189 */
190export function afterPush(now: FeedBuffer, pending: FeedBuffer, sent: number, delivered: boolean): FeedBuffer {
191  // Pushes run one at a time, so `now` is `pending` plus rows kept since,
192  // unless the buffer was dropped meanwhile (it no longer starts with them).
193  const stillQueued = pending.rows.every((row, i) => now.rows[i] === row)
194  if (!stillQueued) return now
195  const rows = now.rows.slice(sent)
196  return {
197    rows,
198    bytes: rows.reduce((n, row) => n + sizeOf(row), 0),
199    gap: delivered ? now.gap - pending.gap : now.gap,
200  }
201}
202
203/** What each subagent's spawn said, for the subagents a push's rows are from. */
204export function agentsOf<A>(rows: FeedRow[], agents: Readonly<Record<string, A>>): Record<string, A> {
205  const ids = [...new Set(rows.flatMap(r => (r.agentId === undefined ? [] : [r.agentId])))]
206  return Object.fromEntries(ids.filter(id => agents[id] !== undefined).map(id => [id, agents[id] as A]))
207}
208
209/** Whether a push's answer is the daemon's acknowledgement (HTTP 200 and
210 *  `{ ok: true }`); only that counts as delivered. */
211export function isAcknowledged(status: string, body: string): boolean {
212  try {
213    return status === '200' && (JSON.parse(body) as { ok?: unknown }).ok === true
214  } catch {
215    return false
216  }
217}
218
219/**
220 * The structured results of finished tool calls, kept until the rows that
221 * report them are delivered: at most MAX_RESULTS of them and MAX_RESULT_BYTES
222 * in all. A result too large for any push, or dropped to stay in bounds, is
223 * remembered by id as TOO_LARGE, so its row (whenever it comes) is left to the
224 * transcript.
225 */
226export class ResultTable {
227  readonly values = new Map<string, unknown>()
228  private readonly lost = new Set<string>()
229  private bytes = 0
230
231  /** The result for a tool call: its value, TOO_LARGE, or undefined. */
232  get(id: string): unknown {
233    return this.lost.has(id) ? TOO_LARGE : this.values.get(id)
234  }
235
236  /** A call whose structured result the mod does not have (refused, or
237   *  failed): its row is left to the transcript, which records it. */
238  unknown(id: string): number {
239    return this.markLost(id)
240  }
241
242  /** Keeps one result, dropping the oldest to stay in bounds. Returns how
243   *  many lost ids it had to forget (each then a gap for the daemon). */
244  note(id: string, value: unknown): number {
245    const size = resultSize(value)
246    // Too large, or (inside its row) nested deeper than the daemon parses.
247    if (size > MAX_BATCH_BYTES || depthOf(value) > MAX_DEPTH - 1) return this.markLost(id)
248    this.values.set(id, value)
249    this.bytes += size
250    let forgotten = 0
251    while (this.values.size > MAX_RESULTS || this.bytes > MAX_RESULT_BYTES) {
252      const [oldest] = this.values.keys()
253      this.forget([oldest!])
254      forgotten += this.markLost(oldest!)
255    }
256    return forgotten
257  }
258
259  /** Remembers a lost id; returns 1 if an older one had to be forgotten. */
260  private markLost(id: string): number {
261    this.lost.add(id)
262    if (this.lost.size <= MAX_LOST) return 0
263    this.lost.delete(this.lost.values().next().value!)
264    return 1
265  }
266
267  /** Lets go of every result (a new session). */
268  clear(): void {
269    this.values.clear()
270    this.lost.clear()
271    this.bytes = 0
272  }
273
274  /** Lets go of the results whose rows were delivered. */
275  forget(ids: string[]): void {
276    for (const id of ids) {
277      const value = this.values.get(id)
278      if (value !== undefined) this.bytes -= resultSize(value)
279      this.values.delete(id)
280      this.lost.delete(id)
281    }
282  }
283}
284
285/** What became of sending the session-history feed. */
286export type FeedOutcome = 'sent' | 'unsupported' | 'unreachable' | 'failed'
287/** How long a history push waits for a reported tool call to finish. */
288export const RESULT_WAIT_MS = 5000
289/** Pushes before a check: the first, then rounds for rows kept meanwhile. */
290export const MAX_PUSH_ROUNDS = 3
291/** The most time a check spends sending history, all rounds included: room
292 *  for one timed-out request, one daemon start, and a retry. */
293export const PUSH_DEADLINE_MS = 30_000
294/** The denial for a check whose history did not reach the daemon. */
295export const HISTORY_UNSENT =
296  '[SASY] security check unavailable: the session history could not be sent to the sasy-watch daemon'
297
298/** Tool calls started and not yet finished, by tool_use_id, each with a
299 *  promise that settles when it finishes. */
300export class RunningCalls {
301  private readonly calls = new Map<string, { done: Promise<void>; finish: () => void }>()
302
303  start(id: string): void {
304    let finish = (): void => {}
305    const done = new Promise<void>(resolve => (finish = resolve))
306    this.calls.set(id, { done, finish })
307  }
308
309  stop(id: string): void {
310    this.calls.get(id)?.finish()
311    this.calls.delete(id)
312  }
313
314  has(id: string): boolean {
315    return this.calls.has(id)
316  }
317
318  /** What settles when the call finishes; undefined when it is not running. */
319  done(id: string): Promise<void> | undefined {
320    return this.calls.get(id)?.done
321  }
322}
323
hooks/enforce.ts 269 lines
1// The daemon requests' pure parts: the curl arguments for the sasy-watch
2// daemon's routes (the settings hooks' own payloads), and how the daemon's
3// answer to a policy check becomes a `classic.PreToolUse` result. register.tsx
4// makes the calls, since only it may hold the engine interface.
5import type { PreToolUseResult } from 'claude-code'
6
7export const DEFAULT_PORT = '51711'
8export const CHECK_TIMEOUT_MS = 12_000
9export const ENSURE_TIMEOUT_MS = 10_000
10
11/** What the daemon is asked about one tool call: the settings hook's stdin. */
12export type CheckInput = {
13  session_id: string
14  tool_name: string
15  tool_input: Record<string, unknown>
16  tool_use_id: string
17  cwd: string
18  transcript_path?: string
19  /** A subagent's id, as the hook receives it for a subagent's call. */
20  agent_id?: string
21  /** The caller's agent type: a subagent's, or a session started with --agent. */
22  agent_type?: string
23  /** The session's permission mode (`default`, `acceptEdits`, `plan`, ...), as
24   *  of the latest prompt or finished tool call; the hook receives it the same
25   *  way. Left out until the mod has seen it. */
26  permission_mode?: string
27  /** Marks the mod, so a daemon that supports it adds the bypass offer for the
28   *  mod's own dialog (`sasyApproval`); an older daemon ignores it. */
29  sasy_mod: true
30}
31
32/** What the daemon is told about the host, as the hook plugin's scripts tell
33 *  it: Claude Code's entrypoint (`cli`, `claude-vscode`, ...) and the terminal
34 *  program. The daemon logs both and uses the entrypoint to detect the host. */
35export type HostHeaders = { entrypoint: string; term: string }
36
37/** A value safe in a header line: no control characters, at most 64
38 *  characters (the scripts' `header_safe`). */
39export function headerSafe(value: string | undefined): string {
40  return (value ?? '').replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 64)
41}
42
43/** A one-time bypass the daemon offers on a denial, for the mod's own dialog. */
44export type BypassOffer = {
45  /** The daemon-authored question, ending in its `[SASY-ALLOW:…]` routing tag. */
46  question: string
47  /** The canonical choices: approve, decline, and trust-domain when offered. */
48  labels: string[]
49  /** The policy's reason as the user should read it. */
50  reason: string
51  /** The policy's reason with its suggested fix, as the model should read it. */
52  policyReason: string
53  /** The domain trust-domain would trust for the session, when offered. */
54  domain?: string
55}
56
57/** A check's answer: the decision, and the bypass the daemon offers with it. */
58export type CheckAnswer = { result: PreToolUseResult; offer?: BypassOffer }
59
60/** The daemon's two choice lists: without and with a host to trust. */
61const PLAIN_LABELS = ['approve', 'decline']
62const TRUST_LABELS = ['approve', 'decline', 'trust-domain']
63/** Whether `labels` is exactly `expected`, element by element. */
64const isExactly = (labels: unknown[], expected: string[]): boolean =>
65  labels.length === expected.length && labels.every((label, i) => label === expected[i])
66/** A host the daemon names for session trust, as it derives one: 3 to 253
67 *  characters of [a-z0-9.-], with at least one dot between labels. */
68const DOMAIN = /^(?=.{3,253}$)[a-z0-9-]+(\.[a-z0-9-]+)+$/
69/** The daemon's routing tag, which ends every question it asks. */
70const ROUTING_TAG = /\[SASY-ALLOW:[0-9a-f]+\]$/
71/** What the dialog removes before drawing a question (invisible and control
72 *  characters, as register.tsx's CONTROL). */
73const HIDDEN = /[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}\u2800]/gu
74/** Whether a question says something the user can read besides its tag. */
75const isReadable = (question: string): boolean =>
76  /[\p{L}\p{N}]/u.test(question.replace(ROUTING_TAG, '').replace(HIDDEN, ''))
77
78/** The `sasyApproval` field of a daemon answer, or undefined when it is not one. */
79function offerOf(value: unknown): BypassOffer | undefined {
80  if (!isRecord(value)) return undefined
81  const { question, labels, reason, policyReason, domain, ...unknown } = value
82  const isValid =
83    Object.keys(unknown).length === 0 &&
84    typeof question === 'string' && ROUTING_TAG.test(question) && isReadable(question) &&
85    typeof reason === 'string' &&
86    typeof policyReason === 'string' &&
87    Array.isArray(labels) &&
88    (domain === undefined
89      ? isExactly(labels, PLAIN_LABELS)
90      : typeof domain === 'string' && DOMAIN.test(domain) && isExactly(labels, TRUST_LABELS))
91  if (!isValid) return undefined
92  return {
93    question: question as string,
94    labels: labels as string[],
95    reason: reason as string,
96    policyReason: policyReason as string,
97    ...(domain === undefined ? {} : { domain: domain as string }),
98  }
99}
100
101/**
102 * curl's arguments for one POST to the daemon: bounded in time and size, never
103 * via a proxy, the body on stdin, the HTTP status appended on a line of its
104 * own. A daemon that authenticates hooks is sent its header file with
105 * `-H @file`, so the secret never appears in a process's arguments.
106 */
107export function postArgv(
108  port: string,
109  authFile: string | undefined,
110  route: string,
111  maxSeconds: number,
112  retrySeconds = 0,
113  host: HostHeaders = { entrypoint: '', term: '' },
114): string[] {
115  // Retries, when asked for, also cover HTTP errors (a daemon whose policy
116  // engine is still starting answers 400), one a second, for at most
117  // retrySeconds in all (curl restarts --max-time for each attempt).
118  const retry =
119    retrySeconds === 0
120      ? []
121      : [
122          '--fail', '--retry', String(retrySeconds), '--retry-delay', '1',
123          '--retry-max-time', String(retrySeconds), '--retry-all-errors',
124        ]
125  return [
126    'curl', '-sS', '--noproxy', '*', '--max-time', String(maxSeconds), ...retry,
127    '--max-filesize', '1048576', '-X', 'POST', '-H', 'content-type: application/json',
128    '-H', `x-claude-code-entrypoint: ${headerSafe(host.entrypoint) || 'unknown'}`,
129    '-H', `x-claude-code-term-program: ${headerSafe(host.term)}`,
130    ...(authFile === undefined ? [] : ['-H', `@${authFile}`]),
131    '--data-binary', '@-', '--write-out', '\n%{http_code}',
132    `http://127.0.0.1:${port}${route}`,
133  ]
134}
135
136/** curl's arguments for one policy check (/v1/pretooluse). */
137export function checkArgv(port: string, authFile: string | undefined, host?: HostHeaders): string[] {
138  return postArgv(port, authFile, '/v1/pretooluse', 10, 0, host)
139}
140
141/** Splits curl's output into the body and the HTTP status it appended. */
142export function splitStatus(stdout: string): { body: string; status: string } {
143  const cut = stdout.lastIndexOf('\n')
144  return { body: stdout.slice(0, Math.max(cut, 0)), status: stdout.slice(cut + 1) }
145}
146
147const isRecord = (v: unknown): v is Record<string, unknown> =>
148  typeof v === 'object' && v !== null && !Array.isArray(v)
149
150/**
151 * The daemon's hook output as a `classic.PreToolUse` result, or undefined when
152 * it is not one. The accepted shapes are exactly the daemon's: `{}` (no
153 * objection), or `{ hookSpecificOutput }` with `hookEventName: "PreToolUse"`,
154 * an optional decision with its reason, and an optional input rewrite and
155 * context note, each of its own type, with at least one of decision, rewrite
156 * or note. Anything else fails closed rather than passing a call the backup
157 * hook would then skip.
158 */
159export function toResult(body: string): PreToolUseResult | undefined {
160  return parseAnswer(body)?.result
161}
162
163/**
164 * A daemon answer as the decision and, beside a denial, the one-time bypass it
165 * offers (`sasyApproval`, sent only to a client that set `sasy_mod`). Undefined
166 * when the answer is not one of the daemon's exact shapes: that fails closed.
167 */
168export function parseAnswer(body: string): CheckAnswer | undefined {
169  let out: unknown
170  try {
171    out = JSON.parse(body)
172  } catch {
173    return undefined
174  }
175  if (!isRecord(out)) return undefined
176  const { sasyApproval, ...rest } = out
177  const keys = Object.keys(rest)
178  const offer = sasyApproval === undefined ? undefined : offerOf(sasyApproval)
179  if (sasyApproval !== undefined && offer === undefined) return undefined
180  if (keys.length === 0) return offer === undefined ? { result: {} } : undefined
181  if (keys.length !== 1 || !isRecord(rest.hookSpecificOutput)) return undefined
182  const result = decisionOf(rest.hookSpecificOutput)
183  if (result === undefined) return undefined
184  // An offer rides only on a denial.
185  if (offer !== undefined && result.deny === undefined) return undefined
186  return offer === undefined ? { result } : { result, offer }
187}
188
189/** A `hookSpecificOutput` block as a `classic.PreToolUse` result, or undefined. */
190function decisionOf(block: Record<string, unknown>): PreToolUseResult | undefined {
191  const {
192    hookEventName,
193    permissionDecision: decision,
194    permissionDecisionReason: reason,
195    updatedInput,
196    additionalContext: note,
197    ...unknown
198  } = block
199  const isValid =
200    Object.keys(unknown).length === 0 &&
201    hookEventName === 'PreToolUse' &&
202    (decision === undefined || decision === 'allow' || decision === 'ask' || decision === 'deny') &&
203    (reason === undefined || typeof reason === 'string') &&
204    (updatedInput === undefined || isRecord(updatedInput)) &&
205    (note === undefined || typeof note === 'string') &&
206    (decision !== undefined || updatedInput !== undefined || note !== undefined)
207  if (!isValid) return undefined
208  const why = typeof reason === 'string' ? reason : ''
209  const decided: PreToolUseResult =
210    decision === 'deny'
211      ? { deny: why || '[SASY] denied by policy' }
212      : decision === 'ask'
213        ? { ask: why || '[SASY] approval needed' }
214        : {} // an `allow` too: SASY never skips Claude Code's own permission prompt
215  return {
216    ...decided,
217    ...(isRecord(updatedInput) ? { updatedInput } : {}),
218    ...(typeof note === 'string' && note !== '' ? { additionalContext: [note] } : {}),
219  }
220}
221
222/** SASY's denial, keeping the context notes other hooks added to the call. */
223export function denyWith(ours: PreToolUseResult & { deny: string }, theirs: PreToolUseResult): PreToolUseResult {
224  const context = [...(ours.additionalContext ?? []), ...(theirs.additionalContext ?? [])]
225  return { deny: ours.deny, ...(context.length === 0 ? {} : { additionalContext: context }) }
226}
227
228/** One answer from several PreToolUse deciders: deny over ask over allow. */
229export function combine(ours: PreToolUseResult, theirs: PreToolUseResult): PreToolUseResult {
230  const context = [...(ours.additionalContext ?? []), ...(theirs.additionalContext ?? [])]
231  // SASY's rewrite is part of what it authorised, so it wins over another
232  // hook's rewrite of the same call.
233  const updatedInput = ours.updatedInput ?? theirs.updatedInput
234  if (theirs.deny !== undefined) return denyWith({ ...theirs, deny: theirs.deny }, ours)
235  const extra = {
236    ...(updatedInput === undefined ? {} : { updatedInput }),
237    ...(context.length === 0 ? {} : { additionalContext: context }),
238  }
239  if (ours.ask !== undefined) return { ask: ours.ask, ...extra }
240  if (theirs.ask !== undefined) return { ask: theirs.ask, ...extra }
241  if (theirs.allow === true) return { allow: true, ...extra }
242  return extra
243}
244
245/** curl exits that mean the daemon did not answer: could not connect (7),
246 *  partial reply (18), timed out (28), empty reply (52), the connection dropped
247 *  while sending (55) or receiving (56). */
248export const UNREACHABLE_CURL_EXITS = [7, 18, 28, 52, 55, 56]
249
250/** A check's reply: the daemon's body, or why there is none and of what kind. */
251export type CheckReply = { body: string } | { error: string; kind: 'unreachable' | 'auth' | 'answer' }
252
253/** What a check's curl run came to. Only a daemon that is down or not
254 *  answering is "unreachable" (the one failure SASY_FAIL_OPEN covers); curl
255 *  failing otherwise, as on an auth header file it cannot read, is not. */
256export function replyOf(ran: { exitCode: number; stdout: string }, port: string): CheckReply {
257  if (ran.exitCode !== 0) {
258    const kind = UNREACHABLE_CURL_EXITS.includes(ran.exitCode) ? 'unreachable' : 'answer'
259    return { error: `curl exit ${ran.exitCode} on port ${port}`, kind }
260  }
261  const { body, status } = splitStatus(ran.stdout)
262  if (status === '200') return { body }
263  const kind = status === '401' || status === '403' ? 'auth' : 'answer'
264  return { error: `sasy-watch answered HTTP ${status}`, kind }
265}
266
267/** A command whose run fails sooner than this never started. */
268export const SPAWN_FAILURE_MS = 2000
269
hooks/text.ts 213 lines
1// Pure text helpers: what the mod draws, prints and records, held to the
2// shapes the daemon sends and stripped of what could disguise it. Split from
3// the hooks module, which keeps everything that talks to Claude Code.
4import type { PreToolUseResult } from 'claude-code'
5
6import type { GuardCounts, GuardDecision, GuardVerdict } from '../types'
7import type { BypassOffer } from './enforce'
8
9/** The most characters of a call's target the band and /guard show. */
10export const TARGET_CHARS = 80
11/** Where the policy's own words start in a hook's text. */
12export const MARKER = '[SASY]'
13const REASON_CHARS = 4000
14
15/** What the model is told at SessionStart, as the hook plugin's script says it. */
16export const SESSION_NOTE =
17  'SASY policy enforcement is active for this session. Tool calls are checked against ' +
18  'a security policy; denied calls return a [SASY] reason — relay it to the user and ' +
19  'follow its suggested fix rather than retrying or working around it.'
20
21/** The tool-call fields that name what a call acts on, in order of preference. */
22export const TARGET_FIELDS = ['command', 'file_path', 'notebook_path', 'url', 'path', 'pattern']
23
24/** Control and format characters (C0 and C1 controls other than newline and
25 *  tab, bidirectional marks, tags), every default-ignorable code point (zero-
26 *  width characters, variation selectors, fillers that draw as nothing), and
27 *  the blank Braille pattern: whatever could make a drawn command or path look
28 *  like a different one or hide it. */
29export const CONTROL =
30  /(?![\n\t])[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}\u2800]/gu
31
32export function shorten(text: string, max: number): string {
33  const flat = text.replace(/\s+/g, ' ').replace(CONTROL, '').trim()
34  return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
35}
36
37export function targetOf(e: Readonly<Record<string, unknown>>): string {
38  for (const field of TARGET_FIELDS) {
39    const value = e[field]
40    if (typeof value === 'string' && value !== '') return shorten(value, TARGET_CHARS)
41  }
42  return ''
43}
44
45/**
46 * A policy reason as the band and /guard show it: control characters other
47 * than newline and tab removed, and held to REASON_CHARS. The model has already
48 * read the same text as the call's result; this keeps the session's own copy
49 * small and free of terminal escapes.
50 */
51export function cleanReason(text: string): string {
52  const clean = text.replace(CONTROL, '').trim()
53  return clean.length <= REASON_CHARS ? clean : `${clean.slice(0, REASON_CHARS - 1)}…`
54}
55
56/** The sasy-guard verdict in a PreToolUse result, or none for any other. */
57export function verdictOf(result: PreToolUseResult): { verdict: GuardVerdict; reason: string } | null {
58  const pairs: [GuardVerdict, string | undefined][] = [
59    ['deny', result.deny],
60    ['ask', result.ask],
61  ]
62  for (const [verdict, text] of pairs) {
63    // The engine may wrap the hook's text (`PreToolUse:Bash hook error: ...`);
64    // the policy's own words start at the marker.
65    const at = text?.indexOf(MARKER) ?? -1
66    if (text !== undefined && at >= 0) {
67      return { verdict, reason: cleanReason(text.slice(at + MARKER.length)) }
68    }
69  }
70  return null
71}
72
73export function statusText(c: GuardCounts): string {
74  return `${c.checked} checked · ${c.denied} denied · ${c.asked} asked`
75}
76
77export function clockTime(ms: number): string {
78  return new Date(ms).toTimeString().slice(0, 8)
79}
80
81/** What the dialog's buttons say, by the daemon's canonical choice. */
82export function choiceLabels(offer: BypassOffer): Record<string, string> {
83  return {
84    approve: 'Approve once',
85    decline: 'Deny',
86    ...(offer.domain === undefined ? {} : { 'trust-domain': `Trust ${offer.domain} for this session` }),
87  }
88}
89
90/**
91 * Text that must keep its end: the question ends with what is being approved
92 * (`Attempted: ...`) and the policy reason with its fix. Control characters
93 * are removed as for every reason; past REASON_CHARS the middle goes.
94 */
95function cleanKeepingEnd(text: string): string {
96  const clean = text.replace(CONTROL, '').trim()
97  if (clean.length <= REASON_CHARS) return clean
98  const tail = Math.floor(REASON_CHARS * 0.6)
99  return `${clean.slice(0, REASON_CHARS - tail - 3)} … ${clean.slice(-tail)}`
100}
101
102/** The offer's daemon-authored texts as the mod draws every reason. */
103export function cleanOffer(offer: BypassOffer): BypassOffer {
104  return {
105    ...offer,
106    question: cleanKeepingEnd(offer.question),
107    reason: cleanReason(offer.reason),
108    policyReason: cleanKeepingEnd(offer.policyReason),
109  }
110}
111
112/** The /healthz fields /guard prints, each held to the shape the daemon sends. */
113/** An endpoint /guard may print: a DNS host name, an IPv4 address or a
114 *  bracketed IPv6 address, and a port. Anything else is not printed. */
115const ENDPOINT =
116  /^(?=.{1,259}$)([A-Za-z0-9-]{1,63}(\.[A-Za-z0-9-]{1,63})*|\[[0-9a-fA-F:]{2,39}\]):\d{1,5}$/
117const FAIL_MODES = ['open', 'closed']
118
119/** One decision for /guard: a heading, then its reason, whole or first line. */
120export function decisionLines(d: GuardDecision, isWhole: boolean): string[] {
121  const head = `  ${clockTime(d.at)}  ${d.verdict.padEnd(8)}  ${d.tool}  ${d.target}`
122  const reason = d.reason.split('\n').filter(line => line.trim() !== '')
123  const body = isWhole ? reason : reason.slice(0, 1).map(line => shorten(line, 120))
124  return [head.trimEnd(), ...body.map(line => `            ${line}`)]
125}
126
127/** The line /guard prints for a /healthz answer (curl's output, the HTTP
128 *  status on its last line): only values in the daemon's own shapes. */
129export function healthLine(url: string, stdout: string): string {
130  const cut = stdout.lastIndexOf('\n')
131  const status = stdout.slice(cut + 1)
132  if (!/^[0-9]{3}$/.test(status)) return `daemon: ${url} gave no HTTP status`
133  if (status !== '200') return `daemon: ${url} answered HTTP ${status}`
134  let h: unknown
135  try {
136    h = JSON.parse(stdout.slice(0, Math.max(cut, 0)))
137  } catch {
138    return `daemon: ${url} answered with a body that is not JSON`
139  }
140  const r = (typeof h === 'object' && h !== null ? h : {}) as Record<string, unknown>
141  const isDaemon =
142    r.ok === true &&
143    typeof r.ready === 'boolean' &&
144    typeof r.endpoint === 'string' &&
145    ENDPOINT.test(r.endpoint) &&
146    typeof r.failMode === 'string' &&
147    FAIL_MODES.includes(r.failMode) &&
148    Number.isInteger(r.sessions) &&
149    (r.sessions as number) >= 0
150  if (!isDaemon) return `daemon: ${url} answered, but not as the sasy-watch daemon`
151  const state = r.ready ? 'up, policy engine ready' : 'up, policy engine not ready'
152  return (
153    `daemon: ${state} · endpoint ${r.endpoint} · ` +
154    `fail mode ${r.failMode} · ${r.sessions} session(s)`
155  )
156}
157
158/** The `additionalContext` a daemon answer carries, if any. */
159export function contextOf(answer: string | undefined): string[] {
160  if (answer === undefined) return []
161  try {
162    const out = JSON.parse(answer) as { hookSpecificOutput?: { additionalContext?: unknown } }
163    const note = out.hookSpecificOutput?.additionalContext
164    return typeof note === 'string' && note !== '' ? [note] : []
165  } catch {
166    return []
167  }
168}
169
170/** The band shows the policy's reason and fix; /guard has the rest. */
171const BAND_REASON_LINES = 3
172
173/** How the band draws a decision: its verb and colour, and the reason lines
174 *  that fit (the heading, a possible overflow line and the button take the
175 *  other rows). */
176export function bandLines(
177  d: GuardDecision,
178  maxRows: number,
179): { verb: string; color: string; shown: string[] } {
180  const verb = {
181    deny: 'denied',
182    ask: 'needs approval for',
183    approved: 'asked you, and you allowed',
184    declined: 'asked you, and blocked',
185  }[d.verdict]
186  const color = { deny: 'red', ask: 'yellow', approved: 'green', declined: 'red' }[d.verdict]
187  const room = Math.max(1, Math.min(BAND_REASON_LINES, maxRows - 3))
188  const reason = d.reason.split('\n').filter(line => line.trim() !== '')
189  const shown = reason.slice(0, room)
190  if (reason.length > room) shown.push('… full text: /guard')
191  return { verb, color, shown }
192}
193
194/** The decisions /guard lists, newest first. */
195const RECENT_IN_COMMAND = 5
196
197/** What /guard prints: the daemon's health line, this session's totals, and
198 *  the most recent decisions, the newest in full. */
199export function guardText(health: string, c: GuardCounts, decisions: GuardDecision[]): string {
200  const recent = decisions.slice(-RECENT_IN_COMMAND).reverse()
201  const lines = [health, `this session: ${c.checked} checked · ${c.denied} denied · ${c.asked} asked`]
202  if (recent.length === 0) lines.push('no denials or approval requests yet')
203  else lines.push('recent decisions (newest first):', ...recent.flatMap((d, i) => decisionLines(d, i === 0)))
204  return lines.join('\n')
205}
206
207/** What the mod's own dialog came to, for the record: the offer the user
208 *  answered (its reason) and whether they approved. */
209export type DialogRecord = { verdict: GuardVerdict; reason: string }
210/** What became of one approval dialog: the call's result and what to record
211 *  (absent when the outcome is an ordinary SASY denial). */
212export type DialogOutcome = { result: PreToolUseResult; record?: DialogRecord }
213
types/index.d.ts 45 lines
1/** The verdict sasy-guard reached on one tool call. */
2/** What sasy-guard decided about a call: denied, asking (Claude Code's own
3 *  prompt), or, for the mod's own approval dialog, what the user chose. */
4export type GuardVerdict = 'deny' | 'ask' | 'approved' | 'declined'
5
6/** One tool call that sasy-guard denied or held for the user's approval. */
7export type GuardDecision = {
8  /** Increases by one per decision in the session; keys the band. */
9  seq: number
10  /** Milliseconds since the epoch, from `$.clock.now()`. */
11  at: number
12  tool: string
13  /** The command, path or URL the call acted on, shortened for display. */
14  target: string
15  verdict: GuardVerdict
16  /** The reason the policy gave, with the `[SASY]` prefix removed. */
17  reason: string
18}
19
20/** The session facts a check needs from classic.SessionStart. */
21export type GuardSessionInfo = { transcriptPath: string | null; agentType: string | null }
22
23/** Per-session totals over every tool call sasy-guard checked. */
24export type GuardCounts = { checked: number; denied: number; asked: number }
25
26declare module 'claude-code' {
27  interface PluginState {
28    'sasy-guard-mod': {
29      counts: GuardCounts
30      /** The newest decisions, oldest first, capped in the hooks module. */
31      decisions: GuardDecision[]
32      /** The `seq` of the decision the user dismissed from the band. */
33      dismissedSeq: number
34      /** What classic.SessionStart said about the session, or null before it
35       *  fired: the transcript file (sent with each check so a restarted daemon
36       *  can rebuild the session) and the agent type of a session started with
37       *  --agent. The mod checks a call only once it knows these. */
38      sessionInfo: GuardSessionInfo | null
39      /** Written just before compaction; still there after it unless the
40       *  compaction cleared the mod's values. 0 until the first compaction. */
41      compactMark: number
42    }
43  }
44}
45