@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,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
|
+
}
|