@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,475 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* write-guard — a raw control or bidi character does not reach a file.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a second layer at all
|
|
6
|
+
*
|
|
7
|
+
* `scripts/scan-control-chars.mjs` already refuses these characters in tracked
|
|
8
|
+
* files, but it runs in CI — AFTER the write, after the commit, on a machine
|
|
9
|
+
* that is not the author's. This one runs at the moment of the write, which is
|
|
10
|
+
* the only moment at which the answer is cheap and the cause is still visible.
|
|
11
|
+
*
|
|
12
|
+
* The need is not theoretical and it is not about an attacker. In this
|
|
13
|
+
* repository's own construction, editing tools replaced the TEXT of an escape
|
|
14
|
+
* with the character itself **nine times in one session, across three agents,
|
|
15
|
+
* twice inside the very code written to prevent it**. Every occurrence was
|
|
16
|
+
* silent. One NUL byte made `grep` return zero matches with exit 0, so the
|
|
17
|
+
* file looked empty to every search that followed it.
|
|
18
|
+
*
|
|
19
|
+
* ## A gate on ONE entrance is not a gate
|
|
20
|
+
*
|
|
21
|
+
* That is why this is registered for every tool that writes file content, not
|
|
22
|
+
* for the one that happens to be the usual suspect. The set is not a judgement
|
|
23
|
+
* call: `FILE_WRITING_TOOLS` is imported from `scripts/hooks-check.mjs`, where
|
|
24
|
+
* it is transcribed from the platform's OWN enumeration of the tools whose
|
|
25
|
+
* input adds file content (`Edit`, `Write`, `NotebookEdit`). `Bash` is
|
|
26
|
+
* registered alongside them for a different reason, stated under LIMITS below.
|
|
27
|
+
*
|
|
28
|
+
* ## The rule is imported, never restated
|
|
29
|
+
*
|
|
30
|
+
* The membership question — "is this codepoint invisible?" — is asked of
|
|
31
|
+
* `scanText`, which asks `invisibleProblem` in `scripts/invisible.mjs`. This
|
|
32
|
+
* file contains no list of codepoints and no character class, deliberately:
|
|
33
|
+
* ADR-19 correction 1 measured three spellings of that rule disagreeing on 456
|
|
34
|
+
* codepoints, with the weakest spelling standing closest to the reader. A
|
|
35
|
+
* fourth spelling here would recreate exactly that, in the layer with the
|
|
36
|
+
* shortest distance to a file on disk.
|
|
37
|
+
*
|
|
38
|
+
* ## Why the whole input is walked, not three known fields
|
|
39
|
+
*
|
|
40
|
+
* Measured: the platform types `tool_input` as `h.unknown()`. It does not
|
|
41
|
+
* validate the shape, so the shape is genuinely arbitrary and a guard keyed on
|
|
42
|
+
* `content` / `new_string` / `new_source` would be correct for exactly today's
|
|
43
|
+
* three tools and silently blind for the fourth. Every string in the payload
|
|
44
|
+
* is therefore scanned, at any depth, and a payload this guard cannot walk to
|
|
45
|
+
* the bottom is REFUSED rather than passed — the coverage doctrine the secrets
|
|
46
|
+
* gate arrived at the hard way: a partial scan that reports nothing looks
|
|
47
|
+
* exactly like a clean one.
|
|
48
|
+
*
|
|
49
|
+
* ## LIMITS, stated because an unstated limit is a false guarantee
|
|
50
|
+
*
|
|
51
|
+
* - **For `Bash` this guard checks the TEXT of the command, never the effect
|
|
52
|
+
* of running it. That is a KNOWN, MEASURED HOLE, not a theoretical one.**
|
|
53
|
+
* Review ran it live: a `Write` carrying a TAG character was refused, and
|
|
54
|
+
* in the same turn the model proposed `printf` via Bash by itself. That
|
|
55
|
+
* route worked, because the command text was pure ASCII and the character
|
|
56
|
+
* only came into existence when the shell expanded the escape:
|
|
57
|
+
*
|
|
58
|
+
* $ od -c payload.txt
|
|
59
|
+
* 0000000 X 363 240 201 201 Y <- UTF-8 for U+E0041
|
|
60
|
+
*
|
|
61
|
+
* `decodeShellEscapes` now closes that specific route by decoding escape
|
|
62
|
+
* notations in the command text. It is a DENYLIST and therefore a FLOOR,
|
|
63
|
+
* NOT A CEILING — `ESCAPE_DECLARED_MISSES` enumerates what still gets
|
|
64
|
+
* through, and the largest entry is unavoidable: a character assembled at
|
|
65
|
+
* runtime, or written by any program the command launches, cannot be seen
|
|
66
|
+
* without executing the command. So the honest claim for Bash is "the
|
|
67
|
+
* obvious routes are closed", never "covered".
|
|
68
|
+
* - **MCP tools ARE covered**, via the `mcp__.*` alternative — see
|
|
69
|
+
* `MCP_TOOL_PATTERN` for what that costs. What is still NOT covered is a
|
|
70
|
+
* tool whose name contains no `mcp__` and is not one of the four named
|
|
71
|
+
* ones; there is no way to enumerate those in advance.
|
|
72
|
+
* - **CR (U+000D) is forbidden**, so an Edit against a file with CRLF line
|
|
73
|
+
* endings is refused. That is the shared rule, not a local choice, and it
|
|
74
|
+
* is the one place this gate is likely to bounce honest work; the refusal
|
|
75
|
+
* says so and names the remedy.
|
|
76
|
+
*/
|
|
77
|
+
import { realpathSync } from 'node:fs';
|
|
78
|
+
import { dirname, resolve } from 'node:path';
|
|
79
|
+
import { fileURLToPath } from 'node:url';
|
|
80
|
+
|
|
81
|
+
import { FILE_WRITING_TOOLS } from '../../scripts/hooks-check.mjs';
|
|
82
|
+
import { formatCodePoint, formatFinding, scanPath, scanText } from '../../scripts/scan-control-chars.mjs';
|
|
83
|
+
import { PASS, field, main, runGate } from './hook-io.mjs';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* This gate's own budget, well under the `timeout` its hooks.json entry
|
|
87
|
+
* declares (ADR-22 point 2); a test reads both numbers and refuses to let them
|
|
88
|
+
* cross.
|
|
89
|
+
*
|
|
90
|
+
* Sized from measurement rather than from a round number that felt safe.
|
|
91
|
+
* `hook-io` caps stdin at MAX_INPUT_BYTES (1 MiB), and `scanText` over 1 MiB
|
|
92
|
+
* of text costs 13.8 ms on this machine; the astral worst case — 200 000
|
|
93
|
+
* codepoints that are all surrogate pairs — costs 9.4 ms. The work is pure
|
|
94
|
+
* CPU over an in-memory object with no I/O and no child process, so 2 000 ms
|
|
95
|
+
* is roughly 130x the measured ceiling. It exists to bound a pathological
|
|
96
|
+
* input, not to accommodate a slow one.
|
|
97
|
+
*/
|
|
98
|
+
export const DEADLINE_MS = 2000;
|
|
99
|
+
|
|
100
|
+
/** Tools registered by NAME: the platform's own set, plus Bash. */
|
|
101
|
+
export const GUARDED_TOOLS = Object.freeze([...FILE_WRITING_TOOLS, 'Bash']);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The alternative that covers every MCP server, and the reason it is here.
|
|
105
|
+
*
|
|
106
|
+
* The first version of this file declared MCP tools out of scope because "the
|
|
107
|
+
* equality branch cannot wildcard them". That sentence is true and the
|
|
108
|
+
* conclusion drawn from it was false: nothing forces the matcher to STAY in
|
|
109
|
+
* the equality branch. Adding a single alternative containing `.` and `*`
|
|
110
|
+
* moves the whole matcher into the regex branch, where `mcp__.*` covers every
|
|
111
|
+
* server that exists or will exist. Measured on the verified predicate:
|
|
112
|
+
*
|
|
113
|
+
* Write · Edit · NotebookEdit · Bash -> match
|
|
114
|
+
* mcp__filesystem__write_file -> match
|
|
115
|
+
* Read · NotebookRead · Glob · Grep · WebFetch -> no match
|
|
116
|
+
* TaskOutput · WriteSomething -> no match (anchored)
|
|
117
|
+
*
|
|
118
|
+
* A filesystem MCP server is today the commonest FOURTH way to write a file,
|
|
119
|
+
* and the story is binding: a gate on one entrance is not a gate.
|
|
120
|
+
*
|
|
121
|
+
* THE COSTS, stated rather than discovered later:
|
|
122
|
+
*
|
|
123
|
+
* - The whole alternation is ANCHORED, `^(...)$`, and that is load-bearing
|
|
124
|
+
* rather than tidy. Unanchored, the platform retries a regex against every
|
|
125
|
+
* ALIAS of the query, so `Bash` also matched `TaskOutput` — whose alias
|
|
126
|
+
* `BashOutputTool` contains it — and `Write` matched any `WriteSomething`.
|
|
127
|
+
* Measured both ways; anchoring costs nothing and removes both. `doctor
|
|
128
|
+
* --hooks` flags the unanchored form, and it was right to.
|
|
129
|
+
* - The guard still runs on READ-ONLY MCP tools: `mcp__.*` cannot tell a
|
|
130
|
+
* reader from a writer, because the name is all there is. That costs one
|
|
131
|
+
* process per call and scans strings that were never going to reach a file.
|
|
132
|
+
* Accepted: the alternative is enumerating servers we cannot know.
|
|
133
|
+
* - Coverage refusals were the live risk, so they were measured rather than
|
|
134
|
+
* argued: across 401 local transcripts, real MCP tool inputs reach depth 2
|
|
135
|
+
* and 4 strings at worst, against this guard's caps of 32 and 10 000. The
|
|
136
|
+
* refusal path does not fire on real MCP traffic.
|
|
137
|
+
*/
|
|
138
|
+
export const MCP_TOOL_PATTERN = 'mcp__.*';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The exact matcher string `hooks.json` must carry. Exported so the
|
|
142
|
+
* registration can be asserted against the code rather than kept in step by
|
|
143
|
+
* somebody remembering — a matcher and the guard behind it drifting apart is
|
|
144
|
+
* the failure this whole story is about.
|
|
145
|
+
*/
|
|
146
|
+
export const GUARD_MATCHER = `^(${[...GUARDED_TOOLS, MCP_TOOL_PATTERN].join('|')})$`;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Input keys whose value is a PATH rather than file content.
|
|
150
|
+
*
|
|
151
|
+
* They get the disjoint identifier rule as well, because a tab or a newline is
|
|
152
|
+
* ordinary text inside a file and a catastrophe in a name — one path prints as
|
|
153
|
+
* two columns in every tool that lists it. `scanPath` is the existing answer
|
|
154
|
+
* for that; this is a list of KEYS, not a second rule.
|
|
155
|
+
*/
|
|
156
|
+
const PATH_KEYS = Object.freeze(['file_path', 'notebook_path', 'path', 'filePath']);
|
|
157
|
+
|
|
158
|
+
/** How deep a tool_input may nest before this gate stops trusting its own walk. */
|
|
159
|
+
export const MAX_DEPTH = 32;
|
|
160
|
+
|
|
161
|
+
/** How many string values it will look at before it stops trusting the walk. */
|
|
162
|
+
export const MAX_STRINGS = 10000;
|
|
163
|
+
|
|
164
|
+
/** Findings named individually in a refusal before it stops repeating. */
|
|
165
|
+
const MAX_FINDINGS_SHOWN = 10;
|
|
166
|
+
|
|
167
|
+
/** Raised when the payload could not be walked to the bottom. */
|
|
168
|
+
export class CoverageFailure extends Error {}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------- escapes inside a shell command
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Named escapes worth decoding, and the ones deliberately left out.
|
|
174
|
+
*
|
|
175
|
+
* `\e`/`\E` mean one thing only: the ESC character. `\a` likewise. But `\b`,
|
|
176
|
+
* `\f`, `\v` and `\r` are ALSO regular-expression syntax — `grep -E '\bword\b'`
|
|
177
|
+
* is ordinary work, and refusing it would be the crying-wolf failure ADR-19
|
|
178
|
+
* warns about, on a command that writes nothing at all. They are left out on
|
|
179
|
+
* purpose and named in `ESCAPE_DECLARED_MISSES` rather than forgotten.
|
|
180
|
+
*
|
|
181
|
+
* `\n` and `\t` are absent for a different reason: they decode to LF and TAB,
|
|
182
|
+
* which the invisibility rule calls legal text. They are not misses.
|
|
183
|
+
*/
|
|
184
|
+
const NAMED_ESCAPES = Object.freeze(
|
|
185
|
+
Object.assign(Object.create(null), { e: 0x1b, E: 0x1b, a: 0x07 }),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Escape notations a shell materializes into a real character.
|
|
190
|
+
*
|
|
191
|
+
* Order matters: the hex and Unicode forms are tried before the octal run, so
|
|
192
|
+
* `\x1b` is not read as `\` + `x` + digits.
|
|
193
|
+
*/
|
|
194
|
+
const ESCAPE_RE = /\\(U[0-9a-fA-F]{1,8}|u[0-9a-fA-F]{1,4}|x[0-9a-fA-F]{1,2}|[0-7]{1,3}|[eEa])/g;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* WHAT THIS DENYLIST DOES NOT CATCH — the floor, not the ceiling.
|
|
198
|
+
*
|
|
199
|
+
* Stated in the same shape as the secrets gate's declared misses, and for the
|
|
200
|
+
* same reason: a denylist whose gaps live only in someone's head is a false
|
|
201
|
+
* guarantee. Every entry here is a way to put a forbidden code point into a
|
|
202
|
+
* file through `Bash` that this guard will NOT stop.
|
|
203
|
+
*/
|
|
204
|
+
export const ESCAPE_DECLARED_MISSES = Object.freeze([
|
|
205
|
+
'the character assembled at runtime, from a variable, a command substitution, ' +
|
|
206
|
+
'base64, xxd, a file already on disk, or any program the command launches',
|
|
207
|
+
'the named escapes \\b \\f \\v \\r, left out because they are also regular-expression ' +
|
|
208
|
+
'syntax and refusing `grep -E "\\bword\\b"` would cost more than it buys ' +
|
|
209
|
+
'(their hex and octal spellings ARE caught)',
|
|
210
|
+
'a here-doc whose body is fed to an interpreter that decodes escapes itself',
|
|
211
|
+
'anything a script writes once it is running — this guard reads the COMMAND, ' +
|
|
212
|
+
'never the effect of running it',
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Escape notations in `text` that decode to a forbidden code point.
|
|
217
|
+
*
|
|
218
|
+
* The membership question is still `invisibleProblem`'s, asked through the
|
|
219
|
+
* same `scanText` the rest of this file uses — this function only performs the
|
|
220
|
+
* DECODING that the shell would perform, and hands the result to the one
|
|
221
|
+
* answer. `\n` and `\t` therefore pass here exactly as a raw LF or TAB does,
|
|
222
|
+
* with no second opinion about what is legal.
|
|
223
|
+
*/
|
|
224
|
+
export function decodeShellEscapes(text) {
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const match of String(text).matchAll(ESCAPE_RE)) {
|
|
227
|
+
const body = match[1];
|
|
228
|
+
let cp;
|
|
229
|
+
if (body[0] === 'U' || body[0] === 'u' || body[0] === 'x') cp = Number.parseInt(body.slice(1), 16);
|
|
230
|
+
else if (/^[0-7]+$/.test(body)) cp = Number.parseInt(body, 8);
|
|
231
|
+
else cp = NAMED_ESCAPES[body];
|
|
232
|
+
if (!Number.isInteger(cp) || cp > 0x10ffff) continue;
|
|
233
|
+
// Surrogates are not scalar values and `String.fromCodePoint` throws on
|
|
234
|
+
// them; a shell cannot produce one either. Skipping is correct, and doing
|
|
235
|
+
// it here keeps the scan from crashing on `\ud800`.
|
|
236
|
+
if (cp >= 0xd800 && cp <= 0xdfff) continue;
|
|
237
|
+
const problem = scanText(String.fromCodePoint(cp));
|
|
238
|
+
if (problem.length > 0) {
|
|
239
|
+
out.push({ notation: match[0], codePoint: cp, index: match.index, hit: problem[0] });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Every string in `value`, with the path that reaches it.
|
|
247
|
+
*
|
|
248
|
+
* Walks arrays and plain objects. A cycle is impossible in a JSON payload, but
|
|
249
|
+
* the depth and count caps are enforced anyway and they REFUSE rather than
|
|
250
|
+
* truncate: this function's answer is used to say "the input is clean", and a
|
|
251
|
+
* walk that quietly stopped early would make that sentence false in exactly
|
|
252
|
+
* the case someone constructed on purpose.
|
|
253
|
+
*/
|
|
254
|
+
export function collectStrings(value, { maxDepth = MAX_DEPTH, maxStrings = MAX_STRINGS } = {}) {
|
|
255
|
+
const out = [];
|
|
256
|
+
const walk = (node, path, depth) => {
|
|
257
|
+
if (depth > maxDepth) {
|
|
258
|
+
throw new CoverageFailure(`tool input nests deeper than ${maxDepth} levels at ${path || '(root)'}`);
|
|
259
|
+
}
|
|
260
|
+
// `>=`, not `>`: the check runs BEFORE the push, so `>` admitted one
|
|
261
|
+
// string more than the cap it advertises. Safe direction, still wrong.
|
|
262
|
+
if (out.length >= maxStrings) {
|
|
263
|
+
throw new CoverageFailure(`tool input holds more than ${maxStrings} string values`);
|
|
264
|
+
}
|
|
265
|
+
if (typeof node === 'string') {
|
|
266
|
+
out.push({ path, value: node });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (Array.isArray(node)) {
|
|
270
|
+
node.forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (node !== null && typeof node === 'object') {
|
|
274
|
+
// Own keys only: a payload carrying `__proto__` is an own property after
|
|
275
|
+
// JSON.parse, and walking the prototype chain would scan Object.prototype.
|
|
276
|
+
for (const key of Object.keys(node)) {
|
|
277
|
+
walk(node[key], path === '' ? key : `${path}.${key}`, depth + 1);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// numbers, booleans, null: nothing to scan, nothing to hide in.
|
|
281
|
+
};
|
|
282
|
+
walk(value, '', 0);
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Keys whose value is a SHELL COMMAND, i.e. text a shell will interpret.
|
|
288
|
+
*
|
|
289
|
+
* The escape scan is confined to these, and the boundary is load-bearing
|
|
290
|
+
* rather than cautious. In file CONTENT, `\x1b` is not a control character —
|
|
291
|
+
* it is the escape NOTATION, which is precisely what this repository tells
|
|
292
|
+
* people to write instead of the raw byte. Decoding escapes there would refuse
|
|
293
|
+
* the remedy the refusal itself recommends, and would make the guard reject
|
|
294
|
+
* most of its own source. In a shell command the same four characters become
|
|
295
|
+
* a real ESC at execution time. Same text, opposite meaning, so the rule
|
|
296
|
+
* cannot be the same.
|
|
297
|
+
*/
|
|
298
|
+
const SHELL_COMMAND_KEYS = Object.freeze(['command']);
|
|
299
|
+
|
|
300
|
+
/** True when this key names a shell command rather than file content. */
|
|
301
|
+
function isShellCommandKey(path, toolName) {
|
|
302
|
+
const leaf = path.split('.').at(-1)?.replace(/\[\d+\]$/, '') ?? '';
|
|
303
|
+
return SHELL_COMMAND_KEYS.includes(leaf) || (toolName === 'Bash' && leaf === '');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** True when this key names a path, so the identifier rule applies too. */
|
|
307
|
+
function isPathKey(path) {
|
|
308
|
+
const leaf = path.split('.').at(-1)?.replace(/\[\d+\]$/, '') ?? '';
|
|
309
|
+
return PATH_KEYS.includes(leaf);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Every forbidden codepoint in a tool input, with the field that carries it.
|
|
314
|
+
*
|
|
315
|
+
* Iterates CODEPOINTS, not UTF-16 units, because `scanText` does — and that is
|
|
316
|
+
* the difference between catching the TAG block and not. U+E0001..U+E007F are
|
|
317
|
+
* surrogate PAIRS, and neither half is invisible on its own, so a unit-wise
|
|
318
|
+
* walk sees 0xDB40 and 0xDC01, finds nothing wrong with either, and reports
|
|
319
|
+
* success over a payload that spells arbitrary ASCII in invisible characters.
|
|
320
|
+
*/
|
|
321
|
+
export function scanToolInput(toolInput, options = {}) {
|
|
322
|
+
const { toolName = '', ...walkOptions } = options;
|
|
323
|
+
const findings = [];
|
|
324
|
+
for (const { path, value } of collectStrings(toolInput, walkOptions)) {
|
|
325
|
+
const where = isPathKey(path) ? 'name' : 'content';
|
|
326
|
+
const hits = where === 'name' ? scanPath(value) : scanText(value);
|
|
327
|
+
for (const hit of hits) findings.push({ field: path, where, hit });
|
|
328
|
+
// A shell command carries a SECOND way to reach a file: an escape the
|
|
329
|
+
// shell expands at execution time. Measured live by review — the model
|
|
330
|
+
// proposed this route by itself, in the same turn a Write was refused,
|
|
331
|
+
// and it worked.
|
|
332
|
+
if (!isShellCommandKey(path, toolName)) continue;
|
|
333
|
+
for (const esc of decodeShellEscapes(value)) {
|
|
334
|
+
findings.push({ field: path, where: 'shell-escape', hit: esc.hit, notation: esc.notation });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return findings;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
341
|
+
|
|
342
|
+
/** The refusal text: what, where, and what to do instead. */
|
|
343
|
+
export function refusalFor(toolName, findings) {
|
|
344
|
+
// "raw" is only true when the characters are already there; for an escape
|
|
345
|
+
// the whole point is that they are not yet. A refusal that misdescribes what
|
|
346
|
+
// it saw sends the reader looking for the wrong thing.
|
|
347
|
+
const onlyEscapes = findings.every((f) => f.where === 'shell-escape');
|
|
348
|
+
const lines = [
|
|
349
|
+
`tyran write-guard: refusing this ${toolName} — it would ${onlyEscapes ? 'expand' : 'write'} ` +
|
|
350
|
+
`${findings.length} ${onlyEscapes ? 'escape sequence(s) into invisible character(s)' : 'raw control or invisible character(s)'}` +
|
|
351
|
+
`${onlyEscapes ? ' in a file' : ' into a file'}.`,
|
|
352
|
+
'',
|
|
353
|
+
];
|
|
354
|
+
for (const f of findings.slice(0, MAX_FINDINGS_SHOWN)) {
|
|
355
|
+
if (f.where === 'shell-escape') {
|
|
356
|
+
// Rendered here rather than through `formatFinding`, which knows three
|
|
357
|
+
// sites and would have labelled this one "in the symlink TARGET".
|
|
358
|
+
lines.push(
|
|
359
|
+
` ${f.field}: ${f.notation} would be expanded by the shell into ` +
|
|
360
|
+
`${formatCodePoint(f.hit.codePoint)}${f.hit.name ? ' ' + f.hit.name : ''} — ${f.hit.what}`,
|
|
361
|
+
);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
lines.push(` ${formatFinding(f.field, f.hit, f.where)}`);
|
|
365
|
+
}
|
|
366
|
+
if (findings.length > MAX_FINDINGS_SHOWN) {
|
|
367
|
+
lines.push(` ... and ${findings.length - MAX_FINDINGS_SHOWN} more`);
|
|
368
|
+
}
|
|
369
|
+
lines.push('');
|
|
370
|
+
if (onlyEscapes) {
|
|
371
|
+
lines.push(
|
|
372
|
+
'The command text is clean ASCII, but the SHELL would expand these notations into the real',
|
|
373
|
+
'characters at execution time, and the file on disk would carry them.',
|
|
374
|
+
'',
|
|
375
|
+
'If you meant to write the notation itself, quote it so the shell cannot expand it',
|
|
376
|
+
"(single quotes with printf %s, or a here-doc with a quoted delimiter).",
|
|
377
|
+
);
|
|
378
|
+
} else {
|
|
379
|
+
lines.push(
|
|
380
|
+
'These are RAW characters, not escape sequences. A writing or editing tool has almost certainly',
|
|
381
|
+
'turned the TEXT of an escape into the character itself — it happened nine times in one session',
|
|
382
|
+
'while this repository was being built, twice inside the code meant to prevent it.',
|
|
383
|
+
'',
|
|
384
|
+
'Write the escape notation instead, or build the character explicitly:',
|
|
385
|
+
' const NUL = String.fromCodePoint(0);',
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
// The deterrent, and the answer to "should a refusal name other tools".
|
|
389
|
+
//
|
|
390
|
+
// It should not, and this one never did — the ROUTE was proposed by the model
|
|
391
|
+
// itself, unprompted, in the same turn a Write was refused ("use Bash, which
|
|
392
|
+
// would bypass this guard"). Measured, and it worked. Removing helpful text
|
|
393
|
+
// would not have prevented that; the model reasoned about the tool surface,
|
|
394
|
+
// not about this message.
|
|
395
|
+
//
|
|
396
|
+
// So the fix is not silence, which would only leave a refusal with no way
|
|
397
|
+
// forward — the shape ADR-19 says produces someone looking for a way around.
|
|
398
|
+
// It is to say plainly that the rule is not tool-specific, so the obvious
|
|
399
|
+
// next idea is answered before it is tried. The remedy above stays, because
|
|
400
|
+
// it produces the CORRECT artefact (escape notation in source) rather than
|
|
401
|
+
// the same forbidden artefact through another door.
|
|
402
|
+
lines.push(
|
|
403
|
+
'',
|
|
404
|
+
'This rule is not specific to one tool: the same check runs on Write, Edit, NotebookEdit, Bash',
|
|
405
|
+
'and every MCP tool, and on a Bash command it also decodes escape sequences. Reaching for a',
|
|
406
|
+
'different tool is not a way around it.',
|
|
407
|
+
);
|
|
408
|
+
if (findings.some((f) => f.hit.codePoint === CARRIAGE_RETURN)) {
|
|
409
|
+
lines.push(
|
|
410
|
+
'',
|
|
411
|
+
'One of these is a CARRIAGE RETURN. If this file genuinely uses CRLF line endings, this gate',
|
|
412
|
+
'and the repository scanner both treat CR as forbidden — normalize the file (`git config',
|
|
413
|
+
'core.autocrlf`, or a .gitattributes `text=auto eol=lf` rule) rather than writing CR back in.',
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return lines.join('\n');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The verdict for one hook invocation. Pure: no I/O, no clock, no process, so
|
|
421
|
+
* the whole decision is testable without a runtime.
|
|
422
|
+
*/
|
|
423
|
+
export function judge(input, options = {}) {
|
|
424
|
+
const toolName = field(input, 'tool_name');
|
|
425
|
+
const toolInput = field(input, 'tool_input');
|
|
426
|
+
// `tool_input` absent entirely is not "nothing to check" — it is an input
|
|
427
|
+
// this gate does not recognise, arriving at a gate the matcher decided
|
|
428
|
+
// applies. Passing would be a guess; the honest answer is that there is
|
|
429
|
+
// nothing to scan only when the field is genuinely empty.
|
|
430
|
+
if (toolInput === undefined || toolInput === null) return PASS;
|
|
431
|
+
|
|
432
|
+
let findings;
|
|
433
|
+
try {
|
|
434
|
+
findings = scanToolInput(toolInput, { ...options, toolName: String(toolName ?? '') });
|
|
435
|
+
} catch (err) {
|
|
436
|
+
if (err instanceof CoverageFailure) {
|
|
437
|
+
return {
|
|
438
|
+
decision: 'deny',
|
|
439
|
+
reason:
|
|
440
|
+
`tyran write-guard: refusing this ${String(toolName)} because it could not scan the whole ` +
|
|
441
|
+
`input (${err.message}). A partial scan that reports nothing is indistinguishable from a ` +
|
|
442
|
+
'clean one, so this gate refuses rather than covering part of a payload. Split the write ' +
|
|
443
|
+
'into smaller steps.',
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
if (findings.length === 0) return PASS;
|
|
449
|
+
return { decision: 'deny', reason: refusalFor(String(toolName), findings) };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** See journal.mjs — both sides canonicalized, or a symlinked path no-ops. */
|
|
453
|
+
function canonicalPath(path) {
|
|
454
|
+
const abs = resolve(path);
|
|
455
|
+
try {
|
|
456
|
+
return realpathSync(abs);
|
|
457
|
+
} catch {
|
|
458
|
+
return abs;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isMainModule(moduleUrl) {
|
|
463
|
+
if (!process.argv[1]) return false;
|
|
464
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (isMainModule(import.meta.url)) {
|
|
468
|
+
await main(() =>
|
|
469
|
+
runGate({
|
|
470
|
+
event: 'PreToolUse',
|
|
471
|
+
deadlineMs: DEADLINE_MS,
|
|
472
|
+
handler: ({ input }) => judge(input),
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jjanczur/tyran",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line scripts for the Tyran task conductor (doctor, journal, schema, and repo tooling) for CI and terminals outside Claude Code. Installing this package does NOT install the Claude Code plugin — add that separately with `/plugin marketplace add jjanczur/tyran` inside Claude Code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"tyran": "./bin/tyran.mjs"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
".claude-plugin/",
|
|
17
|
+
"agents/",
|
|
18
|
+
"bin/",
|
|
19
|
+
"hooks/",
|
|
20
|
+
"scripts/",
|
|
21
|
+
"skills/",
|
|
22
|
+
"templates/",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Jacek Janczura",
|
|
29
|
+
"url": "https://github.com/jjanczur"
|
|
30
|
+
},
|
|
31
|
+
"license": "Apache-2.0",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/jjanczur/tyran.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/jjanczur/tyran#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/jjanczur/tyran/issues"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"agent-orchestration",
|
|
42
|
+
"ai-agents",
|
|
43
|
+
"claude-code",
|
|
44
|
+
"claude-code-plugin",
|
|
45
|
+
"code-quality",
|
|
46
|
+
"developer-tools",
|
|
47
|
+
"git-hooks",
|
|
48
|
+
"llm-agents",
|
|
49
|
+
"multi-agent",
|
|
50
|
+
"self-improving"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* desc-budget — CI guard against the "skill sprawl context tax".
|
|
4
|
+
*
|
|
5
|
+
* Every skill's frontmatter `description` is loaded into EVERY session's
|
|
6
|
+
* context. This script sums the description lengths across all skills and
|
|
7
|
+
* fails when the total exceeds the budget. The library may grow; the
|
|
8
|
+
* always-loaded context surface may not.
|
|
9
|
+
*
|
|
10
|
+
* Usage: node scripts/desc-budget.mjs [--budget <chars>] [pluginRoot]
|
|
11
|
+
* Exit: 0 within budget · 1 over budget · 2 usage/IO error
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { escapeInvisible } from './invisible.mjs';
|
|
15
|
+
import { join, dirname, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The ceiling, and the ONLY place it is written.
|
|
20
|
+
*
|
|
21
|
+
* It was 4000 while the library was eight skills. Raising it to 5000 for the
|
|
22
|
+
* six protocol skills is an OWNER decision, recorded in the changelog — which
|
|
23
|
+
* is the whole difference between this and the failure the README cites
|
|
24
|
+
* (oh-my-claudecode #2943, where the budget was exceeded rather than moved).
|
|
25
|
+
* An agent proposes a raise; it does not perform one.
|
|
26
|
+
*
|
|
27
|
+
* Exported so a test can pin it. Until this change the number lived here AND
|
|
28
|
+
* in `.github/workflows/ci.yml` as `--budget 4000`, which is the hand-copied
|
|
29
|
+
* constant `prompt-tuning` rule 7 calls a future lie: raising one leaves the
|
|
30
|
+
* other enforcing the old value, and the CI failure names a budget that no
|
|
31
|
+
* longer exists anywhere in the repo. The workflow now invokes the script
|
|
32
|
+
* bare, exactly as `CONTRIBUTING.md` already did.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_BUDGET = 5000;
|
|
35
|
+
|
|
36
|
+
export function parseFrontmatterDescription(markdown) {
|
|
37
|
+
// Frontmatter = first block delimited by lines that are exactly `---`.
|
|
38
|
+
const lines = markdown.split('\n');
|
|
39
|
+
if (lines[0]?.trim() !== '---') return null;
|
|
40
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
41
|
+
if (end === -1) return null;
|
|
42
|
+
const fm = lines.slice(1, end);
|
|
43
|
+
const idx = fm.findIndex((l) => /^description:/.test(l));
|
|
44
|
+
if (idx === -1) return null;
|
|
45
|
+
// Single-line value (possibly quoted) + YAML folded/literal continuation lines.
|
|
46
|
+
let value = fm[idx].replace(/^description:\s*/, '');
|
|
47
|
+
if (/^[>|][+-]?\s*$/.test(value.trim())) {
|
|
48
|
+
value = '';
|
|
49
|
+
for (let i = idx + 1; i < fm.length; i++) {
|
|
50
|
+
if (/^\s+\S/.test(fm[i])) value += fm[i].trim() + ' ';
|
|
51
|
+
else break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return value.replace(/^["']|["']$/g, '').trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function collectSkillDescriptions(pluginRoot) {
|
|
58
|
+
const skillsDir = join(pluginRoot, 'skills');
|
|
59
|
+
if (!existsSync(skillsDir)) return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const entry of readdirSync(skillsDir)) {
|
|
62
|
+
const skillMd = join(skillsDir, entry, 'SKILL.md');
|
|
63
|
+
if (statSync(join(skillsDir, entry)).isDirectory() && existsSync(skillMd)) {
|
|
64
|
+
const description = parseFrontmatterDescription(readFileSync(skillMd, 'utf8'));
|
|
65
|
+
out.push({ skill: entry, length: description ? description.length : 0, missing: description === null });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out.sort((a, b) => b.length - a.length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main() {
|
|
72
|
+
const args = process.argv.slice(2);
|
|
73
|
+
let budget = DEFAULT_BUDGET;
|
|
74
|
+
const bi = args.indexOf('--budget');
|
|
75
|
+
if (bi !== -1) {
|
|
76
|
+
budget = Number(args[bi + 1]);
|
|
77
|
+
if (!Number.isFinite(budget) || budget <= 0) {
|
|
78
|
+
console.error('desc-budget: --budget must be a positive number');
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
args.splice(bi, 2);
|
|
82
|
+
}
|
|
83
|
+
const root = resolve(args[0] ?? join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
84
|
+
|
|
85
|
+
const rows = collectSkillDescriptions(root);
|
|
86
|
+
const missing = rows.filter((r) => r.missing);
|
|
87
|
+
const total = rows.reduce((s, r) => s + r.length, 0);
|
|
88
|
+
|
|
89
|
+
for (const r of rows) {
|
|
90
|
+
// A skill directory name is attacker-controlled the moment a repo
|
|
91
|
+
// installs a third-party skill, and this line runs in CI where the output
|
|
92
|
+
// is read by whoever is debugging the build.
|
|
93
|
+
console.log(`${String(r.length).padStart(6)} ${escapeInvisible(r.skill)}${r.missing ? ' (MISSING description)' : ''}`);
|
|
94
|
+
}
|
|
95
|
+
console.log(`${String(total).padStart(6)} TOTAL (budget: ${budget})`);
|
|
96
|
+
|
|
97
|
+
if (missing.length > 0) {
|
|
98
|
+
console.error(`desc-budget: ${missing.length} skill(s) missing a description — every skill must declare one`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
if (total > budget) {
|
|
102
|
+
console.error(`desc-budget: total ${total} exceeds budget ${budget}. Trim descriptions or remove skills.`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Absolute, symlink-resolved path; falls back to the merely absolute form when
|
|
109
|
+
* realpath cannot follow argv[1] (the script directory was renamed after
|
|
110
|
+
* launch, a parent component is unreadable, or a launcher rewrote argv[1] to a
|
|
111
|
+
* logical name). Without the fallback the guard would throw out of module
|
|
112
|
+
* scope and kill the tool at startup — a different bug, not a fix.
|
|
113
|
+
*/
|
|
114
|
+
function canonicalPath(path) {
|
|
115
|
+
const abs = resolve(path);
|
|
116
|
+
try {
|
|
117
|
+
return realpathSync(abs);
|
|
118
|
+
} catch {
|
|
119
|
+
return abs;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* True when this module is the program's entry point.
|
|
125
|
+
*
|
|
126
|
+
* BOTH sides must be canonicalized. `import.meta.url` already names the real
|
|
127
|
+
* file — Node resolves module specifiers through symlinks — while
|
|
128
|
+
* `process.argv[1]` is whatever the caller typed. Comparing them raw turned
|
|
129
|
+
* every invocation through a symlinked path into a SILENT no-op under exit 0:
|
|
130
|
+
* `main()` never ran, nothing was summed, and CI read that as "within budget".
|
|
131
|
+
* `/tmp` and `/var` are symlinks on macOS and plugin installs reach `scripts/`
|
|
132
|
+
* through one, so this is the ordinary case rather than an exotic one.
|
|
133
|
+
*/
|
|
134
|
+
function isMainModule(moduleUrl) {
|
|
135
|
+
if (!process.argv[1]) return false;
|
|
136
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isMainModule(import.meta.url)) main();
|