@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,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* schema — validators for Tyran's repo data layer (`.tyran/`).
|
|
4
|
+
*
|
|
5
|
+
* Three file families, one rule each (see docs/configuration.md):
|
|
6
|
+
* config.yaml — how Tyran behaves in THIS repo
|
|
7
|
+
* knowledge/*.yaml — facts Tyran learned about THIS repo
|
|
8
|
+
* policies/*.yaml — what Tyran may do without asking
|
|
9
|
+
*
|
|
10
|
+
* Every inferred config field carries provenance so a human can audit where
|
|
11
|
+
* a value came from and how sure the scanner was.
|
|
12
|
+
*
|
|
13
|
+
* CLI: node schema.mjs validate <kind> <file...> kind: config|knowledge|policy
|
|
14
|
+
* Exit: 0 valid · 1 findings · 2 usage/IO error
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { parse, YamlLiteError } from './yaml-lite.mjs';
|
|
20
|
+
import { escapeInvisible } from './invisible.mjs';
|
|
21
|
+
|
|
22
|
+
export const PROFILES = Object.freeze(['eco', 'balanced', 'full']);
|
|
23
|
+
export const AUTONOMY_CLASSES = Object.freeze(['P1', 'P2', 'P3']);
|
|
24
|
+
/**
|
|
25
|
+
* Capability tiers, cheapest first. Four rather than three because "expensive"
|
|
26
|
+
* is not one thing: `deep` buys harder reasoning for root-cause work, `top`
|
|
27
|
+
* is reserved for the calls where being wrong is expensive AND hard to notice
|
|
28
|
+
* (security review, arbitration, acceptance). Collapsing them into one tier
|
|
29
|
+
* means every escalation pays the top price, which is how a cost mode stops
|
|
30
|
+
* being used at all.
|
|
31
|
+
*/
|
|
32
|
+
export const TIER_KEYS = Object.freeze(['cheap', 'work', 'deep', 'top']);
|
|
33
|
+
export const ARTIFACT_CLASSES = Object.freeze(['AUTO', 'GATED', 'KERNEL']);
|
|
34
|
+
export const KNOWLEDGE_KINDS = Object.freeze(['fact', 'convention', 'gotcha', 'command', 'decision']);
|
|
35
|
+
|
|
36
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
37
|
+
const isNonEmptyString = (v) => typeof v === 'string' && v.trim() !== '';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A provenance-carrying value: either a bare scalar (author-written) or
|
|
41
|
+
* `{value, source, confidence, needs_confirmation}` (scanner-inferred).
|
|
42
|
+
*/
|
|
43
|
+
function checkProvenanced(node, path, errors, { valueCheck, valueHint }) {
|
|
44
|
+
if (isPlainObject(node) && 'value' in node) {
|
|
45
|
+
if (!valueCheck(node.value)) errors.push(`${path}.value: ${valueHint}`);
|
|
46
|
+
if (!isNonEmptyString(node.source)) {
|
|
47
|
+
errors.push(`${path}.source: required for inferred values (file or command that proved it)`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof node.confidence !== 'number' || node.confidence < 0 || node.confidence > 1) {
|
|
50
|
+
errors.push(`${path}.confidence: must be a number in [0, 1]`);
|
|
51
|
+
}
|
|
52
|
+
if ('needs_confirmation' in node && typeof node.needs_confirmation !== 'boolean') {
|
|
53
|
+
errors.push(`${path}.needs_confirmation: must be a boolean`);
|
|
54
|
+
}
|
|
55
|
+
for (const key of Object.keys(node)) {
|
|
56
|
+
if (!['value', 'source', 'confidence', 'needs_confirmation'].includes(key)) {
|
|
57
|
+
errors.push(`${path}.${key}: unknown key in a provenanced value`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return node.value;
|
|
61
|
+
}
|
|
62
|
+
if (!valueCheck(node)) errors.push(`${path}: ${valueHint}`);
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Validate `.tyran/config.yaml`. */
|
|
67
|
+
export function validateConfig(doc) {
|
|
68
|
+
const errors = [];
|
|
69
|
+
if (!isPlainObject(doc)) return ['config must be a mapping'];
|
|
70
|
+
|
|
71
|
+
const known = ['profile', 'autonomy', 'tiers', 'validation', 'shared_zones', 'budget'];
|
|
72
|
+
for (const key of Object.keys(doc)) {
|
|
73
|
+
if (!known.includes(key)) errors.push(`${key}: unknown top-level key`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!('profile' in doc)) errors.push('profile: required (eco | balanced | full)');
|
|
77
|
+
else {
|
|
78
|
+
checkProvenanced(doc.profile, 'profile', errors, {
|
|
79
|
+
valueCheck: (v) => PROFILES.includes(v),
|
|
80
|
+
valueHint: `must be one of ${PROFILES.join(' | ')}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!('autonomy' in doc)) errors.push('autonomy: required (P1 | P2 | P3)');
|
|
85
|
+
else {
|
|
86
|
+
checkProvenanced(doc.autonomy, 'autonomy', errors, {
|
|
87
|
+
valueCheck: (v) => AUTONOMY_CLASSES.includes(v),
|
|
88
|
+
valueHint: `must be one of ${AUTONOMY_CLASSES.join(' | ')}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!('tiers' in doc)) errors.push(`tiers: required (${TIER_KEYS.join(', ')})`);
|
|
93
|
+
else if (!isPlainObject(doc.tiers)) errors.push('tiers: must be a mapping');
|
|
94
|
+
else {
|
|
95
|
+
for (const key of TIER_KEYS) {
|
|
96
|
+
if (!(key in doc.tiers)) errors.push(`tiers.${key}: required`);
|
|
97
|
+
else if (!isNonEmptyString(doc.tiers[key])) errors.push(`tiers.${key}: must be a model alias string`);
|
|
98
|
+
}
|
|
99
|
+
for (const key of Object.keys(doc.tiers)) {
|
|
100
|
+
if (!TIER_KEYS.includes(key)) errors.push(`tiers.${key}: unknown tier (allowed: ${TIER_KEYS.join(', ')})`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ('validation' in doc) {
|
|
105
|
+
const list = checkProvenanced(doc.validation, 'validation', errors, {
|
|
106
|
+
valueCheck: (v) => Array.isArray(v),
|
|
107
|
+
valueHint: 'must be a list of shell commands',
|
|
108
|
+
});
|
|
109
|
+
if (Array.isArray(list)) {
|
|
110
|
+
list.forEach((cmd, i) => {
|
|
111
|
+
if (!isNonEmptyString(cmd)) errors.push(`validation[${i}]: must be a non-empty command string`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if ('shared_zones' in doc) {
|
|
117
|
+
if (!Array.isArray(doc.shared_zones)) errors.push('shared_zones: must be a list of path globs');
|
|
118
|
+
else {
|
|
119
|
+
doc.shared_zones.forEach((z, i) => {
|
|
120
|
+
if (!isNonEmptyString(z)) errors.push(`shared_zones[${i}]: must be a non-empty path glob`);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if ('budget' in doc) {
|
|
126
|
+
if (!isPlainObject(doc.budget)) errors.push('budget: must be a mapping');
|
|
127
|
+
else {
|
|
128
|
+
for (const [k, v] of Object.entries(doc.budget)) {
|
|
129
|
+
if (typeof v !== 'number' || v <= 0) errors.push(`budget.${k}: must be a positive number`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return errors;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Validate a knowledge file: `entries:` list of learned facts.
|
|
139
|
+
* Schema adapted from the best pattern found in the wild (metaswarm):
|
|
140
|
+
* provenance + confidence + usage counters, so entries that stop earning
|
|
141
|
+
* their keep can be degraded or retired by a later retrospective.
|
|
142
|
+
*/
|
|
143
|
+
export function validateKnowledge(doc) {
|
|
144
|
+
const errors = [];
|
|
145
|
+
if (!isPlainObject(doc)) return ['knowledge file must be a mapping with an "entries" list'];
|
|
146
|
+
if (!Array.isArray(doc.entries)) return ['entries: required, must be a list'];
|
|
147
|
+
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
doc.entries.forEach((entry, i) => {
|
|
150
|
+
const at = `entries[${i}]`;
|
|
151
|
+
if (!isPlainObject(entry)) {
|
|
152
|
+
errors.push(`${at}: must be a mapping`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!isNonEmptyString(entry.id)) errors.push(`${at}.id: required, non-empty string`);
|
|
156
|
+
else if (seen.has(entry.id)) errors.push(`${at}.id: duplicate id "${entry.id}"`);
|
|
157
|
+
else seen.add(entry.id);
|
|
158
|
+
|
|
159
|
+
if (!KNOWLEDGE_KINDS.includes(entry.kind)) {
|
|
160
|
+
errors.push(`${at}.kind: must be one of ${KNOWLEDGE_KINDS.join(' | ')}`);
|
|
161
|
+
}
|
|
162
|
+
if (!isNonEmptyString(entry.text)) errors.push(`${at}.text: required, non-empty string`);
|
|
163
|
+
if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 1) {
|
|
164
|
+
errors.push(`${at}.confidence: must be a number in [0, 1]`);
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(entry.provenance) || entry.provenance.length === 0) {
|
|
167
|
+
errors.push(`${at}.provenance: required, at least one {source, reference} entry`);
|
|
168
|
+
} else {
|
|
169
|
+
entry.provenance.forEach((p, j) => {
|
|
170
|
+
if (!isPlainObject(p) || !isNonEmptyString(p.source)) {
|
|
171
|
+
errors.push(`${at}.provenance[${j}].source: required (where this was learned)`);
|
|
172
|
+
} else if (!isNonEmptyString(p.reference)) {
|
|
173
|
+
errors.push(`${at}.provenance[${j}].reference: required (which run/file/commit proved it)`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
for (const counter of ['used', 'helpful', 'outdated_reports']) {
|
|
178
|
+
if (counter in entry && (!Number.isInteger(entry[counter]) || entry[counter] < 0)) {
|
|
179
|
+
errors.push(`${at}.${counter}: must be a non-negative integer`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if ('applies_to' in entry && !Array.isArray(entry.applies_to)) {
|
|
183
|
+
errors.push(`${at}.applies_to: must be a list of path globs`);
|
|
184
|
+
}
|
|
185
|
+
if ('supersedes' in entry && !isNonEmptyString(entry.supersedes)) {
|
|
186
|
+
errors.push(`${at}.supersedes: must be an entry id`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return errors;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Paths that MUST be classified KERNEL. A policy that downgrades any of
|
|
194
|
+
* these would let the self-improvement loop disable its own enforcement,
|
|
195
|
+
* so the validator rejects it — the boundary cannot be edited away, only
|
|
196
|
+
* tightened (review E2S2-R6).
|
|
197
|
+
*/
|
|
198
|
+
export const MANDATORY_KERNEL_PATHS = Object.freeze(['hooks/**', '.tyran/policies/**']);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validate `policies/autonomy.yaml`: which paths the retro agent may write
|
|
202
|
+
* autonomously (AUTO), which need approval (GATED), which are untouchable
|
|
203
|
+
* (KERNEL). Enforced later by a PreToolUse hook — this validator makes sure
|
|
204
|
+
* the file itself can never be ambiguous:
|
|
205
|
+
* - the mandatory KERNEL paths are present and classified KERNEL;
|
|
206
|
+
* - a `default` class exists, so an unmatched path has a defined answer;
|
|
207
|
+
* - precedence is explicit (most specific rule wins — longest path glob).
|
|
208
|
+
*/
|
|
209
|
+
export function validatePolicy(doc) {
|
|
210
|
+
const errors = [];
|
|
211
|
+
if (!isPlainObject(doc)) return ['policy must be a mapping'];
|
|
212
|
+
if (!Array.isArray(doc.rules)) return ['rules: required, must be a list'];
|
|
213
|
+
|
|
214
|
+
if (!('default' in doc)) {
|
|
215
|
+
errors.push("default: required — the class applied to paths no rule matches (use 'GATED' when unsure)");
|
|
216
|
+
} else if (!ARTIFACT_CLASSES.includes(doc.default)) {
|
|
217
|
+
errors.push(`default: must be one of ${ARTIFACT_CLASSES.join(' | ')}`);
|
|
218
|
+
}
|
|
219
|
+
for (const key of Object.keys(doc)) {
|
|
220
|
+
if (!['rules', 'default'].includes(key)) errors.push(`${key}: unknown top-level key`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const seenPaths = new Set();
|
|
224
|
+
const classByPath = new Map();
|
|
225
|
+
doc.rules.forEach((rule, i) => {
|
|
226
|
+
const at = `rules[${i}]`;
|
|
227
|
+
if (!isPlainObject(rule)) {
|
|
228
|
+
errors.push(`${at}: must be a mapping`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!isNonEmptyString(rule.path)) errors.push(`${at}.path: required path glob`);
|
|
232
|
+
else if (seenPaths.has(rule.path)) errors.push(`${at}.path: duplicate rule for "${rule.path}"`);
|
|
233
|
+
else {
|
|
234
|
+
seenPaths.add(rule.path);
|
|
235
|
+
classByPath.set(rule.path, rule.class);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!ARTIFACT_CLASSES.includes(rule.class)) {
|
|
239
|
+
errors.push(`${at}.class: must be one of ${ARTIFACT_CLASSES.join(' | ')}`);
|
|
240
|
+
}
|
|
241
|
+
if (!isNonEmptyString(rule.reason)) {
|
|
242
|
+
errors.push(`${at}.reason: required — every boundary must say why it exists`);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
for (const required of MANDATORY_KERNEL_PATHS) {
|
|
247
|
+
if (!classByPath.has(required)) {
|
|
248
|
+
errors.push(`rules: a rule for "${required}" is required and must be class KERNEL`);
|
|
249
|
+
} else if (classByPath.get(required) !== 'KERNEL') {
|
|
250
|
+
errors.push(
|
|
251
|
+
`rules: "${required}" is classified ${classByPath.get(required)} — it must be KERNEL ` +
|
|
252
|
+
'(a system that can disable its own gates has none)',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Presence of the literal rule is not enough: a MORE SPECIFIC non-KERNEL
|
|
258
|
+
// rule would out-rank it under classifyPath's precedence and hand the
|
|
259
|
+
// enforcement mechanism to AUTO with a green CI (review E2S2-R9).
|
|
260
|
+
// Validate the EFFECT, not the spelling.
|
|
261
|
+
for (const rule of doc.rules) {
|
|
262
|
+
if (!isPlainObject(rule) || !isNonEmptyString(rule.path) || rule.class === 'KERNEL') continue;
|
|
263
|
+
for (const required of MANDATORY_KERNEL_PATHS) {
|
|
264
|
+
// Intersection, not string containment: a rule matching ANY concrete
|
|
265
|
+
// path under the protected glob is a downgrade attempt, however it is
|
|
266
|
+
// spelled (review E2S2-R10).
|
|
267
|
+
// Intersect in both directions: concretize the rule INTO the protected
|
|
268
|
+
// namespace (`**/x.mjs` → `hooks/x.mjs`) and probe the protected glob
|
|
269
|
+
// against the rule. Either hit means the rule can claim a kernel file.
|
|
270
|
+
const base = required.replace(/\/?\*\*$/, '');
|
|
271
|
+
const intoNamespace = [rule.path.replace(/\*\*/g, base), rule.path.replace(/\*\*/g, `${base}/a`)];
|
|
272
|
+
if (
|
|
273
|
+
globMatches(required, concretize(rule.path)) ||
|
|
274
|
+
intoNamespace.some((candidate) => globMatches(required, candidate.replace(/\*/g, 'x'))) ||
|
|
275
|
+
probesFor(required).some((probe) => globMatches(rule.path, probe))
|
|
276
|
+
) {
|
|
277
|
+
errors.push(
|
|
278
|
+
`rules: "${rule.path}" (class ${rule.class}) falls under the protected path "${required}" — ` +
|
|
279
|
+
'kernel paths may only be tightened, never downgraded by a more specific rule',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Belt and braces: probe the resolver itself, so any future change to
|
|
286
|
+
// precedence that reopens this hole fails here.
|
|
287
|
+
if (errors.length === 0) {
|
|
288
|
+
for (const probe of MANDATORY_KERNEL_PATHS.flatMap(probesFor)) {
|
|
289
|
+
if (classifyPath(doc, probe) !== 'KERNEL') {
|
|
290
|
+
errors.push(`rules: "${probe}" resolves to ${classifyPath(doc, probe)}, but protected paths must resolve to KERNEL`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return errors;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Concrete sample paths under a protected glob, at several depths. */
|
|
298
|
+
function probesFor(protectedGlob) {
|
|
299
|
+
const base = protectedGlob.replace(/\/?\*\*$/, '');
|
|
300
|
+
return [`${base}/probe.mjs`, `${base}/a/probe.mjs`, `${base}/a/b/probe.yaml`, `${base}/autonomy.yaml`];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Replace wildcards with concrete segments so a glob can be tested for containment. */
|
|
304
|
+
function concretize(glob) {
|
|
305
|
+
return glob.replace(/\*\*/g, '__any__/__any__').replace(/(?<!_)\*(?!_)/g, '__any__').replace(/__any__/g, 'x');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolve which class applies to a path. Precedence: the most specific
|
|
310
|
+
* matching rule wins, measured by glob length; ties go to the stricter
|
|
311
|
+
* class. Unmatched paths fall back to `default`. Exported so the future
|
|
312
|
+
* PreToolUse hook and `doctor` share exactly one implementation.
|
|
313
|
+
*/
|
|
314
|
+
export function classifyPath(policy, filePath) {
|
|
315
|
+
const strictness = { AUTO: 0, GATED: 1, KERNEL: 2 };
|
|
316
|
+
const normalized = normalizePath(filePath);
|
|
317
|
+
if (normalized === null) return 'KERNEL'; // outside the repo → never autonomous
|
|
318
|
+
// Protected paths win unconditionally, BEFORE any rule is consulted: no
|
|
319
|
+
// glob spelling (`**/policy-gate.mjs`, casing, nesting) can outrank them
|
|
320
|
+
// (review E2S2-R10). validatePolicy still reports such rules as findings.
|
|
321
|
+
for (const protectedGlob of MANDATORY_KERNEL_PATHS) {
|
|
322
|
+
if (globMatches(protectedGlob, normalized)) return 'KERNEL';
|
|
323
|
+
}
|
|
324
|
+
let best = null;
|
|
325
|
+
for (const rule of policy.rules ?? []) {
|
|
326
|
+
if (!isNonEmptyString(rule.path) || !globMatches(rule.path, normalized)) continue;
|
|
327
|
+
if (
|
|
328
|
+
best === null ||
|
|
329
|
+
rule.path.length > best.path.length ||
|
|
330
|
+
(rule.path.length === best.path.length && strictness[rule.class] > strictness[best.class])
|
|
331
|
+
) {
|
|
332
|
+
best = rule;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return best ? best.class : (policy.default ?? 'GATED');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Normalize to a repo-relative POSIX path before matching. A hook receives
|
|
340
|
+
* whatever form the tool used (`./hooks/x`, an absolute path, Windows
|
|
341
|
+
* separators); without this, the same file could resolve to two different
|
|
342
|
+
* classes and the gate would fail open (review E2S2-R9b).
|
|
343
|
+
* Returns null when the path escapes the repo root.
|
|
344
|
+
*/
|
|
345
|
+
export function normalizePath(filePath, repoRoot = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()) {
|
|
346
|
+
let p = String(filePath).replace(/\\/g, '/');
|
|
347
|
+
const root = String(repoRoot).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
348
|
+
if (p.startsWith(root + '/')) p = p.slice(root.length + 1);
|
|
349
|
+
else if (p.startsWith('/') || /^[A-Za-z]:\//.test(p)) return null; // absolute, outside the repo
|
|
350
|
+
const segments = [];
|
|
351
|
+
for (const segment of p.split('/')) {
|
|
352
|
+
if (segment === '' || segment === '.') continue;
|
|
353
|
+
if (segment === '..') {
|
|
354
|
+
if (segments.length === 0) return null;
|
|
355
|
+
segments.pop();
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
segments.push(segment);
|
|
359
|
+
}
|
|
360
|
+
return segments.join('/');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Minimal glob: `**` spans separators, `*` does not.
|
|
365
|
+
*
|
|
366
|
+
* Exported so a caller can ask WHICH glob matched, which `classifyPath` cannot
|
|
367
|
+
* answer: it applies `MANDATORY_KERNEL_PATHS` unconditionally and returns a
|
|
368
|
+
* class, so probing it with a one-rule policy reports the first protected glob
|
|
369
|
+
* for every protected path. The policy gate needs the real answer to name the
|
|
370
|
+
* right authority in a refusal, and the only alternative was a fourth spelling
|
|
371
|
+
* of glob matching in a repository that already counted three spellings of one
|
|
372
|
+
* rule (ADR-21).
|
|
373
|
+
*/
|
|
374
|
+
export function globMatches(glob, filePath) {
|
|
375
|
+
const pattern = glob
|
|
376
|
+
.split('**')
|
|
377
|
+
.map((part) => part.split('*').map(escapeRegExp).join('[^/]*'))
|
|
378
|
+
.join('.*');
|
|
379
|
+
// Case-insensitive on purpose: on case-insensitive filesystems (macOS,
|
|
380
|
+
// Windows) `HOOKS/x` and `hooks/x` are the same file, and a security
|
|
381
|
+
// classifier must fail closed rather than let casing pick a weaker class.
|
|
382
|
+
return new RegExp(`^${pattern}$`, 'i').test(filePath);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function escapeRegExp(s) {
|
|
386
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const VALIDATORS = { config: validateConfig, knowledge: validateKnowledge, policy: validatePolicy };
|
|
390
|
+
|
|
391
|
+
/** Parse + validate a file. Returns {ok, errors}. */
|
|
392
|
+
export function validateFile(kind, file) {
|
|
393
|
+
const validator = VALIDATORS[kind];
|
|
394
|
+
if (!validator) return { ok: false, errors: [`unknown kind "${kind}" (config | knowledge | policy)`] };
|
|
395
|
+
if (!existsSync(file)) return { ok: false, errors: [`file not found: ${file}`] };
|
|
396
|
+
let doc;
|
|
397
|
+
try {
|
|
398
|
+
doc = parse(readFileSync(file, 'utf8'));
|
|
399
|
+
} catch (err) {
|
|
400
|
+
// Any parse failure is a finding, never a crash — pathological input
|
|
401
|
+
// (e.g. deep nesting → RangeError) must not escape as a stack trace
|
|
402
|
+
// (review E2S2-R8).
|
|
403
|
+
if (err instanceof YamlLiteError) return { ok: false, errors: [`YAML: ${err.message}`] };
|
|
404
|
+
return { ok: false, errors: [`YAML: unparseable (${err.name}: ${err.message})`] };
|
|
405
|
+
}
|
|
406
|
+
const errors = validator(doc);
|
|
407
|
+
return { ok: errors.length === 0, errors, doc };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------- CLI
|
|
411
|
+
|
|
412
|
+
function main() {
|
|
413
|
+
const [cmd, kind, ...files] = process.argv.slice(2);
|
|
414
|
+
if (cmd !== 'validate' || !kind || files.length === 0) {
|
|
415
|
+
console.error('usage: schema.mjs validate <config|knowledge|policy> <file...>');
|
|
416
|
+
process.exit(2);
|
|
417
|
+
}
|
|
418
|
+
let failed = false;
|
|
419
|
+
for (const file of files) {
|
|
420
|
+
const { ok, errors } = validateFile(kind, file);
|
|
421
|
+
if (ok) {
|
|
422
|
+
console.log(`ok ${escapeInvisible(file)}`);
|
|
423
|
+
} else {
|
|
424
|
+
failed = true;
|
|
425
|
+
console.log(`FAIL ${escapeInvisible(file)}`);
|
|
426
|
+
// Validation messages quote values read out of a YAML file, and a
|
|
427
|
+
// .tyran/ tree can come from a template someone else wrote. Same channel
|
|
428
|
+
// as project.warnings() and the journal CLI, closed the same way.
|
|
429
|
+
for (const e of errors) console.log(` - ${escapeInvisible(String(e))}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
process.exit(failed ? 1 : 0);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Absolute, symlink-resolved path; falls back to the merely absolute form when
|
|
437
|
+
* realpath cannot follow argv[1] (the script directory was renamed after
|
|
438
|
+
* launch, a parent component is unreadable, or a launcher rewrote argv[1] to a
|
|
439
|
+
* logical name). Without the fallback the guard would throw out of module
|
|
440
|
+
* scope and kill the tool at startup — a different bug, not a fix.
|
|
441
|
+
*/
|
|
442
|
+
function canonicalPath(path) {
|
|
443
|
+
const abs = resolve(path);
|
|
444
|
+
try {
|
|
445
|
+
return realpathSync(abs);
|
|
446
|
+
} catch {
|
|
447
|
+
return abs;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* True when this module is the program's entry point.
|
|
453
|
+
*
|
|
454
|
+
* BOTH sides must be canonicalized. `import.meta.url` already names the real
|
|
455
|
+
* file — Node resolves module specifiers through symlinks — while
|
|
456
|
+
* `process.argv[1]` is whatever the caller typed. Comparing them raw turned
|
|
457
|
+
* every invocation through a symlinked path into a SILENT no-op under exit 0:
|
|
458
|
+
* `main()` never ran, no file was validated, and CI read that as "valid".
|
|
459
|
+
* `/tmp` and `/var` are symlinks on macOS and plugin installs reach `scripts/`
|
|
460
|
+
* through one, so this is the ordinary case rather than an exotic one.
|
|
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)) main();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stop-check — the operator's brake on a running initiative.
|
|
4
|
+
*
|
|
5
|
+
* Create `.tyran/STOP` (any content; the first line is read back as the
|
|
6
|
+
* reason) and the conductor halts before its next spawn or merge. Delete it
|
|
7
|
+
* and work resumes. The value is that it needs no session: an operator who
|
|
8
|
+
* sees an overnight run going wrong at 3am can stop it from a phone with a
|
|
9
|
+
* one-line commit, and does not have to kill a process and lose the state
|
|
10
|
+
* that would explain what happened.
|
|
11
|
+
*
|
|
12
|
+
* The idea is borrowed from pro-workflow's file kill-switch; the code and the
|
|
13
|
+
* semantics here are our own.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS IS NOT: a lock. `.tyran/STOP` is classified KERNEL in the shipped
|
|
16
|
+
* policy, so the write guard refuses an agent that tries to delete it through
|
|
17
|
+
* a writing tool — but the policy gate's own documented limits apply, and a
|
|
18
|
+
* shell command that removes the directory without naming the file is one of
|
|
19
|
+
* them. It stops a loop that is misbehaving. It does not stop one that is
|
|
20
|
+
* trying to escape.
|
|
21
|
+
*
|
|
22
|
+
* CLI:
|
|
23
|
+
* node stop-check.mjs [--dir <repo>] # exit 0 clear · 1 stopped · 2 error
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, existsSync, statSync, realpathSync } from 'node:fs';
|
|
26
|
+
import { join, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { escapeInvisible } from './invisible.mjs';
|
|
29
|
+
|
|
30
|
+
export const STOP_PATH = join('.tyran', 'STOP');
|
|
31
|
+
|
|
32
|
+
/** How much of the file is read back. A reason is a sentence, not a payload. */
|
|
33
|
+
const REASON_BYTES = 2000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns `{stopped, reason, path}`.
|
|
37
|
+
*
|
|
38
|
+
* An unreadable STOP file counts as STOPPED, not as absent. Every other
|
|
39
|
+
* reading in this codebase fails open because a broken gate must not block
|
|
40
|
+
* ordinary work; this one fails CLOSED, because the file's only purpose is
|
|
41
|
+
* to say "do not continue" and the operator who created it is not around to
|
|
42
|
+
* be asked. A brake that releases itself when it is damaged is not a brake.
|
|
43
|
+
*/
|
|
44
|
+
export function checkStop(repoDir) {
|
|
45
|
+
const path = join(repoDir, STOP_PATH);
|
|
46
|
+
if (!existsSync(path)) return { stopped: false, reason: null, path };
|
|
47
|
+
try {
|
|
48
|
+
if (statSync(path).isDirectory()) {
|
|
49
|
+
return { stopped: true, reason: '(STOP exists but is a directory)', path };
|
|
50
|
+
}
|
|
51
|
+
const first = readFileSync(path, 'utf8').slice(0, REASON_BYTES).split('\n')[0].trim();
|
|
52
|
+
return { stopped: true, reason: first.length > 0 ? first : '(no reason given)', path };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { stopped: true, reason: `(STOP exists but could not be read: ${error.code ?? 'unknown'})`, path };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
const args = process.argv.slice(2);
|
|
60
|
+
const di = args.indexOf('--dir');
|
|
61
|
+
const dir = resolve(di === -1 ? process.cwd() : (args[di + 1] ?? process.cwd()));
|
|
62
|
+
|
|
63
|
+
const { stopped, reason, path } = checkStop(dir);
|
|
64
|
+
if (!stopped) {
|
|
65
|
+
console.log(`stop-check: clear (no ${STOP_PATH})`);
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
// The reason is operator-authored text on its way into a terminal that a
|
|
69
|
+
// half-awake reader is scanning; escape it like any other untrusted string.
|
|
70
|
+
console.error(`stop-check: STOPPED by ${escapeInvisible(path)}`);
|
|
71
|
+
console.error(` reason: ${escapeInvisible(reason)}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function canonicalPath(path) {
|
|
76
|
+
const abs = resolve(path);
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(abs);
|
|
79
|
+
} catch {
|
|
80
|
+
return abs;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isMainModule(moduleUrl) {
|
|
85
|
+
if (!process.argv[1]) return false;
|
|
86
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isMainModule(import.meta.url)) main();
|