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

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).
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.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.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.classic.PostToolUse): the daemon's post-tool signal (/v1/posttooluse), as the hook plugin sends it./v1/session/end).[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).
/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.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.curl, which must be on PATH.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 checkouthooks/register.tsx 999 lines1// 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}
999hooks/agents.ts 102 lines1// 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}
102hooks/carry.ts 36 lines1// 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}
36hooks/feed.ts 323 lines1// 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}
323hooks/enforce.ts 269 lines1// 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
269hooks/text.ts 213 lines1// 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 }
213types/index.d.ts 45 lines1/** 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