@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.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +22 -0
- package/CHANGELOG.md +245 -0
- package/LICENSE +201 -0
- package/README.md +468 -0
- package/agents/.gitkeep +0 -0
- package/agents/implementer.md +60 -0
- package/agents/retro.md +88 -0
- package/agents/reviewer.md +55 -0
- package/agents/scout.md +60 -0
- package/bin/tyran.mjs +172 -0
- package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
- package/hooks/hooks.json +83 -0
- package/hooks/scripts/.gitkeep +0 -0
- package/hooks/scripts/evidence-gate.mjs +705 -0
- package/hooks/scripts/hook-io.mjs +813 -0
- package/hooks/scripts/policy-gate.mjs +1402 -0
- package/hooks/scripts/pre-compact.mjs +211 -0
- package/hooks/scripts/retro-gate.mjs +191 -0
- package/hooks/scripts/secrets-gate.mjs +1683 -0
- package/hooks/scripts/session-start.mjs +358 -0
- package/hooks/scripts/write-guard.mjs +475 -0
- package/package.json +52 -0
- package/scripts/desc-budget.mjs +139 -0
- package/scripts/doctor.mjs +1267 -0
- package/scripts/hooks-check.mjs +1312 -0
- package/scripts/invisible.mjs +346 -0
- package/scripts/journal.mjs +747 -0
- package/scripts/project.mjs +981 -0
- package/scripts/scan-control-chars.mjs +547 -0
- package/scripts/scan-repo.mjs +287 -0
- package/scripts/schema.mjs +467 -0
- package/scripts/stop-check.mjs +89 -0
- package/scripts/tiers.mjs +324 -0
- package/scripts/yaml-lite.mjs +383 -0
- package/skills/browser-check/SKILL.md +118 -0
- package/skills/code-review/SKILL.md +83 -0
- package/skills/deslop/SKILL.md +97 -0
- package/skills/doctor/SKILL.md +35 -0
- package/skills/fidelity-gate/SKILL.md +132 -0
- package/skills/hello/SKILL.md +16 -0
- package/skills/pr-feedback/SKILL.md +85 -0
- package/skills/prompt-tuning/SKILL.md +102 -0
- package/skills/retro/SKILL.md +69 -0
- package/skills/root-cause/SKILL.md +89 -0
- package/skills/run/SKILL.md +285 -0
- package/skills/setup/SKILL.md +79 -0
- package/skills/skill-writing/SKILL.md +99 -0
- package/skills/status/SKILL.md +31 -0
- package/templates/.gitkeep +0 -0
- package/templates/config.yaml +44 -0
- package/templates/knowledge.yaml +23 -0
- package/templates/policies/autonomy.yaml +61 -0
- 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
|
+
}
|