@jjanczur/tyran 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/CHANGELOG.md +245 -0
  4. package/LICENSE +201 -0
  5. package/README.md +468 -0
  6. package/agents/.gitkeep +0 -0
  7. package/agents/implementer.md +60 -0
  8. package/agents/retro.md +88 -0
  9. package/agents/reviewer.md +55 -0
  10. package/agents/scout.md +60 -0
  11. package/bin/tyran.mjs +172 -0
  12. package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
  13. package/hooks/hooks.json +83 -0
  14. package/hooks/scripts/.gitkeep +0 -0
  15. package/hooks/scripts/evidence-gate.mjs +705 -0
  16. package/hooks/scripts/hook-io.mjs +813 -0
  17. package/hooks/scripts/policy-gate.mjs +1402 -0
  18. package/hooks/scripts/pre-compact.mjs +211 -0
  19. package/hooks/scripts/retro-gate.mjs +191 -0
  20. package/hooks/scripts/secrets-gate.mjs +1683 -0
  21. package/hooks/scripts/session-start.mjs +358 -0
  22. package/hooks/scripts/write-guard.mjs +475 -0
  23. package/package.json +52 -0
  24. package/scripts/desc-budget.mjs +139 -0
  25. package/scripts/doctor.mjs +1267 -0
  26. package/scripts/hooks-check.mjs +1312 -0
  27. package/scripts/invisible.mjs +346 -0
  28. package/scripts/journal.mjs +747 -0
  29. package/scripts/project.mjs +981 -0
  30. package/scripts/scan-control-chars.mjs +547 -0
  31. package/scripts/scan-repo.mjs +287 -0
  32. package/scripts/schema.mjs +467 -0
  33. package/scripts/stop-check.mjs +89 -0
  34. package/scripts/tiers.mjs +324 -0
  35. package/scripts/yaml-lite.mjs +383 -0
  36. package/skills/browser-check/SKILL.md +118 -0
  37. package/skills/code-review/SKILL.md +83 -0
  38. package/skills/deslop/SKILL.md +97 -0
  39. package/skills/doctor/SKILL.md +35 -0
  40. package/skills/fidelity-gate/SKILL.md +132 -0
  41. package/skills/hello/SKILL.md +16 -0
  42. package/skills/pr-feedback/SKILL.md +85 -0
  43. package/skills/prompt-tuning/SKILL.md +102 -0
  44. package/skills/retro/SKILL.md +69 -0
  45. package/skills/root-cause/SKILL.md +89 -0
  46. package/skills/run/SKILL.md +285 -0
  47. package/skills/setup/SKILL.md +79 -0
  48. package/skills/skill-writing/SKILL.md +99 -0
  49. package/skills/status/SKILL.md +31 -0
  50. package/templates/.gitkeep +0 -0
  51. package/templates/config.yaml +44 -0
  52. package/templates/knowledge.yaml +23 -0
  53. package/templates/policies/autonomy.yaml +61 -0
  54. package/templates/project-command/SKILL.md +16 -0
