cabloy 5.1.214 → 5.1.216

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/agent-governance.yml +19 -0
  3. package/CHANGELOG.md +16 -0
  4. package/package.json +3 -1
  5. package/repo-agent-governance/managed-assets.json +9 -9
  6. package/repo-agent-governance/policies/repository.md +1 -0
  7. package/repo-agent-governance/scripts/pack-check.mjs +1 -0
  8. package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +28 -1
  9. package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +2 -0
  10. package/repo-agent-governance/tests/evidence-paths.test.mjs +736 -0
  11. package/repo-agent-governance/tests/governance.test.mjs +25 -0
  12. package/repo-agent-governance/tools/evidence-paths/sanitize.mjs +549 -0
  13. package/repo-docs/ai/playbook-spec-execution.md +20 -2
  14. package/repo-docs/reference/repo-scripts.md +66 -0
  15. package/scripts/bootstrapAgentGovernance.mjs +2 -0
  16. package/scripts/upgrade.ts +2 -0
  17. package/vona/packages-cli/cli/package.json +1 -1
  18. package/vona/packages-cli/cli-set-api/package.json +1 -1
  19. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +4 -4
  20. /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoBaseName%>.tsx_ → {{detailDtoBaseName}}.tsx_} +0 -0
  21. /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoMutateName%>.tsx_ → {{detailDtoMutateName}}.tsx_} +0 -0
  22. /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoResItemName%>.tsx_ → {{detailDtoResItemName}}.tsx_} +0 -0
  23. /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoViewName%>.tsx_ → {{detailDtoViewName}}.tsx_} +0 -0
@@ -147,6 +147,23 @@ test('governance adoption creates state and is idempotent', () => {
147
147
  });
148
148
  });
149
149
 
