@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,1402 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * policy-gate — the autonomy classes stop being a description and become a
4
+ * refusal.
5
+ *
6
+ * ADR-06 splits every artefact into AUTO (the loop edits it itself), GATED
7
+ * (a human approves) and KERNEL (humans only, by hand). Until this file
8
+ * existed that split lived in `.tyran/policies/autonomy.yaml` and in prose:
9
+ * a policy nothing enforces is a comment. This gate enforces it on the
10
+ * PreToolUse event, plus the deployment class from `.tyran/config.yaml`
11
+ * (P1/P2/P3), which decides how far a `git push` may reach.
12
+ *
13
+ * ## What it is built out of, and what it deliberately does not re-derive
14
+ *
15
+ * Three answers already exist in this repository and this file asks them
16
+ * rather than re-spelling them. ADR-21 counted three spellings of one rule
17
+ * here once already; there is no fourth.
18
+ *
19
+ * - **which class a path has** — `scripts/schema.mjs`: `classifyPath`,
20
+ * `normalizePath`, and the unconditional `MANDATORY_KERNEL_PATHS` check
21
+ * that runs BEFORE any rule, so no glob spelling can outrank it;
22
+ * - **what a shell command line does** — `secrets-gate.mjs`'s lexer:
23
+ * segmentation, transparent prefixes, the `cd`/`pushd`/`popd` working
24
+ * directory model, and refusal on anything needing expansion. This gate
25
+ * reads that lexer's OUTPUT and never looks at the command string itself;
26
+ * - **how a refusal may be worded** — `hook-io.mjs` sanitizes every reason,
27
+ * and `elideOpaqueRuns`/`safeRuleName` handle the repository-controlled
28
+ * strings that reach the model's context.
29
+ *
30
+ * ## The two questions this gate had to answer out loud
31
+ *
32
+ * **1. A path no rule matches is a row of the matrix, not a fall-through —
33
+ * and it turned out to be TWO rows.** Inside Tyran's own namespace it takes
34
+ * the policy's `default:` (GATED in the shipped template); outside it the
35
+ * policy has nothing to say and the gate is silent. See `GOVERNED_PREFIXES`
36
+ * for the full argument and the measurement that forced it — the first
37
+ * version of this file applied `default:` everywhere and would have refused
38
+ * an implementer subagent on 65 of the 65 tracked files in this repository.
39
+ * A gate that refuses ordinary work is uninstalled, and then it protects
40
+ * nothing at all; that is the cost side of ADR-19 correction 1, which is
41
+ * otherwise entirely right that "unmatched means allowed" makes a policy
42
+ * protect only what somebody remembered to list.
43
+ *
44
+ * **2. The highest class covers WRITES. Reads are guarded by a separate,
45
+ * narrower rule, and that boundary is stated rather than left to be found.**
46
+ * The trigger was real: a neighbouring project's `.env` was read whole into a
47
+ * conductor session — dozens of live credentials, nobody having asked for the
48
+ * read. The secrets gate defends PUBLICATION and would never have seen it.
49
+ * So a read is refused when the path is secret-SHAPED, for every actor, and
50
+ * AUTO/GATED/KERNEL are not consulted for reads at all. Extending KERNEL to
51
+ * reads instead would have made `hooks/**` unreadable — a gate whose own
52
+ * source cannot be read teaches its user to switch it off. The narrow rule is
53
+ * a denylist and therefore incomplete; `docs/policy-gate.md` says so in those
54
+ * words instead of implying otherwise.
55
+ *
56
+ * ## Failure is refusal
57
+ *
58
+ * Missing policy file, unparseable YAML, a policy the validator rejects, a
59
+ * budget overrun: all deny. A broken policy must never read as "anything
60
+ * goes" (ADR-22). The one exception is deliberate and documented: a repo with
61
+ * no `.tyran/` directory at all has not adopted Tyran, so the path classes
62
+ * have nothing to say there and the gate is silent — except for the secret
63
+ * read rule, which needs no configuration and is what the incident above
64
+ * actually called for.
65
+ */
66
+ import { realpathSync } from 'node:fs';
67
+ import { readFile, stat } from 'node:fs/promises';
68
+ import { basename, dirname, join, resolve as resolvePath } from 'node:path';
69
+ import { fileURLToPath } from 'node:url';
70
+
71
+ import { PASS, field, main, runGate } from './hook-io.mjs';
72
+ import {
73
+ GIT_BUDGET_MS,
74
+ isGitProgram,
75
+ isLiteralPath,
76
+ isLiteralRef,
77
+ makeBudget,
78
+ planCommand,
79
+ runChild,
80
+ shortPath,
81
+ splitSegments,
82
+ tokensOf,
83
+ } from './secrets-gate.mjs';
84
+ import {
85
+ MANDATORY_KERNEL_PATHS,
86
+ classifyPath,
87
+ globMatches,
88
+ normalizePath,
89
+ validatePolicy,
90
+ } from '../../scripts/schema.mjs';
91
+ import { parse } from '../../scripts/yaml-lite.mjs';
92
+
93
+ /** This gate's own budget, half the `timeout` its hooks.json entry declares. */
94
+ export const DEADLINE_MS = 4000;
95
+
96
+ /** Left unspent so a refusal can still be serialized after the last child. */
97
+ export const DEADLINE_MARGIN_MS = 800;
98
+
99
+ /**
100
+ * The most policy this gate will read. A `.tyran/` tree can come from a
101
+ * template someone else wrote, and ADR-22 correction 1 point D is explicit:
102
+ * every file a gate reads is size-checked, because the platform's timeout
103
+ * kills the process and never reads what it wrote.
104
+ */
105
+ export const MAX_POLICY_BYTES = 256 * 1024;
106
+
107
+ /** Where the two files this gate reads live, relative to the repo root. */
108
+ export const POLICY_PATH = '.tyran/policies/autonomy.yaml';
109
+ export const CONFIG_PATH = '.tyran/config.yaml';
110
+ export const TYRAN_DIR = '.tyran';
111
+
112
+ /** Deployment classes, in increasing order of reach. See ADR-06 / config.yaml. */
113
+ export const DEPLOY_CLASSES = Object.freeze(['P1', 'P2', 'P3']);
114
+
115
+ /**
116
+ * A `default:` that is not an artefact class, used to ask `classifyPath`
117
+ * "did any rule match at all?" without a second glob engine.
118
+ *
119
+ * Plain ASCII on purpose. The first spelling of this constant was a string
120
+ * beginning with a space, and the writing tool put a raw NUL byte there
121
+ * instead — after which `grep` reported ZERO matches in this file, with exit
122
+ * status 1 and no message, and `file` called it binary data. That is failure
123
+ * class 1 and ADR-19's opening example, reproduced inside the gate written to
124
+ * enforce them. A constant whose repertoire is `[A-Za-z_]` cannot do it again.
125
+ */
126
+ const UNMATCHED = '__unmatched__';
127
+
128
+ /**
129
+ * Tools that write a file. A tool named here with no readable path REFUSES:
130
+ * a write whose target the gate could not read is a write it did not classify.
131
+ */
132
+ export const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Update']);
133
+
134
+ /**
135
+ * Tools that put file CONTENT into the model's context. `Grep` is here for a
136
+ * measured reason rather than for symmetry: with `output_mode: "content"` it
137
+ * prints matching lines, so it reads a file just as effectively as `Read`.
138
+ */
139
+ export const READ_TOOLS = Object.freeze(['Read', 'NotebookRead', 'Grep']);
140
+
141
+ /** Input keys that name a file. Read prototype-safely; see `field`. */
142
+ export const PATH_FIELDS = Object.freeze(['file_path', 'notebook_path', 'path']);
143
+
144
+ /**
145
+ * The namespace these path classes govern: Tyran's own artefacts.
146
+ *
147
+ * This is a CORRECTED PREMISE, and it is stated rather than quietly encoded.
148
+ * ADR-06 is titled "the limits of SELF-improvement autonomy", and its table
149
+ * classifies what the retrospective agent may change about **Tyran** —
150
+ * knowledge files, local skills, agent overrides, config, the enforcement
151
+ * hooks. It says nothing about a user's source tree, because a user's source
152
+ * tree is not what the self-improvement loop edits.
153
+ *
154
+ * The first version of this gate applied `default:` to every path, and the
155
+ * cost was measured rather than imagined: in THIS repository 65 of 65 tracked
156
+ * files match no rule in the shipped template, so an implementer subagent
157
+ * would have been refused on every single write it makes. That is not a
158
+ * stricter gate, it is an uninstalled one — and subagents writing code is what
159
+ * Tyran is for.
160
+ *
161
+ * So "no rule matched" is not one row of the matrix but two:
162
+ *
163
+ * - **inside this namespace** — the policy is meant to enumerate it, so an
164
+ * unmatched path means somebody added a new kind of Tyran artefact. That
165
+ * deserves `default:` (GATED in the template): fail-closed, and cheap,
166
+ * because these trees are small and fully listed;
167
+ * - **outside it** — the policy has nothing to say and neither does this
168
+ * gate. Silence, declared in `docs/policy-gate.md` as a boundary rather
169
+ * than left to be discovered.
170
+ *
171
+ * What this does NOT weaken, and each is pinned by a test: an explicit rule
172
+ * still applies to any path anywhere, so a user who wants `src/**` gated
173
+ * writes that rule and gets it; a path outside the repository is still
174
+ * KERNEL; and `MANDATORY_KERNEL_PATHS` is still checked unconditionally,
175
+ * before any of this.
176
+ */
177
+ export const GOVERNED_PREFIXES = Object.freeze(['.tyran/', '.claude/', 'hooks/']);
178
+
179
+ /** True when the policy's `default:` is the right answer for an unmatched path. */
180
+ export function isGoverned(normalized) {
181
+ // Case-insensitive for the same reason `globMatches` is: on a
182
+ // case-insensitive filesystem `.TYRAN/x` and `.tyran/x` are one file, and a
183
+ // classifier must not let casing pick the weaker answer.
184
+ const lower = String(normalized).toLowerCase();
185
+ return GOVERNED_PREFIXES.some((prefix) => lower === prefix.slice(0, -1) || lower.startsWith(prefix));
186
+ }
187
+
188
+ /**
189
+ * Permission modes in which the USER is still asked before a write lands.
190
+ *
191
+ * Measured on the live install (v2.1.116): `permission_mode` is present on
192
+ * every PreToolUse payload — `default` in the main loop, and whatever the
193
+ * session was started with otherwise, inherited by subagents. Anything not
194
+ * listed here (`acceptEdits`, `bypassPermissions`, an unknown future mode, or
195
+ * a missing field) counts as unsupervised, which is the fail-closed direction.
196
+ *
197
+ * `ask` was in this list and is gone: the platform does not emit it, so it was
198
+ * a dead entry in a security list — the shape that reads as coverage and is
199
+ * not. Review probed `dontAsk` and `auto`; both are unknown and therefore
200
+ * unsupervised, which is the behaviour this list is for.
201
+ *
202
+ * KNOWN LIMIT, measured by review and stated because an unstated limit is a
203
+ * false guarantee: `permission_mode` stays `default` when the user has
204
+ * allow-listed a tool in their settings, and the hook cannot read those
205
+ * settings. So `default` means "the platform MAY prompt", never "the user was
206
+ * asked", and no refusal from this gate claims otherwise.
207
+ */
208
+ export const SUPERVISED_MODES = Object.freeze(['default', 'plan']);
209
+
210
+ // ------------------------------------------------------------- the read rule
211
+
212
+ /**
213
+ * Path shapes that carry credentials.
214
+ *
215
+ * This is a DENYLIST and is structurally incomplete — the same property
216
+ * ADR-19 correction 1 measured on the invisible-codepoint list, where 18
217
+ * added ranges still leaked. It is used anyway, and the reason is the shape
218
+ * of the alternative: the allowlist version of this rule is "refuse every
219
+ * read", which no user keeps installed for an hour. So the incompleteness is
220
+ * declared in `docs/policy-gate.md` rather than papered over.
221
+ *
222
+ * Matched on the BASENAME unless the id says otherwise, and always
223
+ * case-insensitively: on macOS and Windows `.ENV` and `.env` are one file,
224
+ * and a security classifier must not let casing pick the weaker answer (the
225
+ * same reasoning as `globMatches` in schema.mjs).
226
+ */
227
+ export const SECRET_READ_RULES = Object.freeze([
228
+ // `.env`, `.env.local`, `.env.prod` — but not the checked-in samples, which
229
+ // are the documented way to tell an agent what the real file must contain.
230
+ {
231
+ id: 'dotenv',
232
+ on: 'basename',
233
+ match: (b) => /^\.env(\.[^/]*)?$/i.test(b) && !/\.(example|sample|template|dist|defaults?|schema)$/i.test(b),
234
+ },
235
+ { id: 'private-key-file', on: 'basename', match: (b) => /\.(pem|key|p12|pfx|jks|keystore|kdbx|asc|ppk)$/i.test(b) },
236
+ { id: 'ssh-private-key', on: 'basename', match: (b) => /^id_[a-z0-9]+$/i.test(b) },
237
+ { id: 'credentials-file', on: 'basename', match: (b) => /^\.?credentials(\.[a-z0-9]+)?$/i.test(b) },
238
+ { id: 'netrc', on: 'basename', match: (b) => /^_?\.?netrc$/i.test(b) },
239
+ { id: 'registry-auth', on: 'basename', match: (b) => /^\.(npmrc|pypirc|pgpass|dockercfg)$/i.test(b) },
240
+ // `.git-credentials` holds `https://user:token@host` in plain text, and the
241
+ // documentation already promised "credentials"; review read it without
242
+ // objection. `.envrc` is direnv's shell file and is where a .env moves to
243
+ // when someone wants it auto-loaded. Both are shapes the prose implied and
244
+ // the code did not have — the gap between a claim and its mechanism.
245
+ { id: 'git-credentials', on: 'basename', match: (b) => /^\.git-credentials$/i.test(b) },
246
+ { id: 'direnv', on: 'basename', match: (b) => /^\.envrc$/i.test(b) },
247
+ { id: 'service-account-key', on: 'basename', match: (b) => /service[-_]?account.*\.json$/i.test(b) },
248
+ // Whole-path shapes: the directory is the secret, whatever the file inside
249
+ // is called. This is the shape the measured incident actually had.
250
+ { id: 'ssh-directory', on: 'path', match: (p) => /(^|\/)\.ssh\//i.test(p) },
251
+ { id: 'aws-credentials', on: 'path', match: (p) => /(^|\/)\.aws\//i.test(p) },
252
+ { id: 'gnupg-directory', on: 'path', match: (p) => /(^|\/)\.gnupg\//i.test(p) },
253
+ { id: 'gcloud-credentials', on: 'path', match: (p) => /(^|\/)\.config\/gcloud\//i.test(p) },
254
+ { id: 'kube-config', on: 'path', match: (p) => /(^|\/)\.kube\/config$/i.test(p) },
255
+ ]);
256
+
257
+ /**
258
+ * The ids of every secret rule a path matches. Returns a LIST, not a boolean,
259
+ * so the refusal can name what it recognised without quoting the path's
260
+ * contents back at anyone.
261
+ */
262
+ export function secretReadRules(rawPath) {
263
+ const text = String(rawPath).replace(/\\/g, '/');
264
+ const base = basename(text);
265
+ const hits = [];
266
+ for (const rule of SECRET_READ_RULES) {
267
+ const subject = rule.on === 'basename' ? base : text;
268
+ if (rule.match(subject)) hits.push(rule.id);
269
+ }
270
+ return hits;
271
+ }
272
+
273
+ // ---------------------------------------------------------------- the actor
274
+
275
+ /**
276
+ * Which actor this call belongs to.
277
+ *
278
+ * Measured, and asymmetric — the asymmetry is the whole point. `agent_id`
279
+ * present means we are INSIDE a subagent; its absence does NOT mean the main
280
+ * loop is unattended, because a main thread started with `--agent` carries
281
+ * `agent_type` and still no `agent_id`. So the presence test is used in the
282
+ * direction it is sound in, and nothing is inferred from its absence beyond
283
+ * "not a subagent".
284
+ */
285
+ export function actorOf(input) {
286
+ const agentId = field(input, 'agent_id');
287
+ return typeof agentId === 'string' && agentId.trim() !== '' ? 'subagent' : 'main';
288
+ }
289
+
290
+ /**
291
+ * True when nobody will be asked before this call takes effect.
292
+ *
293
+ * A subagent is unsupervised by construction: its tool calls do not surface a
294
+ * permission prompt to the user. The main loop is supervised only while the
295
+ * session's permission mode still prompts. This is what lets GATED mean what
296
+ * ADR-06 says it means — "the operator approves" — without this gate having to
297
+ * invent an approval channel it does not have: on PreToolUse the platform
298
+ * offers `deny` and silence, and no third answer.
299
+ */
300
+ export function isUnsupervised(input) {
301
+ if (actorOf(input) === 'subagent') return true;
302
+ const mode = field(input, 'permission_mode');
303
+ return !(typeof mode === 'string' && SUPERVISED_MODES.includes(mode));
304
+ }
305
+
306
+ // --------------------------------------------------------------- the policy
307
+
308
+ /** Thrown when the gate could not decide. Always becomes a refusal. */
309
+ export class PolicyFailure extends Error {
310
+ constructor(what, remedy) {
311
+ super(what);
312
+ this.name = 'PolicyFailure';
313
+ this.remedy = remedy;
314
+ }
315
+ }
316
+
317
+ /** The repository this call is about. Explicit, never defaulted per call site. */
318
+ export function repoRootOf(input, env = process.env) {
319
+ const fromEnv = env.CLAUDE_PROJECT_DIR;
320
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv;
321
+ const cwd = field(input, 'cwd');
322
+ if (typeof cwd === 'string' && cwd.trim() !== '') return cwd;
323
+ return process.cwd();
324
+ }
325
+
326
+ /**
327
+ * Read a small file, or say why not. Size-checked BEFORE it is read, because
328
+ * a gate that blocks the thread on a large read is a gate the platform kills
329
+ * without ever reading its refusal (ADR-22 correction 2).
330
+ */
331
+ async function readBounded(path, what) {
332
+ let info;
333
+ try {
334
+ info = await stat(path);
335
+ } catch (err) {
336
+ if (err?.code === 'ENOENT') return null;
337
+ throw new PolicyFailure(
338
+ `${what} at ${JSON.stringify(shortPath(path))} could not be read (${err?.code ?? 'unknown error'})`,
339
+ 'fix the file permissions, or remove the file if this repository is not meant to use Tyran',
340
+ );
341
+ }
342
+ if (!info.isFile()) {
343
+ throw new PolicyFailure(
344
+ `${what} at ${JSON.stringify(shortPath(path))} is not a regular file`,
345
+ 'a directory or a device in the policy path means the gate cannot know what the policy says',
346
+ );
347
+ }
348
+ if (info.size > MAX_POLICY_BYTES) {
349
+ throw new PolicyFailure(
350
+ `${what} is ${info.size} bytes, past the ${MAX_POLICY_BYTES} this gate will read`,
351
+ 'a policy file this large is not a policy; split it or shrink it',
352
+ );
353
+ }
354
+ return await readFile(path, 'utf8');
355
+ }
356
+
357
+ /**
358
+ * Load and validate the autonomy policy.
359
+ *
360
+ * Returns `null` — and only then — when the repository has no `.tyran/`
361
+ * directory at all. Every other outcome is either a valid policy or a
362
+ * refusal: absent while `.tyran/` exists, unparseable, or rejected by
363
+ * `validatePolicy` all mean the boundary is unknown, and an unknown boundary
364
+ * that lets writes through is the defect this whole epic is about.
365
+ */
366
+ export async function loadPolicy(root) {
367
+ const text = await readBounded(join(root, POLICY_PATH), 'the autonomy policy');
368
+ if (text === null) {
369
+ let adopted = false;
370
+ try {
371
+ adopted = (await stat(join(root, TYRAN_DIR))).isDirectory();
372
+ } catch {
373
+ adopted = false;
374
+ }
375
+ if (!adopted) return null;
376
+ throw new PolicyFailure(
377
+ `this repository has a ${TYRAN_DIR}/ directory but no ${POLICY_PATH}`,
378
+ `restore it from the shipped template (templates/policies/autonomy.yaml) and validate it with ` +
379
+ `\`node scripts/schema.mjs validate policy ${POLICY_PATH}\`. A missing boundary is not an open one.`,
380
+ );
381
+ }
382
+ let doc;
383
+ try {
384
+ doc = parse(text);
385
+ } catch (err) {
386
+ throw new PolicyFailure(
387
+ `${POLICY_PATH} is not parseable YAML (${err?.name ?? 'error'})`,
388
+ `fix the file and check it with \`node scripts/schema.mjs validate policy ${POLICY_PATH}\`. ` +
389
+ 'A policy the gate cannot read cannot mean "everything is allowed".',
390
+ );
391
+ }
392
+ const errors = validatePolicy(doc);
393
+ if (errors.length > 0) {
394
+ throw new PolicyFailure(
395
+ `${POLICY_PATH} is not a valid policy (${errors.length} finding(s); the first is ` +
396
+ `${JSON.stringify(safePolicyText(errors[0]))})`,
397
+ `run \`node scripts/schema.mjs validate policy ${POLICY_PATH}\` for the full list. ` +
398
+ 'The validator is what stops a policy from downgrading its own enforcement paths.',
399
+ );
400
+ }
401
+ return doc;
402
+ }
403
+
404
+ /** Load the deployment class from config.yaml, or refuse. `null` = no config. */
405
+ export async function loadDeployClass(root) {
406
+ const text = await readBounded(join(root, CONFIG_PATH), 'the Tyran config');
407
+ if (text === null) return null;
408
+ let doc;
409
+ try {
410
+ doc = parse(text);
411
+ } catch (err) {
412
+ throw new PolicyFailure(
413
+ `${CONFIG_PATH} is not parseable YAML (${err?.name ?? 'error'})`,
414
+ `fix the file and check it with \`node scripts/schema.mjs validate config ${CONFIG_PATH}\`.`,
415
+ );
416
+ }
417
+ const node = doc === null || typeof doc !== 'object' ? undefined : doc.autonomy;
418
+ // The field carries provenance when a scanner inferred it: `{value, source,
419
+ // confidence}`. Reading `.autonomy` raw would then compare an object against
420
+ // 'P1' and silently fall through to the widest class.
421
+ const value = node !== null && typeof node === 'object' && 'value' in node ? node.value : node;
422
+ if (!DEPLOY_CLASSES.includes(value)) {
423
+ throw new PolicyFailure(
424
+ `${CONFIG_PATH} does not declare a deployment class (autonomy: ${DEPLOY_CLASSES.join(' | ')})`,
425
+ `set \`autonomy: P1\` if you are unsure — it is the narrowest class, and setup picks it by ` +
426
+ 'default. The gate refuses rather than assuming the widest one.',
427
+ );
428
+ }
429
+ return value;
430
+ }
431
+
432
+ // ------------------------------------------------------ quoting the decision
433
+
434
+ /**
435
+ * The repertoire a policy string is allowed to contribute to a refusal.
436
+ *
437
+ * A refusal is republished into the model's context, so every string in it
438
+ * that came out of a file is attacker-controlled text (failure class 6, which
439
+ * happened four times in this initiative). Review of the secrets gate put an
440
+ * imperative sentence in a rule id and had it printed back verbatim.
441
+ *
442
+ * The mechanical answer used there was an allowlist that drops spaces, so a
443
+ * sentence stops being a sentence. That works for an identifier and destroys
444
+ * a prose `reason:` field. So this gate does not reproduce `reason:` AT ALL —
445
+ * the refusal quotes the rule's `path` glob and its `class`, and points the
446
+ * reader at the file. The guarantee is then exact and testable: the only
447
+ * bytes that travel from the policy file into the model's context are a glob
448
+ * in this repertoire and one of three enum members.
449
+ *
450
+ * The narrower claim, because review measured the wider one false: dropping
451
+ * spaces is NOT the same as "it stops reading as an instruction". A glob may
452
+ * contain `-`, `!` and `?`, so `ignore-previous-instructions-and-approve!`
453
+ * survives this filter intact. What is guaranteed is the REPERTOIRE and the
454
+ * LENGTH, and that a rule path is the only prose-shaped field reproduced at
455
+ * all; the reader of a refusal still has to treat a quoted glob as data.
456
+ */
457
+ export function safePolicyText(value, limit = 120) {
458
+ const kept = String(value).replace(/[^A-Za-z0-9._/*[\]{}?!-]/g, '');
459
+ return (kept === '' ? '(empty)' : kept).slice(0, limit);
460
+ }
461
+
462
+ /**
463
+ * The rule that decided, and a proof that it is the same rule the resolver
464
+ * used.
465
+ *
466
+ * `classifyPath` returns a class, not a rule, so a refusal that names the
467
+ * deciding rule has to select one — and selecting one means restating the
468
+ * precedence, which is exactly how a repository ends up with two spellings of
469
+ * one rule (ADR-21). It is restated here and then CHECKED: the caller
470
+ * compares this rule's class against `classifyPath`'s answer and refuses on
471
+ * disagreement rather than reporting the wrong reason. The check, not the
472
+ * code, is what keeps the two honest, and a test pins it over a corpus.
473
+ *
474
+ * Matching itself is delegated: a single-rule probe policy is handed to
475
+ * `classifyPath`, so the glob semantics are never re-implemented.
476
+ */
477
+ export function decidingRule(policy, normalized) {
478
+ const strictness = { AUTO: 0, GATED: 1, KERNEL: 2 };
479
+ let best = null;
480
+ for (const rule of policy?.rules ?? []) {
481
+ if (rule === null || typeof rule !== 'object') continue;
482
+ if (typeof rule.path !== 'string' || rule.path.trim() === '') continue;
483
+ // A one-rule policy answers "does this rule match?" without a second glob
484
+ // engine. The unconditional kernel check inside classifyPath cannot make
485
+ // this a false positive: it returns KERNEL for kernel paths whatever the
486
+ // rule says, and a kernel path is refused by the caller regardless.
487
+ if (classifyPath({ rules: [rule], default: UNMATCHED }, normalized) === UNMATCHED) continue;
488
+ if (
489
+ best === null ||
490
+ rule.path.length > best.path.length ||
491
+ (rule.path.length === best.path.length && strictness[rule.class] > strictness[best.class])
492
+ ) {
493
+ best = rule;
494
+ }
495
+ }
496
+ return best;
497
+ }
498
+
499
+ /**
500
+ * The built-in protected glob covering this path, or null.
501
+ *
502
+ * `classifyPath` applies `MANDATORY_KERNEL_PATHS` unconditionally, before any
503
+ * rule is consulted, so a refusal that could only ever quote the policy file
504
+ * would name the wrong authority for exactly the paths that matter most. An
505
+ * EMPTY policy isolates that unconditional branch: whatever it still calls
506
+ * KERNEL, it calls KERNEL for a reason no policy can edit.
507
+ */
508
+ export function protectedGlobFor(normalized) {
509
+ // Asked of the MATCHER, never of the resolver.
510
+ //
511
+ // The first version probed `classifyPath` with a one-rule policy, and it was
512
+ // wrong for every path except the ones covered by the first glob in the list:
513
+ // `classifyPath` applies EVERY protected glob unconditionally, before it
514
+ // looks at any rule, so a one-rule probe answers KERNEL whichever glob it was
515
+ // handed. `.tyran/policies/autonomy.yaml` was therefore refused while being
516
+ // told it was `hooks/**` — the strictest refusal this gate gives, naming the
517
+ // wrong authority, with the `.tyran/policies/**` branch unreachable.
518
+ //
519
+ // Worse than the bug: the whole suite was green with it AND green with the
520
+ // fix, because nothing asserted WHICH glob was quoted. That is the fifth
521
+ // "the test aims beside the sink" in this initiative, so the test added
522
+ // alongside this uses a REPAIRING mutant — it must go red when this function
523
+ // is put back the way it was.
524
+ if (normalized === null || normalized === undefined) return null;
525
+ for (const glob of MANDATORY_KERNEL_PATHS) {
526
+ if (globMatches(glob, normalized)) return glob;
527
+ }
528
+ return null;
529
+ }
530
+
531
+ /** How the refusal names the rule that decided. Never the `reason:` prose. */
532
+ export function quoteRule(policy, normalized, cls) {
533
+ if (normalized === null) {
534
+ return 'no rule applies: the path is outside this repository, which is never autonomous';
535
+ }
536
+ const glob = protectedGlobFor(normalized);
537
+ if (glob !== null) {
538
+ return (
539
+ `the built-in protected path \`${safePolicyText(glob)}\` (class KERNEL). No policy can ` +
540
+ 'downgrade it: the validator rejects the file whatever glob spelling is used.'
541
+ );
542
+ }
543
+ const rule = decidingRule(policy, normalized);
544
+ if (rule === null) {
545
+ return `no rule in \`${POLICY_PATH}\` matched, so its \`default:\` applies (class ${safePolicyText(cls)})`;
546
+ }
547
+ return (
548
+ `\`${POLICY_PATH}\`, rule \`path: ${safePolicyText(rule.path)}\` (class ${safePolicyText(rule.class)}). ` +
549
+ "The rule's `reason:` is deliberately not reproduced here — read it in the file"
550
+ );
551
+ }
552
+
553
+ // ----------------------------------------------------------- the path matrix
554
+
555
+ /** How many ancestors `canonicalDeepest` will walk. A path is not a tree. */
556
+ const MAX_PATH_DEPTH = 64;
557
+
558
+ /**
559
+ * The realpath of the deepest ANCESTOR that exists, with the rest re-appended.
560
+ *
561
+ * A write usually names a file that does not exist yet, so `realpath` on the
562
+ * target itself fails. The directory above it almost always does.
563
+ */
564
+ function canonicalDeepest(raw) {
565
+ let head = resolvePath(raw);
566
+ const tail = [];
567
+ for (let i = 0; i < MAX_PATH_DEPTH; i++) {
568
+ try {
569
+ return tail.length === 0 ? realpathSync(head) : join(realpathSync(head), ...tail);
570
+ } catch {
571
+ const parent = dirname(head);
572
+ if (parent === head) return resolvePath(raw);
573
+ tail.unshift(basename(head));
574
+ head = parent;
575
+ }
576
+ }
577
+ return resolvePath(raw);
578
+ }
579
+
580
+ /**
581
+ * The repo-relative path, resolved through symlinks on BOTH sides.
582
+ *
583
+ * Measured on the live install: `file_path` is always ABSOLUTE — the matrix
584
+ * for this gate was first written against relative paths, which is a shape the
585
+ * platform never sends, and mutant M17 is what exposed it. Absolute paths make
586
+ * symlinks load-bearing, and on macOS `/tmp` and `/var` ARE symlinks, so a
587
+ * root and a `file_path` routinely name one directory in two spellings. Left
588
+ * alone that produces "outside the repository, class KERNEL" for an ordinary
589
+ * source file: a refusal on the commonest write there is, which is how a gate
590
+ * gets switched off in its first hour.
591
+ *
592
+ * Canonicalizing unconditionally (rather than only as a retry) also closes the
593
+ * other direction: `repo/link/x` where `link` points at `/etc` reads as
594
+ * repo-relative until the symlink is followed, and then correctly reads as
595
+ * outside. Two spellings of one location must not resolve to two classes.
596
+ */
597
+ export function repoRelative(raw, root) {
598
+ return normalizePath(canonicalDeepest(raw), canonicalDeepest(root));
599
+ }
600
+
601
+ /** Every path this tool call names. Prototype-safe, deduplicated, order-stable. */
602
+ export function pathTargets(toolInput) {
603
+ if (toolInput === null || typeof toolInput !== 'object') return [];
604
+ const out = [];
605
+ for (const key of PATH_FIELDS) {
606
+ const value = field(toolInput, key);
607
+ if (typeof value === 'string' && value.trim() !== '') out.push(value);
608
+ }
609
+ return [...new Set(out)];
610
+ }
611
+
612
+ /**
613
+ * The decision for one classified path, given the class and the supervision.
614
+ *
615
+ * The full matrix, and every cell is a decision rather than a fall-through:
616
+ *
617
+ * | class | supervised main loop | unsupervised (subagent, or prompts off) |
618
+ * |----------|----------------------|------------------------------------------|
619
+ * | AUTO | pass | pass |
620
+ * | GATED | pass — the platform's own prompt IS the approval | **deny** |
621
+ * | KERNEL | **deny** | **deny** |
622
+ * | unmatched| = the policy's `default:` (GATED in the shipped template) | |
623
+ *
624
+ * The GATED row is the one worth arguing. On PreToolUse the platform offers
625
+ * exactly two answers, `deny` and silence; there is no "ask" this runtime can
626
+ * emit, and inventing one means editing hook-io, which is a different
627
+ * decision than this story's. So GATED is delegated where an approval channel
628
+ * already exists — the user's own permission prompt — and enforced where it
629
+ * does not. That is why supervision, not just actor, is an axis: under
630
+ * `acceptEdits` the main loop has no prompt either, and treating it as
631
+ * supervised would have made the whole row decorative.
632
+ */
633
+ export function verdictForClass(cls, unsupervised) {
634
+ if (cls === 'KERNEL') return 'deny';
635
+ if (cls === 'GATED') return unsupervised ? 'deny' : 'pass';
636
+ if (cls === 'AUTO') return 'pass';
637
+ // An unrecognised class cannot be resolved to a permission. validatePolicy
638
+ // rejects one, so reaching here means the resolver and the validator
639
+ // disagree — refuse rather than guess which of them is right.
640
+ return 'deny';
641
+ }
642
+
643
+ // ------------------------------------------------------- the deployment class
644
+
645
+ /**
646
+ * Branch names that are production wherever they appear.
647
+ *
648
+ * An enumeration, and therefore incomplete — a repository whose production
649
+ * branch is called `ship` is not in it. It is the FLOOR, not the rule: the
650
+ * gate also resolves the remote's own default branch, and refuses under P1/P2
651
+ * when it cannot. Two criteria pointing the same way, so a miss in the list
652
+ * does not become a silent pass (ADR-19: an exclusion may never be quiet).
653
+ */
654
+ export const PRODUCTION_BRANCHES = Object.freeze([
655
+ 'main', 'master', 'production', 'prod', 'release', 'live', 'trunk', 'stable',
656
+ ]);
657
+
658
+ /** Long-lived shared branches. P1 keeps an agent off these too; P2 allows them. */
659
+ export const SHARED_BRANCHES = Object.freeze([
660
+ 'testing', 'staging', 'stage', 'test', 'dev', 'develop', 'development',
661
+ 'preview', 'next', 'qa', 'uat', 'integration', 'beta', 'canary',
662
+ ]);
663
+
664
+ /** `refs/heads/main` -> `main`; `+main` -> `main`. Never a shell expansion. */
665
+ export function refName(spec) {
666
+ const text = String(spec).replace(/^\+/, '');
667
+ return text.replace(/^refs\/(heads|remotes)\//, '');
668
+ }
669
+
670
+ /**
671
+ * What a `git push` in this segment would publish, from the LEXER's tokens.
672
+ *
673
+ * The command string is never re-read here. `planCommand` already segments the
674
+ * line, walks `cd`/`pushd`/`popd`, recognises the program through quoting
675
+ * tricks, and refuses anything needing expansion; this only interprets the
676
+ * argv it produced. Writing a second decomposition would have been the third
677
+ * spelling of one rule in this repository.
678
+ */
679
+ export function readPush(argv) {
680
+ const flags = argv.filter((t) => t.startsWith('-'));
681
+ const words = argv.filter((t) => !t.startsWith('-'));
682
+ const has = (...names) => flags.some((f) => names.includes(f));
683
+ const deleteFlag = has('--delete', '-d');
684
+ const specs = words.slice(1); // words[0] is the remote, when present
685
+ const destinations = [];
686
+ let deletes = deleteFlag && specs.length > 0;
687
+ let forced = flags.some((f) => f.startsWith('--force') || (f.length >= 2 && f[1] !== '-' && f.includes('f')));
688
+ let tags = has('--tags', '--follow-tags') || specs.some((s) => s.includes('refs/tags/'));
689
+ // A refspec the shell would rewrite is a destination this gate cannot read.
690
+ //
691
+ // Round two checked `isLiteralPath` for the REMOTE and nothing at all for the
692
+ // refspec, so `B=main; git push origin "$B"` produced the destination `$B`,
693
+ // which matched no production name and sailed through as a feature branch.
694
+ // It landed a commit on `main` for real. The gate never expands anything —
695
+ // that is what keeps a model-written command line out of a shell — so the
696
+ // only honest answer here is a refusal.
697
+ const unreadable = specs.filter((s) => !isLiteralRef(s.replace(/^\+/, '')));
698
+ const symbolic = [];
699
+ for (const spec of specs) {
700
+ if (spec.startsWith('+')) forced = true;
701
+ const parts = spec.split(':');
702
+ if (parts.length > 1 && parts[0].replace(/^\+/, '') === '') deletes = true;
703
+ const dst = parts.length > 1 ? parts[parts.length - 1] : parts[0];
704
+ if (dst === '') continue;
705
+ const name = refName(dst);
706
+ // `HEAD` and `@` are the same thing and both name whatever is checked out.
707
+ // `git push origin HEAD` is the COMMONEST spelling there is and round two
708
+ // did not have it in the table at all: only a bare `git push` reached for
709
+ // `symbolic-ref`, so `HEAD` was compared against the production names as
710
+ // if it were a branch called "HEAD", matched nothing, and published.
711
+ if (name === 'HEAD' || name === '@') symbolic.push(name);
712
+ else destinations.push(name);
713
+ }
714
+ return {
715
+ remote: words[0] ?? null,
716
+ destinations,
717
+ unreadable,
718
+ // Destinations that only git can name. Resolved by the caller, exactly
719
+ // like the no-refspec case, so there is one answer for "which branch is
720
+ // this" instead of two.
721
+ symbolic,
722
+ deletes,
723
+ forced,
724
+ tags,
725
+ // `--all` and `--mirror` publish every local branch, which includes the
726
+ // default one whatever it is called, and `--mirror` also DELETES remote
727
+ // refs that are absent locally.
728
+ everything: has('--all', '--mirror'),
729
+ mirrors: has('--mirror'),
730
+ // No refspec and no wildcard flag: the destination is whatever branch is
731
+ // checked out, and only git can say which. Resolved by the caller.
732
+ impliesCurrentBranch: specs.length === 0 && !has('--all', '--mirror', '--tags'),
733
+ };
734
+ }
735
+
736
+ /**
737
+ * `git symbolic-ref --short`, or null.
738
+ *
739
+ * Every way of not getting an answer collapses to null — git absent, killed,
740
+ * non-zero, empty — because the CALLER turns null into a refusal with a
741
+ * one-command remedy. Distinguishing them here would only produce refusals
742
+ * that differ in wording and not in what the reader has to do.
743
+ */
744
+ export async function symbolicRef(dir, ref, { runner, timeoutMs }) {
745
+ if (!(timeoutMs > 0)) return null;
746
+ const result = await runner('git', ['-C', dir, 'symbolic-ref', '--quiet', '--short', ref], {
747
+ cwd: dir,
748
+ timeoutMs,
749
+ });
750
+ if (result?.spawned !== true || result.timedOut === true || result.code !== 0) return null;
751
+ const line = String(result.stdout ?? '').trim().split('\n')[0] ?? '';
752
+ return line === '' ? null : line;
753
+ }
754
+
755
+ /**
756
+ * The decision for one `git push`, under the repo's deployment class.
757
+ *
758
+ * P1 keeps an agent on its own branch · P2 adds the shared/testing branches ·
759
+ * P3 adds production but still refuses the irreversible, user-visible
760
+ * operations, which is what "P3 passes" was always supposed to mean and never
761
+ * mechanically did.
762
+ */
763
+ export function deployVerdict(deployClass, scope, irreversible) {
764
+ if (irreversible.length > 0) return { verdict: 'deny', because: 'irreversible' };
765
+ if (deployClass === 'P3') return { verdict: 'pass', because: 'P3' };
766
+ if (deployClass === 'P2') {
767
+ return scope === 'production' ? { verdict: 'deny', because: 'production' } : { verdict: 'pass', because: 'P2' };
768
+ }
769
+ return scope === 'feature' ? { verdict: 'pass', because: 'P1' } : { verdict: 'deny', because: scope };
770
+ }
771
+
772
+ // -------------------------------------------------------------- the decision
773
+
774
+ const READ_REMEDY =
775
+ 'If you need a value from this file, ask the operator for the single value rather than the file. ' +
776
+ 'If this path is genuinely not a secret in this repository, the operator can add an explicit ' +
777
+ `\`class: AUTO\` rule for it in \`${POLICY_PATH}\`.\n` +
778
+ // The round-two version of this sentence ended "this gate cannot be talked
779
+ // out of it from inside a session", and review took it apart in three tool
780
+ // calls: `cat >> .tyran/policies/autonomy.yaml`, then the same read, allowed.
781
+ // The shell route is closed now, so the claim is nearly true — and "nearly"
782
+ // is exactly the word a guarantee may not leave out.
783
+ 'That file is class KERNEL, and a shell command that NAMES it is refused too, so the exemption ' +
784
+ 'is not one an agent can write for itself in the ordinary way. It is not a proof: a path this ' +
785
+ 'gate never sees as a word — assembled from a variable, or from a program it launches — is in ' +
786
+ 'the declared floor in docs/policy-gate.md. Writing that exemption yourself is working around ' +
787
+ 'the gate, which is the one move that makes a boundary worse than no boundary.';
788
+
789
+ export async function decide({ input, runner = runChild, startedAt = Date.now(), env = process.env } = {}) {
790
+ const budget = makeBudget(startedAt, DEADLINE_MS, DEADLINE_MARGIN_MS);
791
+ const toolName = field(input, 'tool_name');
792
+ const toolInput = field(input, 'tool_input');
793
+ const root = repoRootOf(input, env);
794
+ const unsupervised = isUnsupervised(input);
795
+ const actor = actorOf(input);
796
+
797
+ if (toolName === 'Bash') return await decideBash({ input, toolInput, root, runner, budget });
798
+
799
+ const targets = pathTargets(toolInput);
800
+ const isWriteTool = typeof toolName === 'string' && WRITE_TOOLS.includes(toolName);
801
+ const isReadTool = typeof toolName === 'string' && READ_TOOLS.includes(toolName);
802
+
803
+ if (targets.length === 0) {
804
+ if (!isWriteTool) return PASS;
805
+ return {
806
+ decision: 'deny',
807
+ reason:
808
+ `tyran policy-gate: refused. \`${safePolicyText(String(toolName))}\` writes a file, but this ` +
809
+ 'call carries no readable path, so the gate could not tell which autonomy class applies.\n' +
810
+ 'A write the gate did not classify is a write it did not check (ADR-22).\n' +
811
+ 'Reissue the call with an explicit file path.',
812
+ };
813
+ }
814
+
815
+ // --- reads: one narrow rule, and the write classes are NOT consulted -------
816
+ if (isReadTool) {
817
+ const policy = await loadPolicy(root).catch((err) => {
818
+ // A broken policy must not make reads MORE permissive, but it also must
819
+ // not make the secret rule unavailable: the rule needs no policy, only
820
+ // the exemption does. Rethrow for writes, degrade to "no exemptions" here.
821
+ if (err instanceof PolicyFailure) return { rules: [], default: 'GATED', degraded: err };
822
+ throw err;
823
+ });
824
+ for (const target of targets) {
825
+ // Tested on the RAW spelling and on the resolved one. Round two checked
826
+ // only the raw string while the write path canonicalized, so
827
+ // `notes-symlink-to-env.txt -> .env` was read without objection — the
828
+ // exact inversion of this file's own argument that two spellings of one
829
+ // location must not resolve to two classes. The cost of skipping it is
830
+ // higher here than on the write path, not lower.
831
+ const resolved = canonicalDeepest(target);
832
+ const hits = [...new Set([...secretReadRules(target), ...secretReadRules(resolved)])];
833
+ if (hits.length === 0) continue;
834
+ const normalized = repoRelative(target, root);
835
+ const exempt =
836
+ normalized !== null &&
837
+ policy !== null &&
838
+ decidingRule(policy, normalized)?.class === 'AUTO';
839
+ if (exempt) continue;
840
+ return {
841
+ decision: 'deny',
842
+ reason:
843
+ `tyran policy-gate: refused. This read would put a credential-shaped file into the ` +
844
+ `model's context.\n` +
845
+ `path: ${JSON.stringify(shortPath(target))}\n` +
846
+ `matched: ${hits.map((h) => `\`${h}\``).join(', ')}\n` +
847
+ `actor: ${actor}${normalized === null ? ' · this path is OUTSIDE the repository' : ''}\n` +
848
+ 'Why a read and not just a commit: the secrets gate defends PUBLICATION. A neighbouring ' +
849
+ "project's .env was read whole into a conductor session in this project's own history — " +
850
+ 'dozens of live credentials, nobody having asked for the read, and no commit involved. ' +
851
+ 'A transcript is storage.\n' +
852
+ `${READ_REMEDY}\n` +
853
+ 'Declared limit: this rule is a denylist of path shapes and is therefore incomplete. ' +
854
+ 'It is not a claim that no secret can reach the context by another name.',
855
+ };
856
+ }
857
+ return PASS;
858
+ }
859
+
860
+ // --- writes: the AUTO / GATED / KERNEL matrix ------------------------------
861
+ const policy = await loadPolicy(root);
862
+ if (policy === null) {
863
+ // No `.tyran/` at all: Tyran does not orchestrate this repository, so the
864
+ // path classes have nothing to say. Stated in docs/policy-gate.md as a
865
+ // boundary rather than left to be discovered.
866
+ return PASS;
867
+ }
868
+
869
+ for (const target of targets) {
870
+ // Normalized HERE, with the root this call is about, and only then handed
871
+ // to the resolver. `classifyPath` normalizes internally too, but against
872
+ // `CLAUDE_PROJECT_DIR ?? process.cwd()` — and a hook process's cwd is not
873
+ // reliably the session's repository. Feeding it the raw path would
874
+ // classify against one directory while the refusal talked about another,
875
+ // and the two would agree in every test that happened to run in the repo
876
+ // root. Normalization is idempotent, so the resolver's own call is a no-op.
877
+ const normalized = repoRelative(target, root);
878
+ // The one branch this cannot delegate: a path outside the repository makes
879
+ // `normalizePath` return null, which `classifyPath` answers with KERNEL.
880
+ // Restated in one line rather than reached by passing a null through, and
881
+ // pinned by a test that asserts both spellings still agree.
882
+ const cls = normalized === null ? 'KERNEL' : classifyPath(policy, normalized);
883
+ // The unmatched row, split in two. `decidingRule` returning null is
884
+ // exactly "the policy said nothing about this path"; outside the governed
885
+ // namespace that is not a decision the policy is entitled to make, so the
886
+ // gate is silent. Inside it, `default:` applies and the class above is
887
+ // already it.
888
+ if (normalized !== null && decidingRule(policy, normalized) === null && !isGoverned(normalized)) {
889
+ continue;
890
+ }
891
+ const verdict = verdictForClass(cls, unsupervised);
892
+
893
+ // ADR-21, applied as a check rather than as a refactor: the rule this
894
+ // refusal names and the class the resolver returned must agree. They are
895
+ // selected by two pieces of code, so the agreement is asserted at runtime
896
+ // and pinned by a test instead of being assumed.
897
+ const named = normalized === null ? null : decidingRule(policy, normalized);
898
+ if (named !== null && normalized !== null && classifyPath(policy, normalized) !== 'KERNEL' && named.class !== cls) {
899
+ throw new PolicyFailure(
900
+ 'the rule this gate would quote and the class the resolver returned disagree',
901
+ 'this is a bug in the gate, not in the policy. The gate refuses rather than reporting a ' +
902
+ 'reason that is not the reason.',
903
+ );
904
+ }
905
+
906
+ if (verdict === 'pass') continue;
907
+ return {
908
+ decision: 'deny',
909
+ reason:
910
+ `tyran policy-gate: refused. ${describeTarget(target, normalized)}\n` +
911
+ `class: ${safePolicyText(cls)} · actor: ${actor} · permission_mode: ` +
912
+ `${safePolicyText(String(field(input, 'permission_mode') ?? '(absent)'))}\n` +
913
+ `rule: ${quoteRule(policy, normalized, cls)}\n` +
914
+ `${escapeRoute(cls, unsupervised)}`,
915
+ };
916
+ }
917
+ return PASS;
918
+ }
919
+
920
+ function describeTarget(raw, normalized) {
921
+ if (normalized === null) {
922
+ return (
923
+ `${JSON.stringify(shortPath(raw))} is outside this repository, and a path outside the repo ` +
924
+ 'is never autonomous.'
925
+ );
926
+ }
927
+ return `${JSON.stringify(shortPath(raw))} is not writable at this autonomy level.`;
928
+ }
929
+
930
+ function escapeRoute(cls, unsupervised) {
931
+ if (cls === 'KERNEL') {
932
+ return (
933
+ 'What to do instead: KERNEL means a human edits this by hand, outside an agent session — ' +
934
+ 'these are the files that enforce every other boundary, so a loop able to edit them has no ' +
935
+ 'boundaries at all. Put the change in your report as a diff for the operator to apply. ' +
936
+ 'Reclassifying it is not available: the validator rejects a policy that downgrades a ' +
937
+ 'protected path, whatever glob spelling it uses.'
938
+ );
939
+ }
940
+ if (unsupervised) {
941
+ return (
942
+ 'What to do instead: GATED means the operator approves this one. Stop, and put the exact ' +
943
+ 'change in your report — path, diff, and why — so the conductor can apply it or run the same ' +
944
+ 'edit in the main session, where the permission prompt is the approval. Do not look for a ' +
945
+ 'path around this gate: writing the same bytes through `Bash` is outside what this gate ' +
946
+ 'checks for CLASSES — though it does refuse a shell command that names a credential file or ' +
947
+ 'a built-in protected path — and doing it deliberately is the one thing that turns a ' +
948
+ 'boundary into a decoration.'
949
+ );
950
+ }
951
+ return (
952
+ 'What to do instead: put the change in your report for the operator, with the path and the ' +
953
+ 'diff.'
954
+ );
955
+ // No third branch. A GATED path in a supervised main loop PASSES, so the
956
+ // only refusals that reach here are KERNEL or unsupervised — review found the
957
+ // third arm unreachable, with zero hits in the tests and in the docs, which
958
+ // is a strand of load-bearing-looking text that bears nothing (ADR-20's own
959
+ // lesson, and the same defect the lexer's `cd -` branch had).
960
+ }
961
+
962
+ // --------------------------------------------------------------- Bash / push
963
+
964
+ /**
965
+ * Options whose ARGUMENT is prose, not a path.
966
+ *
967
+ * `git commit -m "fix .env loading"` must not be refused for the words in a
968
+ * commit message. This is an enumeration and therefore incomplete — but the
969
+ * direction of its incompleteness is a FALSE REFUSAL, never a silent pass: a
970
+ * message flag this list misses produces an over-strict gate, not a hole.
971
+ * That is the only shape of enumeration this file allows.
972
+ */
973
+ const MESSAGE_FLAGS = Object.freeze(['-m', '--message', '-F', '--file', '-t', '--template', '-am', '-ma']);
974
+
975
+ /**
976
+ * Remove the ARGUMENT of a message-bearing option before the tokens are taken.
977
+ *
978
+ * `git commit -m "fix .env loading"` is three tokens after whitespace
979
+ * splitting, so skipping "the next token" left `.env` in the stream and this
980
+ * gate refused a commit message — a false alarm on one of the commonest
981
+ * commands there is, which is exactly how a gate gets uninstalled.
982
+ *
983
+ * A quoted run is matched on the RAW text, before quotes are stripped. That is
984
+ * the same move `stripHeredocBodies` makes in the shared lexer and for the same
985
+ * reason: a here-doc body and a commit message are DATA, and lexing data as a
986
+ * program was measured there to be the single largest source of noise.
987
+ *
988
+ * Deliberately NOT a general quote-aware lexer — this repository has one lexer
989
+ * and is not getting a second. What is skipped is a bounded, named shape.
990
+ */
991
+ export function stripMessageArguments(command) {
992
+ return String(command).replace(
993
+ /(^|\s)(-m|--message|-F|--file|-t|--template|-am|-ma)(=|\s+)("[^"]*"|'[^']*'|\S+)/g,
994
+ '$1',
995
+ );
996
+ }
997
+
998
+ /**
999
+ * Paths a shell command NAMES that this gate protects elsewhere.
1000
+ *
1001
+ * ## Why this exists at all
1002
+ *
1003
+ * Round two declared "shell commands are not path-classified" as a boundary.
1004
+ * Review measured what that boundary costs, twice, and both are refusals:
1005
+ *
1006
+ * - `Read .env` was refused and the model reached for `Bash: grep` **in the
1007
+ * next tool call**, unprompted, and read the value;
1008
+ * - the read rule's own way out — "only a human can edit the policy, so only
1009
+ * a human can add an exemption" — was taken by an agent in three tool calls
1010
+ * with `cat >> .tyran/policies/autonomy.yaml`.
1011
+ *
1012
+ * The sibling `write-guard` on `main` had already closed this class for its own
1013
+ * rule, with a matcher covering `Bash` and a refusal that says the rule is not
1014
+ * tool-specific. Two sibling gates answering the same question two ways is the
1015
+ * collision ADR-21 exists to prevent, so this one now answers the same way.
1016
+ *
1017
+ * ## What it checks, and the doctrine it inherits
1018
+ *
1019
+ * Like `write-guard`, it reads the TEXT of the command, never the effect of
1020
+ * running it. Every literal token of every segment is tested — no heuristic
1021
+ * about which token "looks like a path", because an ordinary word matches
1022
+ * nothing and testing it costs nothing. Two families are refused:
1023
+ *
1024
+ * - a **credential-shaped** path, whatever the program does with it. `cat`,
1025
+ * `grep`, `cp` and `base64` all publish the bytes; the distinction between
1026
+ * reading and copying is not one this gate can make, and does not matter.
1027
+ * - a path under `MANDATORY_KERNEL_PATHS`. Deliberately NOT the policy's own
1028
+ * KERNEL rules: those need the policy file, and a broken policy must not be
1029
+ * the thing that stops an operator running the validator. The two built-in
1030
+ * globs need no configuration and cannot be edited away.
1031
+ *
1032
+ * ## The declared floor
1033
+ *
1034
+ * `SHELL_DECLARED_MISSES` names what still gets through. A path assembled at
1035
+ * runtime, or written by a program the command launches, cannot be seen
1036
+ * without running the command — the same ceiling `write-guard` states.
1037
+ */
1038
+ /**
1039
+ * The globs the SHELL rule protects. Wider than the validator's list, on purpose.
1040
+ *
1041
+ * `MANDATORY_KERNEL_PATHS` is the validator's business: a policy may not
1042
+ * downgrade those two, and changing that list is a decision about what a
1043
+ * policy file is allowed to say. This list is a different question — which
1044
+ * paths this gate refuses to see in a shell command — and it has one more
1045
+ * entry for one reason:
1046
+ *
1047
+ * **`.claude/settings.json` is the hook registry. It is the only place inside
1048
+ * a repository from which every gate can be switched off at once.** The
1049
+ * template classifies it KERNEL, so `Edit` and `Write` refuse it — and until
1050
+ * now `echo x > .claude/settings.json` did not, which left the shortest path
1051
+ * to disabling this gate as the one route it did not watch.
1052
+ *
1053
+ * THE ASYMMETRY THIS LEAVES, named rather than hidden: the registry is
1054
+ * protected from a shell write by THIS list, which no policy can edit, but its
1055
+ * CLASS comes from a template rule, which a user can downgrade to AUTO. So a
1056
+ * user who rewrites their own policy can make `Edit .claude/settings.json`
1057
+ * allowed while `echo > .claude/settings.json` stays refused. Raising it into
1058
+ * the validator's list would close that, and is deliberately out of scope here
1059
+ * — it changes what every policy file in the world is allowed to say.
1060
+ */
1061
+ export const SHELL_PROTECTED_GLOBS = Object.freeze([
1062
+ ...MANDATORY_KERNEL_PATHS,
1063
+ '.claude/settings.json',
1064
+ '.claude/settings.local.json',
1065
+ ]);
1066
+
1067
+ /** The shell-protected glob covering this path, or null. */
1068
+ export function shellProtectedGlobFor(normalized) {
1069
+ if (normalized === null || normalized === undefined) return null;
1070
+ for (const glob of SHELL_PROTECTED_GLOBS) {
1071
+ if (globMatches(glob, normalized)) return glob;
1072
+ }
1073
+ return null;
1074
+ }
1075
+
1076
+ export const SHELL_DECLARED_MISSES = Object.freeze([
1077
+ 'a path assembled at runtime — from a variable, a command substitution, a glob, or a file ' +
1078
+ 'already on disk. The gate never expands anything, so it never sees the result',
1079
+ 'a path reached through a relative walk this gate resolves against the SESSION directory ' +
1080
+ 'rather than the directory a `cd` moved to: the answer is then stricter, not looser, but it ' +
1081
+ 'is a different path from the one the shell would use',
1082
+ 'anything a script writes once it is running — this reads the COMMAND, never its effect',
1083
+ 'a KERNEL path declared by the POLICY rather than built in; only SHELL_PROTECTED_GLOBS is ' +
1084
+ 'checked here, so that a broken policy cannot stop the operator repairing it',
1085
+ ]);
1086
+
1087
+ /** Every literal token of a command, with message-flag arguments skipped. */
1088
+ export function commandTokens(command) {
1089
+ const out = [];
1090
+ for (const segment of splitSegments(stripMessageArguments(command))) {
1091
+ for (const token of tokensOf(segment)) {
1092
+ // A leftover bare flag, when the argument was already removed above.
1093
+ if (MESSAGE_FLAGS.includes(token)) continue;
1094
+ if (isLiteralPath(token)) out.push(token);
1095
+ }
1096
+ }
1097
+ return out;
1098
+ }
1099
+
1100
+ /** The protected paths a command names. Empty is the ordinary case. */
1101
+ export function shellPathFindings(command, startDir, root) {
1102
+ const findings = [];
1103
+ for (const token of commandTokens(command)) {
1104
+ const abs = resolvePath(startDir, token);
1105
+ const secret = secretReadRules(token).length > 0 ? secretReadRules(token) : secretReadRules(abs);
1106
+ if (secret.length > 0) {
1107
+ findings.push({ kind: 'credential', token, detail: secret.join(', ') });
1108
+ continue;
1109
+ }
1110
+ const rel = repoRelative(abs, root);
1111
+ const glob = rel === null ? null : shellProtectedGlobFor(rel);
1112
+ if (glob !== null) findings.push({ kind: 'kernel', token, detail: glob });
1113
+ }
1114
+ return findings;
1115
+ }
1116
+
1117
+ /** The refusal for a protected path named in a shell command. */
1118
+ function refuseShellPaths(findings) {
1119
+ const credentials = findings.filter((f) => f.kind === 'credential');
1120
+ const kernel = findings.filter((f) => f.kind === 'kernel');
1121
+ const lines = [
1122
+ 'tyran policy-gate: refused. This command names a path this gate protects.',
1123
+ ...credentials.map(
1124
+ (f) => ` - ${JSON.stringify(shortPath(f.token))} is credential-shaped (${safePolicyText(f.detail)})`,
1125
+ ),
1126
+ ...kernel.map(
1127
+ (f) => ` - ${JSON.stringify(shortPath(f.token))} is under the protected path \`${safePolicyText(f.detail)}\``,
1128
+ ),
1129
+ ];
1130
+ if (credentials.length > 0) {
1131
+ lines.push(
1132
+ '',
1133
+ 'A shell command that names a credential file publishes its bytes whatever the program is: ' +
1134
+ '`cat`, `grep`, `cp` and `base64` are the same event. Measured in review: this gate refused ' +
1135
+ 'a `Read` of a .env file and the model reached for `Bash: grep` in its NEXT tool call, ' +
1136
+ 'unprompted, and got the value.',
1137
+ );
1138
+ }
1139
+ if (kernel.length > 0) {
1140
+ lines.push(
1141
+ '',
1142
+ 'A protected path is edited by a human, by hand, outside an agent session — these are the ' +
1143
+ 'files that enforce every other boundary. To READ one, use the Read tool, which is not ' +
1144
+ 'refused for them.',
1145
+ );
1146
+ }
1147
+ lines.push(
1148
+ '',
1149
+ // The same sentence write-guard settled on, for the same measured reason:
1150
+ // the model proposes the other tool by itself, so the refusal answers the
1151
+ // idea before it is tried rather than leaving a gap for it to find.
1152
+ 'This rule is not specific to one tool: the same check runs on Write, Edit, NotebookEdit, Read',
1153
+ 'and on the text of a Bash command. Reaching for a different tool is not a way around it.',
1154
+ '',
1155
+ 'Declared floor: this reads the COMMAND, never the effect of running it. ' +
1156
+ `${SHELL_DECLARED_MISSES.length} known ways past it are listed in docs/policy-gate.md.`,
1157
+ );
1158
+ return { decision: 'deny', reason: lines.join('\n') };
1159
+ }
1160
+
1161
+ async function decideBash({ input, toolInput, root, runner, budget }) {
1162
+ const command = field(toolInput, 'command');
1163
+ if (typeof command !== 'string') {
1164
+ return {
1165
+ decision: 'deny',
1166
+ reason:
1167
+ 'tyran policy-gate: refused. This Bash call carries no readable `command`, so the gate ' +
1168
+ 'could not tell whether it publishes anything.\n' +
1169
+ 'A check that cannot run must not read as approval (ADR-22).',
1170
+ };
1171
+ }
1172
+ const startDir = (() => {
1173
+ const cwd = field(input, 'cwd');
1174
+ return typeof cwd === 'string' && cwd !== '' ? cwd : root;
1175
+ })();
1176
+
1177
+ // The path rules come FIRST and need no configuration: they are the answer to
1178
+ // "the model was refused a Read and reached for Bash in the next call", and a
1179
+ // check that only runs once a policy loads is a check an unconfigured repo
1180
+ // does not have.
1181
+ const findings = shellPathFindings(command, startDir, root);
1182
+ if (findings.length > 0) return refuseShellPaths(findings);
1183
+
1184
+ const plan = planCommand(command, startDir);
1185
+ const pushes = plan.targets.filter((t) => t.scanPush === true && Array.isArray(t.pushArgv));
1186
+ // Asked of the LEXER, not spelled again here. The first version of this gate
1187
+ // carried its own alias test, which was a second spelling of one rule two
1188
+ // files apart — and the secrets gate, which had the same blind spot, went on
1189
+ // not knowing. It is one answer now, in `planCommand`, and both gates read it.
1190
+ if (plan.aliased) {
1191
+ throw new PolicyFailure(
1192
+ 'this git command defines or uses an ALIAS, so which subcommand it runs is not readable ' +
1193
+ 'from the command line',
1194
+ 'run the subcommand by its real name (`git push origin my-branch`). An alias can rename ' +
1195
+ 'any subcommand, so a gate that trusted the words it can see would be reading a different ' +
1196
+ 'command from the one git runs — measured: `git -c alias.zz=push zz origin main` published ' +
1197
+ 'to a protected branch while this gate saw no push at all.',
1198
+ );
1199
+ }
1200
+ if (pushes.length === 0) return PASS;
1201
+
1202
+ const deployClass = await loadDeployClass(root);
1203
+ if (deployClass === null) {
1204
+ // Symmetry with the policy file, which round two did not have: a missing
1205
+ // config in an ADOPTED repo refuses, exactly as a missing policy does.
1206
+ // Only a repo with no `.tyran/` at all is silent.
1207
+ let adopted = false;
1208
+ try {
1209
+ adopted = (await stat(join(root, TYRAN_DIR))).isDirectory();
1210
+ } catch {
1211
+ adopted = false;
1212
+ }
1213
+ if (!adopted) return PASS;
1214
+ throw new PolicyFailure(
1215
+ `this command pushes, and this repository has a ${TYRAN_DIR}/ directory but no ${CONFIG_PATH}`,
1216
+ `restore it from the shipped template and set \`autonomy: P1\` if you are unsure. A missing ` +
1217
+ 'deployment class is not the widest one.',
1218
+ );
1219
+ }
1220
+
1221
+ if (plan.unmodellable.length > 0) {
1222
+ const what = [...new Set(plan.unmodellable.map((u) => u.what))];
1223
+ throw new PolicyFailure(
1224
+ `this command pushes, and ${what.length} part(s) of it decide WHERE in a way this gate ` +
1225
+ `cannot follow:\n${what.map((w) => ` - ${w}`).join('\n')}`,
1226
+ 'Write the path literally, run the command from that directory, or split it into separate ' +
1227
+ 'tool calls so each one is unambiguous. The gate never expands variables or runs a shell ' +
1228
+ 'to find out where a command would land.',
1229
+ );
1230
+ }
1231
+
1232
+ for (const target of pushes) {
1233
+ const push = readPush(target.pushArgv);
1234
+ const dir = target.dir;
1235
+
1236
+ if (push.unreadable.length > 0) {
1237
+ throw new PolicyFailure(
1238
+ `this push names ${push.unreadable.length} refspec(s) the shell would rewrite before git ` +
1239
+ 'sees them, so the gate cannot tell which branch it publishes to',
1240
+ 'write the refspec literally (`git push origin my-branch`). Measured: a destination held ' +
1241
+ 'in a variable was read as a branch literally named `$B`, matched no protected name, ' +
1242
+ 'and published to the default branch.',
1243
+ );
1244
+ }
1245
+
1246
+ let destinations = [...push.destinations];
1247
+ // `HEAD` and `@` resolve exactly like a bare `git push`: one answer for
1248
+ // "which branch is this", not two.
1249
+ if (push.symbolic.length > 0 || push.impliesCurrentBranch) {
1250
+ const current = await symbolicRef(dir, 'HEAD', { runner, timeoutMs: budget(GIT_BUDGET_MS) });
1251
+ if (current === null) {
1252
+ throw new PolicyFailure(
1253
+ `this push's destination is whatever branch is checked out in ` +
1254
+ `${JSON.stringify(shortPath(dir))} — and the gate could not read that branch`,
1255
+ 'run the push with an explicit refspec (`git push origin my-branch`), or check out a ' +
1256
+ 'named branch. A detached HEAD has no branch name for a policy to reason about.',
1257
+ );
1258
+ }
1259
+ destinations.push(current);
1260
+ }
1261
+
1262
+ const remote = push.remote ?? 'origin';
1263
+ let defaultBranch = null;
1264
+ if (deployClass !== 'P3') {
1265
+ defaultBranch = await symbolicRef(dir, `refs/remotes/${remote}/HEAD`, {
1266
+ runner,
1267
+ timeoutMs: budget(GIT_BUDGET_MS),
1268
+ });
1269
+ if (defaultBranch !== null) defaultBranch = refName(defaultBranch).replace(`${remote}/`, '');
1270
+ if (defaultBranch === null) {
1271
+ // Refusing here rather than falling back to the name list alone. The
1272
+ // list is an enumeration and a repository whose production branch is
1273
+ // called `ship` would sail through it silently. The remedy is one
1274
+ // command and permanent, which is what makes a refusal honest rather
1275
+ // than an obstacle.
1276
+ throw new PolicyFailure(
1277
+ `this repository does not record which branch \`${safePolicyText(remote)}\` treats as ` +
1278
+ 'default, and under ' + deployClass + ' the gate has to know that before it can tell a ' +
1279
+ 'feature branch from production',
1280
+ `run \`git remote set-head ${safePolicyText(remote)} -a\` once in this repository. It ` +
1281
+ 'writes a local ref, changes nothing on the remote, and is the whole fix. The gate ' +
1282
+ 'refuses instead of guessing, because guessing here means guessing in the direction ' +
1283
+ 'of publishing.',
1284
+ );
1285
+ }
1286
+ }
1287
+
1288
+ const isProduction = (b) =>
1289
+ PRODUCTION_BRANCHES.includes(b.toLowerCase()) || (defaultBranch !== null && b === defaultBranch);
1290
+ const isShared = (b) => SHARED_BRANCHES.includes(b.toLowerCase());
1291
+
1292
+ let scope = 'feature';
1293
+ if (push.everything || push.tags || destinations.some(isProduction)) scope = 'production';
1294
+ else if (destinations.some(isShared)) scope = 'shared';
1295
+ if (destinations.length === 0 && !push.everything && !push.tags) {
1296
+ throw new PolicyFailure(
1297
+ 'this push has no destination the gate could read',
1298
+ 'name the remote and the refspec explicitly (`git push origin my-branch`).',
1299
+ );
1300
+ }
1301
+
1302
+ const irreversible = [];
1303
+ if (push.deletes) irreversible.push('it deletes a ref on the remote');
1304
+ if (push.mirrors) irreversible.push('`--mirror` deletes every remote ref that is absent locally');
1305
+ if (push.forced && scope !== 'feature') {
1306
+ irreversible.push('it force-pushes over a shared branch, discarding published history');
1307
+ }
1308
+
1309
+ const { verdict, because } = deployVerdict(deployClass, scope, irreversible);
1310
+ if (verdict === 'pass') continue;
1311
+
1312
+ const named = destinations.map((d) => `\`${safePolicyText(d)}\``).join(', ') || '(every branch)';
1313
+ return {
1314
+ decision: 'deny',
1315
+ reason:
1316
+ `tyran policy-gate: refused. This push reaches further than deployment class ` +
1317
+ `${deployClass} allows.\n` +
1318
+ `destination: ${named} on remote \`${safePolicyText(remote)}\` · scope: ${scope}` +
1319
+ `${defaultBranch === null ? '' : ` · this remote's default branch is \`${safePolicyText(defaultBranch)}\``}\n` +
1320
+ `rule: \`${CONFIG_PATH}\`, \`autonomy: ${deployClass}\` — ` +
1321
+ `${DEPLOY_CLASS_MEANING[deployClass]}\n` +
1322
+ (irreversible.length > 0
1323
+ ? `irreversible: ${irreversible.join('; ')}. These stay refused at every class, ` +
1324
+ 'including P3, because "autonomous" was never meant to include "unrecoverable".\n'
1325
+ : '') +
1326
+ `${DEPLOY_REMEDY[because] ?? DEPLOY_REMEDY.default}`,
1327
+ };
1328
+ }
1329
+ return PASS;
1330
+ }
1331
+
1332
+ const DEPLOY_CLASS_MEANING = Object.freeze(Object.assign(Object.create(null), {
1333
+ P1: 'branch only; an agent pushes its own work and a human moves it further',
1334
+ P2: 'staging and testing branches; production stays a human decision',
1335
+ P3: 'production too, minus the operations that cannot be undone',
1336
+ }));
1337
+
1338
+ const DEPLOY_REMEDY = Object.freeze(Object.assign(Object.create(null), {
1339
+ irreversible:
1340
+ 'What to do instead: push the branch without deleting or rewriting anything, and let the ' +
1341
+ 'operator do the destructive step. A deleted remote ref and an overwritten history are not ' +
1342
+ 'recoverable from this session, which is the whole reason they are outside every autonomy class.',
1343
+ production:
1344
+ 'What to do instead: push to your own branch and open a pull request. The class lives in ' +
1345
+ `\`${CONFIG_PATH}\`, which ADR-06 classifies GATED rather than KERNEL, so raising it is an ` +
1346
+ 'operator decision by CONVENTION, not by mechanism: measured in review, an agent in an ' +
1347
+ 'unattended main loop edited P1 to P3 with no refusal. Treat raising it yourself as the thing ' +
1348
+ 'you were asked not to do, and say in your report that you wanted to.',
1349
+ shared:
1350
+ 'What to do instead: push to your own branch and open a pull request. Under P1 an agent stays ' +
1351
+ 'on the branch it created; the shared branches are P2 and above.',
1352
+ default:
1353
+ 'What to do instead: push to a branch of your own and open a pull request from it.',
1354
+ }));
1355
+
1356
+ /** Turn a PolicyFailure into the refusal it always has to be. */
1357
+ export async function handle({ input, runner, startedAt, env }) {
1358
+ try {
1359
+ return await decide({ input, runner, startedAt, env });
1360
+ } catch (err) {
1361
+ if (err instanceof PolicyFailure) {
1362
+ return {
1363
+ decision: 'deny',
1364
+ reason:
1365
+ `tyran policy-gate: refused because the check could not be completed.\n` +
1366
+ `what happened: ${err.message}\n` +
1367
+ `what to do: ${err.remedy}\n` +
1368
+ 'This is a refusal rather than a warning because the platform fails open (ADR-22): a ' +
1369
+ 'gate that lets the action through whenever it breaks is a gate you switch off by ' +
1370
+ 'breaking it.',
1371
+ };
1372
+ }
1373
+ // Anything else is a bug here. hook-io turns a throw into a refusal naming
1374
+ // the error class, which is the correct ending.
1375
+ throw err;
1376
+ }
1377
+ }
1378
+
1379
+ /** See journal.mjs — both sides canonicalized, or a symlinked path no-ops. */
1380
+ function canonicalPath(path) {
1381
+ const abs = resolvePath(path);
1382
+ try {
1383
+ return realpathSync(abs);
1384
+ } catch {
1385
+ return abs;
1386
+ }
1387
+ }
1388
+
1389
+ function isMainModule(moduleUrl) {
1390
+ if (!process.argv[1]) return false;
1391
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
1392
+ }
1393
+
1394
+ if (isMainModule(import.meta.url)) {
1395
+ await main(() =>
1396
+ runGate({
1397
+ event: 'PreToolUse',
1398
+ deadlineMs: DEADLINE_MS,
1399
+ handler: ({ input }) => handle({ input }),
1400
+ }),
1401
+ );
1402
+ }