@@ -0,0 +1,813 @@
1
+ /**
2
+ * hook-io — the runtime every Tyran hook runs inside.
3
+ *
4
+ * This is not a stdin parser. It is a security component, because of one
5
+ * measured property of the platform (ADR-22): **Claude Code fails OPEN.**
6
+ * A hook that crashes, times out, prints garbage, or is missing entirely
7
+ * does not block anything — the action proceeds with a small "hook error"
8
+ * note. So the worse a gate behaves, the less it gates. An attacker does
9
+ * not need to defeat a gate's logic; breaking the gate is enough.
10
+ *
11
+ * Everything below exists to remove the ways a gate can fail quietly.
12
+ *
13
+ * Measured against the local install (v2.1.116, binary disassembled at
14
+ * `hooks/HOOK-CONTRACT-MEASURED.md`), not assumed:
15
+ *
16
+ * - stdout that does not parse as the hook-output schema is discarded and
17
+ * the action proceeds. Our JSON must always be valid AND schema-shaped.
18
+ * - `hookSpecificOutput.hookEventName` must EQUAL the event that fired, or
19
+ * the platform throws while reading our output — which fails open.
20
+ * - `hookSpecificOutput` has no variant for Stop / SubagentStop /
21
+ * PreCompact / TaskCompleted. Emitting one there fails the schema, so
22
+ * those events refuse through top-level `decision` + `reason` instead.
23
+ * Two shapes, one call site: `deny(reason)`.
24
+ * - the 10 000 limit is applied as `String.prototype.length`, i.e. UTF-16
25
+ * code units, so that is what we count.
26
+ * - `permissionDecision: "allow"` is NOT "no objection" — it AUTO-APPROVES
27
+ * the tool call and skips the permission prompt. There is deliberately no
28
+ * way to emit it from this runtime; see `PASS`.
29
+ */
30
+ import { writeSync } from 'node:fs';
31
+
32
+ import { formatCodePoint, scanText } from '../../scripts/scan-control-chars.mjs';
33
+
34
+ /**
35
+ * The platform's cap on hook stdout and on `additionalContext`, measured as
36
+ * `text.length <= 10000` — UTF-16 code units, not code points. Oversize
37
+ * output is not rejected: it is persisted to a temp file and replaced by a
38
+ * reference, which for injected context means the content silently stops
39
+ * being context. Staying under the limit is the only way to stay read.
40
+ */
41
+ export const OUTPUT_LIMIT = 10000;
42
+
43
+ /** Refuse absurd stdin rather than buffering it. 1 MB is ~100x a real payload. */
44
+ export const MAX_INPUT_BYTES = 1024 * 1024;
45
+
46
+ /**
47
+ * What each event can do — a property of the TYPE, not of a comment.
48
+ *
49
+ * `canBlock: false` events are probes: the platform gives them no way to
50
+ * refuse. Registering a gate on one produces a control that looks like it
51
+ * works and cannot say no, which is the most dangerous defect this system
52
+ * can contain, so `runGate` refuses the registration outright.
53
+ *
54
+ * `refusal` names the OUTPUT SHAPE, which differs per event and is checked
55
+ * by the platform's schema:
56
+ * 'permissionDecision' -> hookSpecificOutput.permissionDecision = 'deny'
57
+ * 'decision' -> top-level decision:'block' + reason
58
+ *
59
+ * `context` says whether the event accepts `hookSpecificOutput.additional-
60
+ * Context`. Where it is null the platform's schema has no variant for the
61
+ * event and emitting one makes the whole output invalid (fail open).
62
+ *
63
+ * Prototype-free, so an event called `constructor` cannot resolve to an
64
+ * inherited member (same reason as doctor.mjs SEVERITY_BY_CODE).
65
+ */
66
+ export const EVENTS = Object.freeze(
67
+ Object.assign(Object.create(null), {
68
+ // Gates — refusal is the product.
69
+ PreToolUse: Object.freeze({ canBlock: true, refusal: 'permissionDecision', context: true }),
70
+ UserPromptSubmit: Object.freeze({ canBlock: true, refusal: 'decision', context: true }),
71
+ Stop: Object.freeze({ canBlock: true, refusal: 'decision', context: false }),
72
+ SubagentStop: Object.freeze({ canBlock: true, refusal: 'decision', context: false }),
73
+ PreCompact: Object.freeze({ canBlock: true, refusal: 'decision', context: false }),
74
+ // TaskCompleted CAN refuse, but only fires in TEAM mode: the platform
75
+ // raises it for the in-progress tasks of the current teammate. In
76
+ // subagent mode it never fires at all, so a check placed only here is
77
+ // an absent control, not a weak one. Flagged in the type rather than in
78
+ // prose, because that is the whole point of this table.
79
+ TaskCompleted: Object.freeze({
80
+ canBlock: true,
81
+ refusal: 'decision',
82
+ context: false,
83
+ teamModeOnly: true,
84
+ }),
85
+ // Probes — injection or record only; refusal is impossible.
86
+ SessionStart: Object.freeze({ canBlock: false, refusal: null, context: true }),
87
+ SubagentStart: Object.freeze({ canBlock: false, refusal: null, context: true }),
88
+ PostToolUse: Object.freeze({ canBlock: false, refusal: null, context: true }),
89
+ Notification: Object.freeze({ canBlock: false, refusal: null, context: true }),
90
+ SessionEnd: Object.freeze({ canBlock: false, refusal: null, context: false }),
91
+ }),
92
+ );
93
+
94
+ /** True for an event this runtime knows how to answer. */
95
+ export function isKnownEvent(name) {
96
+ return typeof name === 'string' && Object.hasOwn(EVENTS, name);
97
+ }
98
+
99
+ /** True for an event on which a refusal is possible at all. */
100
+ export function canBlock(name) {
101
+ return isKnownEvent(name) && EVENTS[name].canBlock === true;
102
+ }
103
+
104
+ /**
105
+ * Registering a gate on an event that cannot refuse. Its own class because
106
+ * it must be assertable in a test: this is the failure that produces a
107
+ * control which passes review and cannot say no.
108
+ */
109
+ export class GateOnProbeEventError extends Error {
110
+ constructor(event) {
111
+ super(
112
+ `cannot register a gate on "${event}": the platform gives this event no way to refuse. ` +
113
+ 'Move the check to PreToolUse, SubagentStop, Stop, PreCompact, UserPromptSubmit or ' +
114
+ 'TaskCompleted, or declare it a probe with runProbe().',
115
+ );
116
+ this.name = 'GateOnProbeEventError';
117
+ this.event = event;
118
+ }
119
+ }
120
+
121
+ /** An input this runtime refused to trust. `errorClass` reaches the operator. */
122
+ export class HookInputError extends Error {
123
+ constructor(errorClass, message, fix) {
124
+ super(message);
125
+ this.name = 'HookInputError';
126
+ this.errorClass = errorClass;
127
+ this.fix = fix;
128
+ }
129
+ }
130
+
131
+ // ------------------------------------------------------------ sanitization
132
+
133
+ /**
134
+ * Everything that reaches our stdout passes through here first.
135
+ *
136
+ * `tool_input` is written by the model and by repo content, and a refusal
137
+ * reason quotes it back. Without this, a gate's own denial message is an
138
+ * injection channel into the transcript: an unterminated bidi override in a
139
+ * quoted path reverses everything the operator reads after it, and a NUL
140
+ * makes the surrounding text invisible to every tool that greps it later.
141
+ *
142
+ * Forbidden codepoints become their ESCAPE NOTATION, not nothing. Silent
143
+ * removal would make a poisoned string and a clean one render identically —
144
+ * the same class of defect as a silent exemption in the scanner.
145
+ */
146
+ export function sanitizeForOutput(value) {
147
+ const text = typeof value === 'string' ? value : String(value);
148
+ // The membership decision is NOT made here. `scanText` is the repo's one
149
+ // implementation of "which codepoints are forbidden", and this asks it
150
+ // rather than re-deriving the answer from its data — ADR-19 correction 1
151
+ // counted three spellings of that rule and found the outermost one the
152
+ // weakest. There is now no fourth. When the scanner's set grows, so does
153
+ // this, with no edit here.
154
+ const findings = scanText(text);
155
+ if (findings.length === 0) return text;
156
+ const forbidden = new Set(findings.map((f) => f.codePoint));
157
+ let out = '';
158
+ for (const ch of text) {
159
+ const cp = ch.codePointAt(0);
160
+ out += forbidden.has(cp) ? `<${formatCodePoint(cp)}>` : ch;
161
+ }
162
+ return out;
163
+ }
164
+
165
+ // ---------------------------------------------------------------- payloads
166
+
167
+ /** The refusal payload for `event`, in the shape that event's schema accepts. */
168
+ export function refusalPayload(event, reason) {
169
+ const meta = EVENTS[event];
170
+ if (meta === undefined || meta.refusal === null) {
171
+ throw new GateOnProbeEventError(String(event));
172
+ }
173
+ if (meta.refusal === 'permissionDecision') {
174
+ return {
175
+ hookSpecificOutput: {
176
+ hookEventName: event,
177
+ permissionDecision: 'deny',
178
+ permissionDecisionReason: reason,
179
+ },
180
+ };
181
+ }
182
+ return { decision: 'block', reason };
183
+ }
184
+
185
+ /** The context-injection payload for `event`, or `{}` where it accepts none. */
186
+ export function contextPayload(event, additionalContext) {
187
+ const meta = EVENTS[event];
188
+ if (meta === undefined || meta.context !== true) return {};
189
+ return { hookSpecificOutput: { hookEventName: event, additionalContext } };
190
+ }
191
+
192
+ /**
193
+ * `PASS` is "I have no objection", and it is an EMPTY object on purpose.
194
+ *
195
+ * The obvious spelling would be `permissionDecision: "allow"`, and it would
196
+ * be a serious bug: measured in the platform, `allow` does not mean "this
197
+ * gate is satisfied", it means "approve this tool call and skip the
198
+ * permission prompt". A gate whose only job is to look for secrets would
199
+ * then be silently auto-approving every command it happened not to object
200
+ * to. There is no way to emit `allow` from this runtime, and a test pins
201
+ * that.
202
+ */
203
+ export const PASS = Object.freeze({ decision: 'pass' });
204
+
205
+ // --------------------------------------------------------------- truncation
206
+
207
+ const TRUNCATION_NOTE = (omitted, total) =>
208
+ `\n[tyran hook-io: output truncated to fit the platform's ${OUTPUT_LIMIT}-character limit; ` +
209
+ `${omitted} of ${total} characters omitted]`;
210
+
211
+ /** UTF-16 length of the serialized payload — the unit the platform counts. */
212
+ function serializedLength(payload) {
213
+ return JSON.stringify(payload).length;
214
+ }
215
+
216
+ /**
217
+ * Fit `text` into `build(text)` so the SERIALIZED payload stays under the
218
+ * platform limit, deterministically and audibly.
219
+ *
220
+ * Two properties matter more than the arithmetic:
221
+ *
222
+ * - it clamps the SERIALIZED form, not the raw string. JSON escaping can
223
+ * triple a length, so a reason measured before encoding sails past the
224
+ * limit after it and the whole output is discarded (fail open).
225
+ * - what is left always SAYS it was cut, and by how much. A quietly
226
+ * shortened state file is the same defect as a quietly skipped file in
227
+ * ADR-19: the reader has no way to know the thing they are trusting is
228
+ * partial.
229
+ *
230
+ * Cuts land on code-point boundaries, so a truncated payload can never end
231
+ * in half a surrogate pair (which would make the JSON lone-surrogate and
232
+ * unparseable — fail open again).
233
+ */
234
+ export function clampPayload(build, text, limit = OUTPUT_LIMIT) {
235
+ const full = build(text);
236
+ if (serializedLength(full) <= limit) return { payload: full, omitted: 0 };
237
+
238
+ const points = [...text];
239
+ const total = points.length;
240
+ const fits = (keep) =>
241
+ serializedLength(build(points.slice(0, keep).join('') + TRUNCATION_NOTE(total - keep, total))) <=
242
+ limit;
243
+
244
+ // Adding one kept code point never shortens the result by more than the
245
+ // one digit the omission counter may lose, so the predicate is monotone
246
+ // and a binary search is exact rather than approximate.
247
+ let lo = 0;
248
+ let hi = total;
249
+ if (!fits(0)) {
250
+ // Even the note alone does not fit: the envelope itself is oversized.
251
+ // Say so in the smallest possible words rather than emitting nothing.
252
+ return { payload: build(`[tyran hook-io: output too large to report]`), omitted: total };
253
+ }
254
+ while (lo < hi) {
255
+ const mid = Math.ceil((lo + hi) / 2);
256
+ if (fits(mid)) lo = mid;
257
+ else hi = mid - 1;
258
+ }
259
+ return {
260
+ payload: build(points.slice(0, lo).join('') + TRUNCATION_NOTE(total - lo, total)),
261
+ omitted: total - lo,
262
+ };
263
+ }
264
+
265
+ // -------------------------------------------------------------------- input
266
+
267
+ /**
268
+ * Read all of stdin, refusing rather than buffering an unbounded payload.
269
+ * A stream that never ends is handled by the caller's deadline, not here.
270
+ */
271
+ export async function readStdin(stream, { maxBytes = MAX_INPUT_BYTES } = {}) {
272
+ if (stream === undefined || stream === null) return '';
273
+ const chunks = [];
274
+ let size = 0;
275
+ for await (const chunk of stream) {
276
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
277
+ size += buf.length;
278
+ if (size > maxBytes) {
279
+ throw new HookInputError(
280
+ 'input-too-large',
281
+ `hook input exceeded ${maxBytes} bytes`,
282
+ 'this hook reads whole tool inputs; if a legitimate payload is this large, raise MAX_INPUT_BYTES deliberately',
283
+ );
284
+ }
285
+ chunks.push(buf);
286
+ }
287
+ return Buffer.concat(chunks).toString('utf8');
288
+ }
289
+
290
+ /**
291
+ * Parse hook input. Every rejection carries a class, because "the gate said
292
+ * no" without saying which way it broke is unfixable in production.
293
+ */
294
+ export function parseHookInput(raw) {
295
+ if (typeof raw !== 'string' || raw.trim() === '') {
296
+ throw new HookInputError(
297
+ 'empty-input',
298
+ 'hook received no input on stdin',
299
+ 'the platform always writes a JSON object to a hook stdin; an empty read means the hook was invoked by something else',
300
+ );
301
+ }
302
+ let parsed;
303
+ try {
304
+ parsed = JSON.parse(raw);
305
+ } catch (err) {
306
+ throw new HookInputError(
307
+ 'malformed-json',
308
+ `hook input is not valid JSON: ${err.message}`,
309
+ 'check whether a wrapper script is writing to stdout on the way in',
310
+ );
311
+ }
312
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
313
+ throw new HookInputError(
314
+ 'not-an-object',
315
+ `hook input parsed to ${Array.isArray(parsed) ? 'an array' : typeof parsed}, not an object`,
316
+ 'the hook contract is a single JSON object',
317
+ );
318
+ }
319
+ return parsed;
320
+ }
321
+
322
+ /**
323
+ * Own-property read. `JSON.parse` puts a `__proto__` key on the object as an
324
+ * own property rather than walking the prototype chain, but every OTHER
325
+ * lookup on that object still consults `Object.prototype` — so a payload
326
+ * with no `tool_name` at all would otherwise resolve `constructor` or
327
+ * `toString` to a function and let a gate compare against it.
328
+ */
329
+ export function field(object, name) {
330
+ if (object === null || typeof object !== 'object') return undefined;
331
+ return Object.hasOwn(object, name) ? object[name] : undefined;
332
+ }
333
+
334
+ /**
335
+ * The event whose shape our answer must take.
336
+ *
337
+ * The platform throws if `hookSpecificOutput.hookEventName` differs from the
338
+ * event it fired, and that throw is caught upstream as "hook failed" — i.e.
339
+ * it fails open. The input's `hook_event_name` is written by the platform
340
+ * itself, so it is the more reliable of the two names; the declared event is
341
+ * the fallback for input we could not read at all. A gate always answers in
342
+ * a shape that can refuse, so a probe name never wins here.
343
+ */
344
+ export function resolveEvent(declared, fromInput, { mustBlock }) {
345
+ if (isKnownEvent(fromInput)) {
346
+ if (!mustBlock || EVENTS[fromInput].canBlock) return fromInput;
347
+ }
348
+ return declared;
349
+ }
350
+
351
+ // ------------------------------------------------------------------ runners
352
+
353
+ /** How many times a stalled sink may report no progress before we give up. */
354
+ const WRITE_SPIN_LIMIT = 10000;
355
+
356
+ /**
357
+ * Write `text` in full, or throw.
358
+ *
359
+ * `writeSync` returns the number of bytes it actually took, and on a
360
+ * non-blocking pipe — which is what a hook's stdout is when the platform is
361
+ * busy — that can be fewer than we handed it. Ignoring the return value
362
+ * therefore produces a TRUNCATED JSON object, which fails the platform's
363
+ * schema, which discards the whole output, which lets the action through.
364
+ * A refusal cut in half is not a weaker refusal; it is an approval.
365
+ *
366
+ * `sink` is injectable so the loop itself is testable without fiddling with
367
+ * real file descriptors.
368
+ */
369
+ export function writeFully(text, sink) {
370
+ const buf = Buffer.from(text, 'utf8');
371
+ let offset = 0;
372
+ let spins = 0;
373
+ while (offset < buf.length) {
374
+ let written;
375
+ try {
376
+ written = sink(buf, offset, buf.length - offset);
377
+ } catch (err) {
378
+ // A momentarily full pipe is not a failure, it is back-pressure. Retry
379
+ // a bounded number of times rather than spinning forever or reporting
380
+ // a decision we only half wrote.
381
+ // Retried with NO delay, deliberately. Everything written here fits in
382
+ // the platform's 10 000-character budget, which is a handful of
383
+ // syscalls on any real pipe, so sustained back-pressure cannot occur —
384
+ // and a sleep would spend part of a gate's deadline waiting for a
385
+ // condition that is not there. If this ever guards a larger payload,
386
+ // that reasoning stops holding and the retry needs a backoff.
387
+ if ((err?.code === 'EAGAIN' || err?.code === 'EWOULDBLOCK') && spins++ < WRITE_SPIN_LIMIT) {
388
+ continue;
389
+ }
390
+ throw err;
391
+ }
392
+ if (!(written > 0)) {
393
+ if (spins++ >= WRITE_SPIN_LIMIT) {
394
+ throw new Error(`stdout accepted ${offset} of ${buf.length} bytes and then stalled`);
395
+ }
396
+ continue;
397
+ }
398
+ offset += written;
399
+ // Patience is per stall, not per write. Without this reset the counter
400
+ // accumulates across a whole payload, so a long write interleaved with
401
+ // legitimate back-pressure eventually reports a healthy pipe as dead.
402
+ spins = 0;
403
+ }
404
+ return offset;
405
+ }
406
+
407
+ function defaultIo() {
408
+ return {
409
+ stdin: process.stdin,
410
+ write: (text) => writeFully(text, (buf, off, len) => writeSync(1, buf, off, len)),
411
+ warn: (text) => writeFully(text, (buf, off, len) => writeSync(2, buf, off, len)),
412
+ exit: (code) => process.exit(code),
413
+ onExit: (cb) => {
414
+ process.on('exit', cb);
415
+ return () => process.removeListener('exit', cb);
416
+ },
417
+ };
418
+ }
419
+
420
+ function describeError(err) {
421
+ if (err instanceof HookInputError) {
422
+ return { errorClass: err.errorClass, message: err.message, fix: err.fix };
423
+ }
424
+ if (err instanceof Error) {
425
+ return {
426
+ errorClass: err.constructor?.name ?? 'Error',
427
+ message: err.message,
428
+ fix: 'this is a bug in the hook, not in the input; the stack is in the transcript',
429
+ };
430
+ }
431
+ return {
432
+ errorClass: 'non-error-throw',
433
+ message: String(err),
434
+ fix: 'the handler threw a value that is not an Error',
435
+ };
436
+ }
437
+
438
+ function refusalText({ errorClass, message, fix }) {
439
+ return (
440
+ `tyran refused because the gate itself could not finish.\n` +
441
+ `error class: ${errorClass}\n` +
442
+ `detail: ${message}\n` +
443
+ `fix: ${fix}\n` +
444
+ 'This is a refusal, not a crash: an unfinished check must not read as approval.'
445
+ );
446
+ }
447
+
448
+ /**
449
+ * Run a blocking hook.
450
+ *
451
+ * Contract, and the whole reason this file exists:
452
+ *
453
+ * 1. it NEVER throws outward and never exits non-zero. Every unexpected
454
+ * error becomes exit 0 plus a well-formed refusal naming the error
455
+ * class, because every other ending is an approval;
456
+ * 2. it refuses to be registered on an event that cannot refuse;
457
+ * 3. it holds its OWN deadline, shorter than the one in hooks.json. The
458
+ * platform's timeout kills the process and DISCARDS its output, so a
459
+ * gate that is merely slow is a gate that approves.
460
+ *
461
+ * `handler(input)` returns `PASS` or `{ decision: 'deny', reason }`.
462
+ * Anything else is treated as a bug and refuses — an unrecognised return
463
+ * value must not be able to mean "allow".
464
+ *
465
+ * ## What the deadline does and does not promise
466
+ *
467
+ * State it narrowly, because the four gates built on this runtime inherit
468
+ * whatever it claims, and a guarantee wider than its mechanism is worse than
469
+ * no guarantee at all — readers rely on it.
470
+ *
471
+ * - **Enforced.** A handler that yields the event loop and has not decided
472
+ * by `deadlineMs` gets a refusal emitted for it, by the timer.
473
+ * - **Enforced.** A handler that overruns the budget and *then* returns has
474
+ * its verdict DISCARDED and replaced by a refusal. This is the case that
475
+ * actually happens in the field (a gate that greps a large file, or shells
476
+ * out to a scanner, finishes — just late), and a timer alone does not
477
+ * catch it: the timer callback is a macrotask and the handler's return
478
+ * settles in a microtask, so the verdict wins the race.
479
+ * - **NOT enforced.** A handler that blocks the thread and never returns.
480
+ * Node is single-threaded, so nothing on this thread runs while it spins
481
+ * and the platform eventually kills the process.
482
+ *
483
+ * Do not reach for the obvious workaround. Measured on a live run: a hook
484
+ * that wrote a complete, valid refusal to stdout and only THEN blocked
485
+ * past its timeout was ignored, and the tool ran. The kill and the abort
486
+ * are the same event, and the consumer returns on `aborted` BEFORE it
487
+ * parses stdout — the bytes are collected, even logged, and never read.
488
+ * **Emitting earlier buys nothing.** Only making the process actually
489
+ * EXIT before the platform's timeout closes this case, which is why every
490
+ * ending in this file writes and then exits.
491
+ *
492
+ * So the mitigation is a rule for gate authors, in `docs/hooks.md`: a gate
493
+ * does no unbounded synchronous work, and every file it reads is
494
+ * size-checked first. The remaining escape hatch is a hard-killed child
495
+ * process — real latency on every tool call, and nothing measured so far
496
+ * justifies it.
497
+ */
498
+ export async function runGate({ event, handler, deadlineMs, io = defaultIo() }) {
499
+ if (!isKnownEvent(event)) throw new GateOnProbeEventError(String(event));
500
+ if (!EVENTS[event].canBlock) throw new GateOnProbeEventError(event);
501
+ if (!Number.isFinite(deadlineMs) || deadlineMs <= 0) {
502
+ throw new Error(`runGate needs a positive deadlineMs (got ${String(deadlineMs)})`);
503
+ }
504
+
505
+ const startedAt = Date.now();
506
+ /** True once the budget is spent, whether or not anything noticed in time. */
507
+ const overrun = () => Date.now() - startedAt >= deadlineMs;
508
+
509
+ let settled = false;
510
+
511
+ /**
512
+ * The endings that are NOT "exit 0 with a decision".
513
+ *
514
+ * Reaching here means the decision exists but the channel for it does not.
515
+ * Exit 2 with the reason on stderr is then the only remaining ending that
516
+ * still blocks on a blocking event — strictly better than exiting 0 with
517
+ * nothing on stdout, which is the quietest possible approval.
518
+ */
519
+ const loudFailure = (text) => {
520
+ settled = true;
521
+ try {
522
+ io.warn(text.endsWith('\n') ? text : `${text}\n`);
523
+ } catch {
524
+ /* stderr is gone too; there is nothing left to do but the exit code */
525
+ }
526
+ io.exit(2);
527
+ };
528
+
529
+ /**
530
+ * A failed write is caught HERE and converted into a loud ending.
531
+ *
532
+ * The round-1 version was `if (done) return; done = true; io.write(...)`
533
+ * with no catch, and it opened a third silent path: a throwing write
534
+ * propagated out, the outer catch called a refusal, the refusal called
535
+ * `emit`, `emit` saw the flag already set and returned, the exit guard saw
536
+ * the same flag and returned — and the process ended with exit 0, an empty
537
+ * stdout and an empty stderr. The quietest possible approval.
538
+ *
539
+ * The catch is the guard; MUST-PASS 2 pins it. Setting `settled` only after
540
+ * a successful write is the secondary, defensive half — and it is honestly
541
+ * an EQUIVALENT change while the catch is present, which mutant M18 of
542
+ * round 2 demonstrated by surviving. It is kept because it is free and
543
+ * because it is the correct order if the catch is ever refactored away, not
544
+ * because a test proves it.
545
+ *
546
+ * What this can and cannot see, measured: a broken pipe (the platform died
547
+ * mid-hook) raises `EPIPE` and lands here, and the process ends with exit 2
548
+ * carrying the reason on stderr. A stdout that was CLOSED before exec
549
+ * cannot be detected at all — Node reopens a closed fd 1 onto /dev/null at
550
+ * startup, so the write succeeds and the bytes are simply gone. No hook can
551
+ * defend against that; it is listed so nobody mistakes the two cases.
552
+ */
553
+ const emit = (payload) => {
554
+ if (settled) return;
555
+ const text = JSON.stringify(payload) + '\n';
556
+ try {
557
+ io.write(text);
558
+ } catch (err) {
559
+ loudFailure(
560
+ `tyran hook-io: could not write its decision to stdout (${err?.code ?? err?.message ?? String(err)}).\n` +
561
+ `The decision was: ${text}`,
562
+ );
563
+ return;
564
+ }
565
+ settled = true;
566
+ io.exit(0);
567
+ };
568
+ const refuse = (outEvent, info) => {
569
+ const { payload } = clampPayload(
570
+ (reason) => refusalPayload(outEvent, reason),
571
+ sanitizeForOutput(refusalText(info)),
572
+ );
573
+ emit(payload);
574
+ };
575
+
576
+ /**
577
+ * Last line of defence: the process is ending and no decision was written.
578
+ *
579
+ * This is not theoretical — it is how the first version of this file failed
580
+ * its own test. The deadline timer was `unref`'d, so when a handler awaited
581
+ * something that did not hold the event loop, Node ran out of work, exited
582
+ * cleanly with empty stdout, and the platform read that as "hook fine,
583
+ * proceed". An unhandled rejection reaches here the same way. Writing
584
+ * synchronously from an `exit` listener is the only thing that still works
585
+ * at that point, which is why the whole output path uses writeSync.
586
+ */
587
+ const silentExitGuard = () => {
588
+ if (settled) return;
589
+ const { payload } = clampPayload(
590
+ (reason) => refusalPayload(event, reason),
591
+ sanitizeForOutput(
592
+ refusalText({
593
+ errorClass: 'exited-without-decision',
594
+ message: 'the hook process ended before the gate produced a verdict',
595
+ fix: 'an unhandled rejection or an empty event loop; the gate must always reach deny or pass',
596
+ }),
597
+ ),
598
+ );
599
+ const text = JSON.stringify(payload) + '\n';
600
+ try {
601
+ io.write(text);
602
+ } catch {
603
+ // Same reasoning as loudFailure, except we are already inside `exit`
604
+ // and must not call process.exit again: set the code instead.
605
+ settled = true;
606
+ try {
607
+ io.warn(`tyran hook-io: ended without being able to write its refusal.\n${text}`);
608
+ } catch {
609
+ /* nothing left */
610
+ }
611
+ process.exitCode = 2;
612
+ return;
613
+ }
614
+ settled = true;
615
+ process.exitCode = 0;
616
+ };
617
+ const releaseGuard = io.onExit ? io.onExit(silentExitGuard) : () => {};
618
+
619
+ let timer = null;
620
+ const deadline = new Promise((resolve) => {
621
+ // Deliberately NOT unref'd: an unref'd deadline stops holding the event
622
+ // loop, and a hook that exits without writing anything is a hook that
623
+ // approves. The timer is cleared the moment a decision is emitted.
624
+ timer = setTimeout(() => {
625
+ refuse(event, {
626
+ errorClass: 'deadline-exceeded',
627
+ message: `the gate did not reach a decision within ${deadlineMs} ms`,
628
+ fix: 'make the check faster, or raise deadlineMs together with the hooks.json timeout above it',
629
+ });
630
+ resolve();
631
+ }, deadlineMs);
632
+ });
633
+
634
+ const work = (async () => {
635
+ let outEvent = event;
636
+ try {
637
+ const raw = await readStdin(io.stdin);
638
+ const input = parseHookInput(raw);
639
+ const named = field(input, 'hook_event_name');
640
+ if (named !== undefined && !isKnownEvent(named)) {
641
+ // Answering an event this runtime does not model means emitting a
642
+ // `hookEventName` the platform rejects, which fails open. There is
643
+ // no decision to write, so the loudest available ending it is: on a
644
+ // blocking event exit 2 blocks, on a probe it surfaces the message.
645
+ loudFailure(
646
+ `tyran hook-io: registered for ${event}, but the platform fired ` +
647
+ `"${sanitizeForOutput(String(named))}", which this runtime does not model. ` +
648
+ 'Refusing to guess an output shape. Fix hooks.json, or add the event to EVENTS.',
649
+ );
650
+ return;
651
+ }
652
+ outEvent = resolveEvent(event, named, { mustBlock: true });
653
+ if (named !== undefined && named !== event) {
654
+ throw new HookInputError(
655
+ 'event-mismatch',
656
+ `this hook is registered for ${event} but the platform fired ${sanitizeForOutput(String(named))}`,
657
+ 'fix the event key in hooks.json; a gate answering for the wrong event is not a gate',
658
+ );
659
+ }
660
+ const verdict = await handler(Object.freeze({ event: outEvent, input }));
661
+ // The budget is checked AFTER the handler returns, not only by a timer.
662
+ // A timer callback is a macrotask; a handler that blocks the thread and
663
+ // then returns settles its promise in a microtask, so `emit` would win
664
+ // the race and a gate that overran its budget fifteenfold would still
665
+ // approve. Measured, not feared — see MUST-PASS 1.
666
+ if (overrun()) {
667
+ throw new HookInputError(
668
+ 'deadline-exceeded',
669
+ `the gate returned a verdict after ${Date.now() - startedAt} ms, past its ${deadlineMs} ms budget`,
670
+ 'a verdict produced after the budget is not a verdict; make the check faster or raise both numbers',
671
+ );
672
+ }
673
+ if (verdict === PASS || (verdict !== null && typeof verdict === 'object' && verdict.decision === 'pass')) {
674
+ // No objection is silence, never `permissionDecision:"allow"` — see PASS.
675
+ emit({});
676
+ return;
677
+ }
678
+ if (verdict !== null && typeof verdict === 'object' && verdict.decision === 'deny') {
679
+ const { payload } = clampPayload(
680
+ (reason) => refusalPayload(outEvent, reason),
681
+ sanitizeForOutput(String(verdict.reason ?? 'refused without a stated reason')),
682
+ );
683
+ emit(payload);
684
+ return;
685
+ }
686
+ throw new Error(
687
+ `handler returned ${JSON.stringify(verdict)}; expected PASS or { decision: 'deny', reason }`,
688
+ );
689
+ } catch (err) {
690
+ refuse(outEvent, describeError(err));
691
+ }
692
+ })();
693
+
694
+ await Promise.race([work, deadline]);
695
+ if (timer !== null) clearTimeout(timer);
696
+ releaseGuard();
697
+ return settled;
698
+ }
699
+
700
+ /**
701
+ * Run a non-blocking hook.
702
+ *
703
+ * A probe has no way to refuse, so failing open here is not a compromise —
704
+ * it is the only honest behaviour, and the cost of getting it wrong is a
705
+ * session the user cannot start. Every failure therefore degrades to the
706
+ * smallest valid output for the event and exit 0.
707
+ *
708
+ * The deadline still exists, for a different reason: a probe that hangs
709
+ * holds up session startup until the platform kills it.
710
+ */
711
+ export async function runProbe({ event, handler, deadlineMs, io = defaultIo() }) {
712
+ if (!isKnownEvent(event)) throw new Error(`unknown hook event: ${String(event)}`);
713
+ if (!Number.isFinite(deadlineMs) || deadlineMs <= 0) {
714
+ throw new Error(`runProbe needs a positive deadlineMs (got ${String(deadlineMs)})`);
715
+ }
716
+
717
+ let done = false;
718
+ /**
719
+ * Symmetric with `runGate.emit`, for one specific reason rather than for
720
+ * tidiness: this is also called from the deadline timer's callback, and an
721
+ * unguarded `io.write` that throws there escapes as an UNHANDLED exception.
722
+ * The user would get a Node stack trace at session start where a one-line
723
+ * note belonged. A probe may fail quietly; it may not fail loudly.
724
+ *
725
+ * Unlike the gate, a failed write here still ends in exit 0. There is no
726
+ * decision to preserve — only context that will not arrive — and a probe
727
+ * must never be the reason a session does not start.
728
+ */
729
+ const emit = (payload) => {
730
+ if (done) return;
731
+ const text = JSON.stringify(payload) + '\n';
732
+ try {
733
+ io.write(text);
734
+ } catch (err) {
735
+ done = true;
736
+ try {
737
+ io.warn(`tyran ${event}: could not write its context (${err?.code ?? err?.message ?? String(err)})\n`);
738
+ } catch {
739
+ /* nothing left to complain with */
740
+ }
741
+ io.exit(0);
742
+ return;
743
+ }
744
+ done = true;
745
+ io.exit(0);
746
+ };
747
+ const note = (text) => {
748
+ // stderr on a probe event is a transcript message, never a block.
749
+ try {
750
+ io.warn(`tyran ${event}: ${sanitizeForOutput(text)}\n`);
751
+ } catch {
752
+ /* a probe may not fail because it could not complain */
753
+ }
754
+ };
755
+
756
+ let timer = null;
757
+ const deadline = new Promise((resolve) => {
758
+ timer = setTimeout(() => {
759
+ note(`gave up after ${deadlineMs} ms; the session continues without injected context`);
760
+ emit({});
761
+ resolve();
762
+ }, deadlineMs);
763
+ });
764
+
765
+ const work = (async () => {
766
+ try {
767
+ const raw = await readStdin(io.stdin);
768
+ const input = parseHookInput(raw);
769
+ const named = field(input, 'hook_event_name');
770
+ const outEvent = resolveEvent(event, named, { mustBlock: false });
771
+ const result = await handler(Object.freeze({ event: outEvent, input }));
772
+ const text = typeof result === 'string' ? result : (result?.additionalContext ?? '');
773
+ if (text === '') {
774
+ emit({});
775
+ return;
776
+ }
777
+ const { payload, omitted } = clampPayload(
778
+ (ctx) => contextPayload(outEvent, ctx),
779
+ sanitizeForOutput(text),
780
+ );
781
+ if (omitted > 0) note(`injected context truncated, ${omitted} character(s) omitted`);
782
+ emit(payload);
783
+ } catch (err) {
784
+ const info = describeError(err);
785
+ note(`${info.errorClass}: ${info.message}`);
786
+ emit({});
787
+ }
788
+ })();
789
+
790
+ await Promise.race([work, deadline]);
791
+ if (timer !== null) clearTimeout(timer);
792
+ return done;
793
+ }
794
+
795
+ /**
796
+ * Entry point for a hook script's `main`. Its one job is the case `runGate`
797
+ * cannot answer for itself: a gate declared on an event that cannot refuse.
798
+ *
799
+ * That throw happens before any input is read, so there is no decision to
800
+ * emit. Exit 2 is the loudest ending available — on a blocking event it
801
+ * blocks, on a probe event it surfaces the message — and it is strictly
802
+ * better than the alternative, which is an unhandled rejection that reads as
803
+ * a small tooling hiccup while the control silently no longer exists.
804
+ */
805
+ export async function main(run, io = defaultIo()) {
806
+ try {
807
+ await run();
808
+ } catch (err) {
809
+ const info = describeError(err);
810
+ io.warn(`tyran hook-io: ${sanitizeForOutput(refusalText(info))}\n`);
811
+ io.exit(2);
812
+ }
813
+ }