150
+ test('evidence commands are distributed through both governance reconciliation paths', () => {
151
+ const packageJson = JSON.parse(readFileSync(resolve(ROOT_DIR, 'package.json'), 'utf8'));
152
+ const bootstrap = readFileSync(resolve(ROOT_DIR, 'scripts/bootstrapAgentGovernance.mjs'), 'utf8');
153
+ const upgrade = readFileSync(resolve(ROOT_DIR, 'scripts/upgrade.ts'), 'utf8');
154
+ const bootstrapList = bootstrap.match(/const GOVERNANCE_SCRIPT_NAMES = \[([\s\S]*?)\];/)[1];
155
+ const upgradeList = upgrade.match(/const governanceScripts = \[([\s\S]*?)\];/)[1];
156
+ for (const mode of ['sanitize', 'check']) {
157
+ const name = `evidence:${mode}`;
158
+ assert.equal(
159
+ packageJson.scripts[name],
160
+ `node repo-agent-governance/tools/evidence-paths/sanitize.mjs ${mode}`,
161
+ );
162
+ assert.ok(bootstrapList.includes(`'${name}'`));
163
+ assert.ok(upgradeList.includes(`'${name}'`));
164
+ }
165
+ });
166
+
150
167
  test('initializer adopts canonical adapters for a fresh transition package', () => {
151
168
  withFixture(root => {
152
169
  const scriptsDirectory = resolve(root, 'scripts');
@@ -179,6 +196,8 @@ test('initializer preserves a legacy-customized adapter during a staged upgrade'
179
196
  writeFileSync(resolve(root, 'CLAUDE.md'), 'legacy project customization\n');
180
197
  const projectPackage = JSON.parse(readFileSync(resolve(ROOT_DIR, 'package.json'), 'utf8'));
181
198
  delete projectPackage.scripts['spec:check'];
199
+ delete projectPackage.scripts['evidence:sanitize'];
200
+ projectPackage.scripts['evidence:check'] = 'node obsolete-evidence-check.mjs';
182
201
  projectPackage.scripts['project:custom'] = 'node project-owned.mjs';
183
202
  writeFileSync(resolve(root, 'package.json'), JSON.stringify(projectPackage));
184
203
 
@@ -207,6 +226,12 @@ test('initializer preserves a legacy-customized adapter during a staged upgrade'
207
226
  updatedPackage.scripts['spec:check'],
208
227
  'node ./repo-agent-governance/tools/spec-audit/audit.mjs',
209
228
  );
229
+ for (const mode of ['sanitize', 'check']) {
230
+ assert.equal(
231
+ updatedPackage.scripts[`evidence:${mode}`],
232
+ `node repo-agent-governance/tools/evidence-paths/sanitize.mjs ${mode}`,
233
+ );
234
+ }
210
235
  assert.equal(updatedPackage.scripts['project:custom'], 'node project-owned.mjs');
211
236
  });
212
237
  });
@@ -0,0 +1,549 @@
1
+ import { constants, realpathSync } from 'node:fs';
2
+ import { lstat, open, readdir } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { basename, join, parse, resolve, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const DEFAULT_REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../../..');
8
+ export const LINE_LIMIT = 1024 * 1024;
9
+ export const FILE_LIMIT = 8 * 1024 * 1024;
10
+
11
+ class SafeError extends Error {
12
+ constructor(code) {
13
+ super(code);
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ function fail(code) {
19
+ throw new SafeError(code);
20
+ }
21
+
22
+ function rootInfo(value) {
23
+ if (
24
+ typeof value !== 'string' ||
25
+ Array.from(value).some(char => char.codePointAt(0) < 32 || char.codePointAt(0) === 127)
26
+ ) {
27
+ fail('INVALID_ROOT');
28
+ }
29
+ const windows = /^[A-Za-z]:[/\\]/u.test(value) || /^\\\\[^\\]+\\[^\\]+/u.test(value);
30
+ if (!windows && !value.startsWith('/')) fail('INVALID_ROOT');
31
+ let root = windows ? value.replaceAll('\\', '/') : value;
32
+ root = root.replace(/\/+$/u, '');
33
+ // Volume roots would turn a bounded recognizer into a universal slash matcher.
34
+ if (
35
+ !root ||
36
+ /^[A-Za-z]:$/u.test(root) ||
37
+ root.split('/').some(part => part === '.' || part === '..')
38
+ ) {
39
+ fail('INVALID_ROOT');
40
+ }
41
+ return { root, windows, key: windows ? root.toLowerCase() : root };
42
+ }
43
+
44
+ function escapeRegex(value) {
45
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
46
+ }
47
+
48
+ function rootPattern(info) {
49
+ const pieces = info.root.split('/').map(escapeRegex);
50
+ // A separator may be native, forward-slash, or a JSON-escaped backslash.
51
+ return pieces.join(info.windows ? '(?:\\\\{1,2}|/)' : '/');
52
+ }
53
+
54
+ function nativeRoots() {
55
+ const roots = [];
56
+ for (const value of [homedir(), tmpdir()]) {
57
+ if (!value) continue;
58
+ roots.push(value);
59
+ try {
60
+ roots.push(realpathSync(value));
61
+ } catch {
62
+ // Lexical coverage remains available when a native root does not exist.
63
+ }
64
+ }
65
+ return roots;
66
+ }
67
+
68
+ function pathEnd(text, start, quoted) {
69
+ let end = start;
70
+ while (end < text.length) {
71
+ const char = text[end];
72
+ if (/[\r\n\t"'`<>(){}[\]|,;!?]/u.test(char) || (!quoted && /\s/u.test(char))) break;
73
+ // Preserve stack line/column suffixes (and keep other colon-delimited prose out).
74
+ if (char === ':') break;
75
+ end++;
76
+ }
77
+ // Sentence punctuation is not part of an unquoted path token.
78
+ if (!quoted) {
79
+ while (end > start && text[end - 1] === '.') end--;
80
+ }
81
+ return end;
82
+ }
83
+
84
+ function boundary(text, index) {
85
+ return index === 0 || !/[\p{L}\p{N}_./\\%~@+\->]/u.test(text[index - 1]);
86
+ }
87
+
88
+ function normalizeTail(value, windows) {
89
+ return windows ? value.replace(/\\{1,2}/gu, '/') : value;
90
+ }
91
+
92
+ /** Shared, bounded recognition. No universal absolute-path detection is attempted. */
93
+ export function createMatcher({ repoRoot = DEFAULT_REPO_ROOT, maps = [] } = {}) {
94
+ const explicit = [];
95
+ const seen = new Map();
96
+ function add(value, alias, privateRoot = false) {
97
+ const info = rootInfo(value);
98
+ const prior = seen.get(info.key);
99
+ if (prior) {
100
+ if (!privateRoot && prior.alias !== alias) fail('CONFLICTING_ROOT');
101
+ return;
102
+ }
103
+ const entry = { ...info, alias, privateRoot };
104
+ entry.regex = new RegExp(rootPattern(info), info.windows ? 'iy' : 'y');
105
+ explicit.push(entry);
106
+ seen.set(info.key, entry);
107
+ }
108
+ add(repoRoot, null);
109
+ if (!Array.isArray(maps)) fail('INVALID_MAP');
110
+ for (const map of maps) {
111
+ if (!map || typeof map.alias !== 'string') fail('INVALID_MAP');
112
+ const alias = map.alias.replace(/^<([A-Z][A-Z0-9_]*)>$/u, '$1');
113
+ if (!/^[A-Z][A-Z0-9_]*$/u.test(alias)) fail('INVALID_ALIAS');
114
+ add(map.root, alias);
115
+ }
116
+ for (const root of nativeRoots()) add(root, 'PRIVATE_PATH', true);
117
+ explicit.sort(
118
+ (a, b) => b.root.length - a.root.length || Number(a.privateRoot) - Number(b.privateRoot),
119
+ );
120
+
121
+ const sep = '(?:\\\\{1,2}|/)';
122
+ const account = '[^/\\\\\\s"\'`<>(){}[\\]|,;:!?]+(?: [^/\\\\\\s"\'`<>(){}[\\]|,;:!?]+)*';
123
+ const known = [
124
+ new RegExp(`/(?:Users|home)/${account}`, 'y'),
125
+ /\/(?:private\/)?tmp/y,
126
+ /\/(?:private\/)?var\/folders/y,
127
+ new RegExp(`[A-Z]:${sep}(?:Users${sep}${account}|(?:Windows${sep})?Temp)`, 'iy'),
128
+ // UNC is a filesystem convention; forward-slash protocol-relative URLs are not.
129
+ /\\{2,4}[^\\/\s"'`<>(){}[\]|,;:!?]+\\{1,2}[^\\/\s"'`<>(){}[\]|,;:!?]+/y,
130
+ ];
131
+
132
+ function recognizeAt(text, start, quoted = false) {
133
+ for (const entry of explicit) {
134
+ entry.regex.lastIndex = start;
135
+ const match = entry.regex.exec(text);
136
+ if (!match) continue;
137
+ const after = start + match[0].length;
138
+ if (after < text.length && !/[/\\\s"'`<>(){}[\]|,;:!?]/u.test(text[after])) continue;
139
+ const end = pathEnd(text, after, quoted);
140
+ const tail = normalizeTail(text.slice(after, end), entry.windows).replace(/^\/+|\/+$/gu, '');
141
+ const replacement =
142
+ entry.privateRoot || tail.split('/').some(part => part === '.' || part === '..')
143
+ ? '<PRIVATE_PATH>'
144
+ : entry.alias
145
+ ? `<${entry.alias}>${tail ? `/${sanitize(tail)}` : ''}`
146
+ : tail
147
+ ? sanitize(tail)
148
+ : '.';
149
+ return { end, replacement };
150
+ }
151
+ for (const regex of known) {
152
+ regex.lastIndex = start;
153
+ const match = regex.exec(text);
154
+ if (!match) continue;
155
+ const after = start + match[0].length;
156
+ if (after < text.length && !/[/\\\s"'`<>(){}[\]|,;:!?]/u.test(text[after])) continue;
157
+ return { end: pathEnd(text, after, quoted), replacement: '<PRIVATE_PATH>' };
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function find(text) {
163
+ if (typeof text !== 'string') fail('INVALID_TEXT');
164
+ const matches = [];
165
+ // Skip non-file URLs as units, including their paths, query strings and fragments.
166
+ const urls = /[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/[^\s"'`<>(){}[\]|]+/gu;
167
+ const protectedUrls = [...text.matchAll(urls)];
168
+ let urlIndex = 0;
169
+ for (let index = 0; index < text.length; index++) {
170
+ const url = protectedUrls[urlIndex];
171
+ if (url && index === url.index) {
172
+ urlIndex++;
173
+ if (/^file:\/\//iu.test(url[0]) && boundary(text, index)) {
174
+ try {
175
+ const parsed = new URL(url[0]);
176
+ let local = decodeURIComponent(parsed.pathname);
177
+ if (parsed.hostname && parsed.hostname !== 'localhost') {
178
+ local = `\\\\${parsed.hostname}${local.replaceAll('/', '\\')}`;
179
+ } else if (/^\/[A-Za-z]:\//u.test(local)) {
180
+ local = local.slice(1);
181
+ }
182
+ const found = recognizeAt(local, 0, true);
183
+ matches.push({
184
+ start: index,
185
+ end: index + url[0].length,
186
+ replacement: found ? found.replacement + local.slice(found.end) : '<PRIVATE_PATH>',
187
+ });
188
+ } catch {
189
+ // Malformed file URLs are not safe to partially decode or echo.
190
+ matches.push({
191
+ start: index,
192
+ end: index + url[0].length,
193
+ replacement: '<PRIVATE_PATH>',
194
+ });
195
+ }
196
+ }
197
+ index += url[0].length - 1;
198
+ continue;
199
+ }
200
+ if (!boundary(text, index)) continue;
201
+ const quoted = index > 0 && /["'`]/u.test(text[index - 1]);
202
+ const found = recognizeAt(text, index, quoted);
203
+ if (found) {
204
+ matches.push({ start: index, ...found });
205
+ index = found.end - 1;
206
+ }
207
+ }
208
+ return matches;
209
+ }
210
+
211
+ function sanitize(text) {
212
+ let result = '';
213
+ let offset = 0;
214
+ for (const match of find(text)) {
215
+ result += text.slice(offset, match.start) + match.replacement;
216
+ offset = match.end;
217
+ }
218
+ return result + text.slice(offset);
219
+ }
220
+ return Object.freeze({ find, sanitize });
221
+ }
222
+
223
+ function decode(bytes) {
224
+ try {
225
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
226
+ } catch {
227
+ fail('INVALID_UTF8');
228
+ }
229
+ }
230
+
231
+ async function write(output, text) {
232
+ try {
233
+ await new Promise((resolveWrite, reject) => {
234
+ output.write(text, error => (error ? reject(error) : resolveWrite()));
235
+ });
236
+ } catch {
237
+ fail('OUTPUT_ERROR');
238
+ }
239
+ }
240
+
241
+ /** Emits only fully validated, sanitized lines; preceding successful lines may remain on failure. */
242
+ export async function sanitizeStream(input, output, { matcher = createMatcher() } = {}) {
243
+ let outputFailed = false;
244
+ const onError = () => {
245
+ outputFailed = true;
246
+ };
247
+ output.on('error', onError);
248
+ let parts = [];
249
+ let length = 0;
250
+ try {
251
+ for await (const chunk of input) {
252
+ if (outputFailed) fail('OUTPUT_ERROR');
253
+ // Buffer input is required so upstream decoding cannot hide invalid UTF-8.
254
+ if (!(chunk instanceof Uint8Array)) fail('INVALID_INPUT');
255
+ const bytes = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
256
+ let start = 0;
257
+ while (start < bytes.length) {
258
+ const newline = bytes.indexOf(10, start);
259
+ const end = newline < 0 ? bytes.length : newline;
260
+ length += end - start;
261
+ if (length > LINE_LIMIT) fail('LINE_LIMIT');
262
+ parts.push(bytes.subarray(start, end));
263
+ if (newline < 0) break;
264
+ const line = Buffer.concat(parts, length);
265
+ if (binary(line)) fail('BINARY');
266
+ const text = decode(line);
267
+ await write(output, `${matcher.sanitize(text)}\n`);
268
+ parts = [];
269
+ length = 0;
270
+ start = newline + 1;
271
+ }
272
+ }
273
+ if (length) {
274
+ const line = Buffer.concat(parts, length);
275
+ if (binary(line)) fail('BINARY');
276
+ await write(output, matcher.sanitize(decode(line)));
277
+ }
278
+ if (outputFailed) fail('OUTPUT_ERROR');
279
+ } catch (error) {
280
+ if (error instanceof SafeError) throw error;
281
+ fail('INPUT_ERROR');
282
+ } finally {
283
+ output.off('error', onError);
284
+ }
285
+ }
286
+
287
+ function safeFilename(value, matcher) {
288
+ return matcher
289
+ .sanitize(value)
290
+ .replace(
291
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}:\\]/gu,
292
+ char => `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`,
293
+ );
294
+ }
295
+
296
+ function binary(bytes) {
297
+ return bytes.some(
298
+ byte => byte === 0 || byte === 127 || (byte < 32 && ![9, 10, 12, 13].includes(byte)),
299
+ );
300
+ }
301
+
302
+ async function ancestorExclusion(path) {
303
+ const root = parse(path).root;
304
+ let current = root;
305
+ // resolve() normalizes native separators; POSIX backslashes remain literal filename characters.
306
+ for (const part of path.slice(root.length).split(sep)) {
307
+ if (!part) continue;
308
+ if (part.startsWith('.env')) return 'ENV_EXCLUDED';
309
+ current = join(current, part);
310
+ try {
311
+ if ((await lstat(current)).isSymbolicLink()) return 'SYMLINK_EXCLUDED';
312
+ } catch {
313
+ return 'UNREADABLE';
314
+ }
315
+ }
316
+ return null;
317
+ }
318
+
319
+ /** Read-only, explicit scopes. Incomplete takes precedence over findings. */
320
+ export async function checkPaths(scopes, { matcher = createMatcher() } = {}) {
321
+ if (
322
+ !Array.isArray(scopes) ||
323
+ !scopes.length ||
324
+ scopes.some(scope => typeof scope !== 'string' || !scope)
325
+ ) {
326
+ fail('INVALID_SCOPE');
327
+ }
328
+ const diagnostics = [];
329
+ let incomplete = false;
330
+ let findings = false;
331
+ function report(label, line, code) {
332
+ diagnostics.push(`${label}:${line} ${code}`);
333
+ if (code === 'PATH_FOUND') findings = true;
334
+ else incomplete = true;
335
+ }
336
+ async function scan(path, label) {
337
+ if (basename(path).startsWith('.env')) {
338
+ report(label, 0, 'ENV_EXCLUDED');
339
+ return;
340
+ }
341
+ let stat;
342
+ try {
343
+ stat = await lstat(path);
344
+ } catch {
345
+ report(label, 0, 'UNREADABLE');
346
+ return;
347
+ }
348
+ if (stat.isSymbolicLink()) {
349
+ report(label, 0, 'SYMLINK_EXCLUDED');
350
+ return;
351
+ }
352
+ if (stat.isDirectory()) {
353
+ let entries;
354
+ try {
355
+ entries = await readdir(path);
356
+ } catch {
357
+ report(label, 0, 'UNREADABLE');
358
+ return;
359
+ }
360
+ entries.sort();
361
+ for (const entry of entries)
362
+ await scan(join(path, entry), `${label}/${safeFilename(entry, matcher)}`);
363
+ return;
364
+ }
365
+ if (!stat.isFile()) {
366
+ report(label, 0, 'SPECIAL_EXCLUDED');
367
+ return;
368
+ }
369
+ if (stat.size > FILE_LIMIT) {
370
+ report(label, 0, 'SIZE_LIMIT');
371
+ return;
372
+ }
373
+ // POSIX mode-000 files remain unreadable even for privileged runners; Windows relies on open/ACL errors.
374
+ if (process.platform !== 'win32' && (stat.mode & 0o444) === 0) {
375
+ report(label, 0, 'UNREADABLE');
376
+ return;
377
+ }
378
+ let handle;
379
+ let bytes;
380
+ try {
381
+ handle = await open(
382
+ path,
383
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
384
+ );
385
+ const opened = await handle.stat();
386
+ if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev)
387
+ fail('UNREADABLE');
388
+ const chunks = [];
389
+ let size = 0;
390
+ while (true) {
391
+ const buffer = Buffer.alloc(64 * 1024);
392
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
393
+ if (!bytesRead) break;
394
+ size += bytesRead;
395
+ if (size > FILE_LIMIT) fail('SIZE_LIMIT');
396
+ chunks.push(buffer.subarray(0, bytesRead));
397
+ }
398
+ bytes = Buffer.concat(chunks, size);
399
+ } catch (error) {
400
+ report(label, 0, error instanceof SafeError ? error.code : 'UNREADABLE');
401
+ return;
402
+ } finally {
403
+ if (handle) await handle.close().catch(() => {});
404
+ }
405
+ if (binary(bytes)) {
406
+ report(label, 0, 'BINARY');
407
+ return;
408
+ }
409
+ let text;
410
+ try {
411
+ text = decode(bytes);
412
+ } catch {
413
+ report(label, 0, 'INVALID_UTF8');
414
+ return;
415
+ }
416
+ const lines = text.split('\n');
417
+ if (lines.some(line => Buffer.byteLength(line) > LINE_LIMIT)) {
418
+ report(label, 0, 'LINE_LIMIT');
419
+ return;
420
+ }
421
+ for (let index = 0; index < lines.length; index++) {
422
+ if (matcher.find(lines[index]).length) report(label, index + 1, 'PATH_FOUND');
423
+ }
424
+ }
425
+ // Input order defines stable scope IDs; each directory is visited in lexical order.
426
+ for (const [index, scope] of scopes.entries()) {
427
+ const path = resolve(scope);
428
+ const label = `scope-${index + 1}`;
429
+ const exclusion = await ancestorExclusion(path);
430
+ if (exclusion) {
431
+ report(label, 0, exclusion);
432
+ } else {
433
+ let directory = false;
434
+ try {
435
+ directory = (await lstat(path)).isDirectory();
436
+ } catch {
437
+ /* scan reports safely */
438
+ }
439
+ await scan(path, directory ? label : `${label}/${safeFilename(basename(path), matcher)}`);
440
+ }
441
+ }
442
+ return { status: incomplete ? 2 : findings ? 1 : 0, diagnostics };
443
+ }
444
+
445
+ const HELP = `Usage:
446
+ node sanitize.mjs sanitize [--repo-root ROOT] [--map ROOT=ALIAS ...]
447
+ node sanitize.mjs check [--repo-root ROOT] [--map ROOT=ALIAS ...] -- FILE_OR_DIR ...
448
+
449
+ sanitize reads raw UTF-8 stdin and writes sanitized stdout only. It never runs commands,
450
+ opens input files, writes files, or spools raw input. check is read-only and scans only
451
+ explicit scopes, recursively in sorted order, without extension filtering.
452
+ ROOT must be an absolute Unix, drive-qualified Windows, or backslash UNC path (not a
453
+ volume root). ALIAS is NAME or <NAME>, matching [A-Z][A-Z0-9_]*. Repeat --map to add
454
+ roots. Longest root wins; conflicting replacements for the same root are rejected.
455
+ The default repo root is resolved from this tool's location, not the working directory.
456
+ Checkout paths become repo-relative (root becomes .); maps become <ALIAS>/suffix.
457
+ Native os home/temp roots (lexical and realpath), /Users/name, /home/name, /tmp,
458
+ /private/tmp, /var/folders, /private/var/folders, Windows Users/Temp conventions and
459
+ backslash UNC paths become <PRIVATE_PATH> for the complete recognized path token.
460
+ Native/forward/JSON-escaped Windows separators and file:// URLs are supported.
461
+ Quote path tokens containing spaces; explicit root spaces and account-name spaces
462
+ are also recognized. Unquoted tokens end at whitespace or common prose delimiters.
463
+ Stack :line:column suffixes are preserved. Non-file URLs, unrelated API/container
464
+ paths and unknown absolute roots are left unchanged: this is NOT a universal secret
465
+ scanner. Use explicit maps for other local roots; inspect output before publication.
466
+ UTF-8 decoding is fatal, with a 1 MiB byte limit per line (including CR, excluding LF).
467
+ NUL, DEL and non-text C0 controls are rejected; tab, LF, form feed and CR are allowed.
468
+ Only complete validated lines are emitted; earlier sanitized lines may survive failure.
469
+ check limits each file to 8 MiB, treats NUL/non-text controls as binary, and reports only
470
+ scope-N/relative-filename:line CODE, never source lines or absolute scan roots.
471
+ Symlinks (including scope ancestors), special files, .env* files/directories, unreadable,
472
+ binary, invalid UTF-8 and over-limit files make checking incomplete. Scope trees must
473
+ remain stable while scanning; this is not an adversarial filesystem snapshot.
474
+ Exit status: sanitize 0 success / 2 error; check 0 clean / 1 findings / 2 incomplete/error.
475
+ Options must precede --; check requires -- and at least one explicit scope.
476
+ `;
477
+
478
+ export async function runCli(
479
+ args,
480
+ { input = process.stdin, output = process.stdout, error = process.stderr } = {},
481
+ ) {
482
+ let outputFailed = false;
483
+ const onError = () => {
484
+ outputFailed = true;
485
+ };
486
+ output.on('error', onError);
487
+ // Diagnostics must themselves tolerate a closed stderr, without a raw exception stack.
488
+ const ignoreError = () => {};
489
+ error.on('error', ignoreError);
490
+ try {
491
+ if (args.length === 1 && args[0] === '--help') {
492
+ await write(output, HELP);
493
+ return 0;
494
+ }
495
+ const [command, ...rest] = args;
496
+ if (!['sanitize', 'check'].includes(command)) fail('INVALID_USAGE');
497
+ if (rest.length === 1 && rest[0] === '--help') {
498
+ await write(output, HELP);
499
+ return 0;
500
+ }
501
+ const options = { maps: [] };
502
+ let scopes;
503
+ for (let index = 0; index < rest.length; index++) {
504
+ const arg = rest[index];
505
+ if (arg === '--' && command === 'check') {
506
+ scopes = rest.slice(index + 1);
507
+ break;
508
+ }
509
+ if (!['--repo-root', '--map'].includes(arg) || index + 1 >= rest.length)
510
+ fail('INVALID_USAGE');
511
+ const value = rest[++index];
512
+ if (arg === '--repo-root') {
513
+ if (options.repoRoot !== undefined) fail('INVALID_USAGE');
514
+ options.repoRoot = value;
515
+ } else {
516
+ const equal = value.lastIndexOf('=');
517
+ if (equal <= 0) fail('INVALID_MAP');
518
+ options.maps.push({ root: value.slice(0, equal), alias: value.slice(equal + 1) });
519
+ }
520
+ }
521
+ const matcher = createMatcher(options);
522
+ if (command === 'sanitize') {
523
+ await sanitizeStream(input, output, { matcher });
524
+ return 0;
525
+ }
526
+ if (!scopes?.length) fail('INVALID_SCOPE');
527
+ const result = await checkPaths(scopes, { matcher });
528
+ for (const diagnostic of result.diagnostics) await write(output, `${diagnostic}\n`);
529
+ if (outputFailed) fail('OUTPUT_ERROR');
530
+ return result.status;
531
+ } catch (caught) {
532
+ const code = caught instanceof SafeError ? caught.code : 'INTERNAL_ERROR';
533
+ try {
534
+ await write(error, `evidence-paths: ${code}\n`);
535
+ } catch {
536
+ /* no unsafe fallback */
537
+ }
538
+ return 2;
539
+ } finally {
540
+ output.off('error', onError);
541
+ error.off('error', ignoreError);
542
+ }
543
+ }
544
+
545
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
546
+ runCli(process.argv.slice(2)).then(status => {
547
+ process.exitCode = status;
548
+ });
549
+ }
@@ -102,11 +102,29 @@ The execution workflow supplies the approved dossier and preserves the boundary;
102
102
  Use the narrowest approved check first, then follow the linked ATP procedures and applicable release gates.
103
103
 
104
104
  1. run the scoped verification specified by the increment
105
- 2. retain observed evidence using the suite's existing convention
106
- 3. redact secrets, credentials, raw tokens, customer data, and provider identifiers
105
+ 2. sanitize machine-local paths and review secrets, credentials, raw tokens, customer data, and provider identifiers **before** durable retention or sharing
106
+ 3. retain only reviewed, redacted observed evidence using the suite's existing convention
107
107
  4. record failures, waivers, invalidation, and supersession rather than erasing history
108
108
  5. update evidence before updating derived progress
109
109
 
110
+ For path-bearing output, combine producer stdout/stderr before sanitizing; do not save a raw intermediate log or use `tee` ahead of the sanitizer. For example, in Bash, using a new output filename (do not enable `set -e` before capturing a known failing verification result):
111
+
112
+ ```bash
113
+ set -o pipefail
114
+ set -o noclobber
115
+ npm run --silent test:agent-governance 2>&1 | npm run --silent evidence:sanitize > evidence.pending.txt
116
+ stage_status=( "${PIPESTATUS[@]}" )
117
+ printf 'producer_exit=%s sanitizer_exit=%s\n' "${stage_status[0]}" "${stage_status[1]}"
118
+ # Only check/retain the artifact if sanitizer_exit is 0; preserve producer failure.
119
+ if [ "${stage_status[1]}" -eq 0 ]; then
120
+ npm run --silent evidence:check -- -- evidence.pending.txt
121
+ fi
122
+ ```
123
+
124
+ Choose the producer from the approved verification scope; this example does not authorize unrelated tests. Evaluate both producer and sanitizer statuses before accepting the artifact. `pipefail` catches upstream failure, but exact per-stage exit codes require immediately capturing Bash `PIPESTATUS` or Zsh `pipestatus` before another command. A failed sanitizer may leave partial sanitized output; do not accept it as evidence. A clean path check cannot turn a failed test into a pass. This pipeline is Bash-specific; Windows/PowerShell requires deliberate UTF-8 handling and separate native process statuses. See [Windows and PowerShell](/reference/repo-scripts#windows-and-powershell) rather than reusing Bash/Zsh syntax.
125
+
126
+ The sanitizer preserves checkout-relative locations and uses aliases for external roots. It does not automatically intercept terminal/tool output, recognize every absolute path, remove secrets, or certify safe publication. Review remaining sensitive data separately. Preserve original failures, diagnostic meaning and source hashes; label sanitized historical diffs as path-redacted, not byte-exact/apply-ready. See [Evidence path sanitization](/reference/repo-scripts#evidence-path-sanitization) for mappings, supported coverage and incomplete-scan behavior.
127
+
110
128
  Use status precisely:
111
129
 
112
130
  - `in-progress` — approved work or verification has actually begun