@sabaiway/agent-workflow-kit 1.13.0 → 1.15.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.
@@ -0,0 +1,496 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ ACCEPT_EDITS_MODE,
8
+ CLAUDE_DIR,
9
+ EXPECTED_WORKFLOW_VERSION,
10
+ SETTINGS_FILE,
11
+ SETTINGS_LOCAL_FILE,
12
+ UNIVERSAL_READONLY_ALLOWLIST,
13
+ VELOCITY_NON_READONLY,
14
+ VELOCITY_INVALID_ARGUMENT,
15
+ WORKFLOW_STAMP,
16
+ discoverGateCandidates,
17
+ main,
18
+ parseArgs,
19
+ screenAllowlistEntry,
20
+ validateProfile,
21
+ } from './velocity-profile.mjs';
22
+
23
+ const UTF8 = 'utf8';
24
+ const TEMP_PREFIX = 'velocity-profile-';
25
+ const JSON_INDENT = 2;
26
+ const EXIT_OK = 0;
27
+ const EXIT_PRECONDITION = 1;
28
+ const EXIT_USAGE = 2;
29
+ const READ_ALLOW = 'Read(*)';
30
+ const LEGACY_FETCH_ALLOW = 'Bash(git fetch:*)';
31
+ const BYPASS_MODE = 'bypassPermissions';
32
+
33
+ const makeTempProject = (t) => {
34
+ const dir = mkdtempSync(join(tmpdir(), TEMP_PREFIX));
35
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
36
+ return dir;
37
+ };
38
+
39
+ const writeText = (absPath, text) => writeFileSync(absPath, text, UTF8);
40
+
41
+ const readText = (absPath) => readFileSync(absPath, UTF8);
42
+
43
+ const writeJson = (absPath, data) => writeText(absPath, `${JSON.stringify(data, null, JSON_INDENT)}\n`);
44
+
45
+ const readJson = (absPath) => JSON.parse(readText(absPath));
46
+
47
+ const ensureClaudeDir = (cwd) => mkdirSync(join(cwd, CLAUDE_DIR), { recursive: true });
48
+
49
+ const seedWorkflowStamp = (cwd, version = EXPECTED_WORKFLOW_VERSION) => {
50
+ mkdirSync(join(cwd, 'docs/ai'), { recursive: true });
51
+ writeText(join(cwd, WORKFLOW_STAMP), `${version}\n`);
52
+ };
53
+
54
+ const pathOf = (cwd, rel) => join(cwd, rel);
55
+
56
+ const settingsPath = (cwd) => pathOf(cwd, SETTINGS_FILE);
57
+
58
+ const localSettingsPath = (cwd) => pathOf(cwd, SETTINGS_LOCAL_FILE);
59
+
60
+ const runMain = (argv, cwd) => {
61
+ const stdout = [];
62
+ const stderr = [];
63
+ const code = main([...argv, '--cwd', cwd], {
64
+ log: (line) => stdout.push(line),
65
+ errlog: (line) => stderr.push(line),
66
+ });
67
+ return { code, stdout: stdout.join('\n'), stderr: stderr.join('\n') };
68
+ };
69
+
70
+ const runMainWithoutCwd = (argv) => {
71
+ const stdout = [];
72
+ const stderr = [];
73
+ const code = main(argv, {
74
+ log: (line) => stdout.push(line),
75
+ errlog: (line) => stderr.push(line),
76
+ });
77
+ return { code, stdout: stdout.join('\n'), stderr: stderr.join('\n') };
78
+ };
79
+
80
+ const assertCorePresentOnce = (allow) => {
81
+ for (const entry of UNIVERSAL_READONLY_ALLOWLIST) {
82
+ assert.equal(allow.filter((candidate) => candidate === entry).length, 1, entry);
83
+ }
84
+ };
85
+
86
+ describe('UNIVERSAL_READONLY_ALLOWLIST', () => {
87
+ it('matches the frozen expected set + count', () => {
88
+ // Frozen snapshot of the audited read-only core. `git grep` (`--open-files-in-pager=<cmd>` runs a
89
+ // program) and `sort` (`-o` writes, `--compress-program=<cmd>` runs a program) are deliberately
90
+ // ABSENT — they carry an inline write/exec flag, so they are not genuinely read-only.
91
+ const expected = [
92
+ 'Bash(git status:*)',
93
+ 'Bash(git diff:*)',
94
+ 'Bash(git log:*)',
95
+ 'Bash(git show:*)',
96
+ 'Bash(git ls-files:*)',
97
+ 'Bash(git check-ignore:*)',
98
+ 'Bash(git branch --list:*)',
99
+ 'Bash(npm view:*)',
100
+ 'Bash(npm ls:*)',
101
+ 'Bash(npm outdated:*)',
102
+ 'Bash(ls:*)',
103
+ 'Bash(cat:*)',
104
+ 'Bash(head:*)',
105
+ 'Bash(tail:*)',
106
+ 'Bash(wc:*)',
107
+ 'Bash(readlink:*)',
108
+ 'Bash(which:*)',
109
+ 'Bash(grep:*)',
110
+ ];
111
+ assert.equal(Object.isFrozen(UNIVERSAL_READONLY_ALLOWLIST), true);
112
+ assert.equal(UNIVERSAL_READONLY_ALLOWLIST.length, 18, 'read-only allowlist count sentinel - edit deliberately');
113
+ assert.deepEqual(UNIVERSAL_READONLY_ALLOWLIST, expected);
114
+ });
115
+
116
+ it('every entry passes the read-only screen', () => {
117
+ for (const e of UNIVERSAL_READONLY_ALLOWLIST) assert.equal(screenAllowlistEntry(e), true, e);
118
+ });
119
+
120
+ it('contains no commit / push / publish allow entry (load-bearing invariant)', () => {
121
+ for (const e of UNIVERSAL_READONLY_ALLOWLIST) {
122
+ assert.doesNotMatch(e, /commit|push|publish/i, e);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe('screenAllowlistEntry', () => {
128
+ it('accepts reviewed read-only Bash allow entries', () => {
129
+ const accepted = [
130
+ 'Bash(git status:*)',
131
+ 'Bash(git diff:*)',
132
+ 'Bash(git log:*)',
133
+ 'Bash(git branch --list:*)',
134
+ 'Bash(ls:*)',
135
+ 'Bash(cat:*)',
136
+ 'Bash(grep:*)',
137
+ 'Bash(npm view:*)',
138
+ 'Bash(npm ls:*)',
139
+ ];
140
+ for (const entry of accepted) assert.equal(screenAllowlistEntry(entry), true, entry);
141
+ });
142
+
143
+ it('rejects non-read-only, write/exec-capable, or over-broad Bash allow entries', () => {
144
+ const rejected = [
145
+ 'Bash(echo:*)',
146
+ 'Bash(find:*)',
147
+ 'Bash(sort:*)', // -o writes, --compress-program=<cmd> runs a program
148
+ 'Bash(git grep:*)', // --open-files-in-pager=<cmd> runs a program
149
+ 'Bash(git fetch:*)',
150
+ 'Bash(git remote:*)',
151
+ 'Bash(git branch:*)',
152
+ 'Bash(git ls-remote:*)',
153
+ 'Bash(git commit:*)',
154
+ 'Bash(git push:*)',
155
+ 'Bash(gh api:*)',
156
+ 'Bash(node --test:*)',
157
+ 'Bash(npm run test:*)',
158
+ 'Bash(npm install:*)',
159
+ 'Bash(npm publish:*)',
160
+ 'Bash(npx x:*)',
161
+ 'Bash(git:*)',
162
+ 'Bash(npm:*)',
163
+ 'Bash(git status && git push:*)',
164
+ 'Bash(cat x > y:*)',
165
+ 'Bash(cat $(git push):*)',
166
+ 'Bash(git\tstatus:*)',
167
+ ];
168
+ for (const entry of rejected) assert.equal(screenAllowlistEntry(entry), false, entry);
169
+ });
170
+ });
171
+
172
+ describe('discoverGateCandidates', () => {
173
+ it('returns package scripts as hand-added npm run candidates with mutating-name warnings', () => {
174
+ const packageJson = {
175
+ scripts: {
176
+ test: 'node --test',
177
+ lint: 'eslint .',
178
+ 'release:npm': 'npm publish',
179
+ prepublishOnly: 'node check-release.mjs',
180
+ commit: 'git-cz',
181
+ build: 'node build.mjs',
182
+ },
183
+ };
184
+ assert.deepEqual(discoverGateCandidates(packageJson), [
185
+ { command: 'npm run test', addByHand: true },
186
+ { command: 'npm run lint', addByHand: true },
187
+ { command: 'npm run release:npm', addByHand: true, warn: 'do not add' },
188
+ { command: 'npm run prepublishOnly', addByHand: true, warn: 'do not add' },
189
+ { command: 'npm run commit', addByHand: true, warn: 'do not add' },
190
+ { command: 'npm run build', addByHand: true },
191
+ ]);
192
+ });
193
+
194
+ it('returns an empty list when scripts are absent or not a script map', () => {
195
+ assert.deepEqual(discoverGateCandidates({}), []);
196
+ assert.deepEqual(discoverGateCandidates(), []);
197
+ assert.deepEqual(discoverGateCandidates({ scripts: [] }), []);
198
+ });
199
+ });
200
+
201
+ describe('validateProfile', () => {
202
+ it('returns ok for the audited read-only allowlist', () => {
203
+ assert.deepEqual(validateProfile(UNIVERSAL_READONLY_ALLOWLIST), {
204
+ ok: true,
205
+ count: UNIVERSAL_READONLY_ALLOWLIST.length,
206
+ });
207
+ });
208
+
209
+ it('throws a typed read-only error for a non-read-only entry', () => {
210
+ assert.throws(
211
+ () => validateProfile([...UNIVERSAL_READONLY_ALLOWLIST, 'Bash(sort:*)']),
212
+ (e) => e.code === VELOCITY_NON_READONLY,
213
+ );
214
+ });
215
+
216
+ it('refuses a commit / push / publish allow entry (load-bearing invariant)', () => {
217
+ for (const bad of ['Bash(git commit:*)', 'Bash(git push:*)', 'Bash(npm publish:*)']) {
218
+ assert.throws(
219
+ () => validateProfile([...UNIVERSAL_READONLY_ALLOWLIST, bad]),
220
+ (e) => e.code === VELOCITY_NON_READONLY,
221
+ bad,
222
+ );
223
+ }
224
+ });
225
+
226
+ it('throws a typed argument error for a non-array input', () => {
227
+ assert.throws(
228
+ () => validateProfile('not-an-array'),
229
+ (e) => e.code === VELOCITY_INVALID_ARGUMENT,
230
+ );
231
+ });
232
+ });
233
+
234
+ describe('velocity profile writer + CLI', () => {
235
+ it('merges without clobbering existing settings and preserves legacy entries', (t) => {
236
+ const cwd = makeTempProject(t);
237
+ seedWorkflowStamp(cwd);
238
+ ensureClaudeDir(cwd);
239
+ writeJson(settingsPath(cwd), {
240
+ includeCoAuthoredBy: false,
241
+ permissions: { allow: [READ_ALLOW, LEGACY_FETCH_ALLOW] },
242
+ custom: 1,
243
+ });
244
+
245
+ const result = runMain(['--apply'], cwd);
246
+ const settings = readJson(settingsPath(cwd));
247
+
248
+ assert.equal(result.code, EXIT_OK);
249
+ assert.equal(settings.includeCoAuthoredBy, false);
250
+ assert.equal(settings.custom, 1);
251
+ assert.equal(settings.permissions.allow.includes(READ_ALLOW), true);
252
+ assert.equal(settings.permissions.allow.includes(LEGACY_FETCH_ALLOW), true);
253
+ assertCorePresentOnce(settings.permissions.allow);
254
+ });
255
+
256
+ it('writes nothing for explicit --dry-run and for the default mode', (t) => {
257
+ const absentCwd = makeTempProject(t);
258
+ seedWorkflowStamp(absentCwd);
259
+ const explicitDryRun = runMain(['--dry-run'], absentCwd);
260
+
261
+ const presentCwd = makeTempProject(t);
262
+ seedWorkflowStamp(presentCwd);
263
+ ensureClaudeDir(presentCwd);
264
+ const original = '{"custom":1}\n';
265
+ writeText(settingsPath(presentCwd), original);
266
+ const defaultDryRun = runMain([], presentCwd);
267
+
268
+ assert.equal(explicitDryRun.code, EXIT_OK);
269
+ assert.equal(existsSync(settingsPath(absentCwd)), false);
270
+ assert.equal(existsSync(pathOf(absentCwd, CLAUDE_DIR)), false);
271
+ assert.equal(defaultDryRun.code, EXIT_OK);
272
+ assert.equal(readText(settingsPath(presentCwd)), original);
273
+ });
274
+
275
+ it('sets defaultMode only when --accept-edits is applied', (t) => {
276
+ const defaultCwd = makeTempProject(t);
277
+ seedWorkflowStamp(defaultCwd);
278
+ const defaultResult = runMain(['--apply'], defaultCwd);
279
+ const defaultSettings = readJson(settingsPath(defaultCwd));
280
+
281
+ const acceptCwd = makeTempProject(t);
282
+ seedWorkflowStamp(acceptCwd);
283
+ const acceptResult = runMain(['--apply', '--accept-edits'], acceptCwd);
284
+ const acceptSettings = readJson(settingsPath(acceptCwd));
285
+
286
+ assert.equal(defaultResult.code, EXIT_OK);
287
+ assert.equal(defaultSettings.permissions.defaultMode, undefined);
288
+ assert.equal(acceptResult.code, EXIT_OK);
289
+ assert.equal(acceptSettings.permissions.defaultMode, ACCEPT_EDITS_MODE);
290
+ });
291
+
292
+ it('refuses bypassPermissions in project settings with zero writes', (t) => {
293
+ const cwd = makeTempProject(t);
294
+ seedWorkflowStamp(cwd);
295
+ ensureClaudeDir(cwd);
296
+ writeJson(settingsPath(cwd), { permissions: { defaultMode: BYPASS_MODE, allow: [READ_ALLOW] } });
297
+ const before = readText(settingsPath(cwd));
298
+ const result = runMain(['--apply'], cwd);
299
+
300
+ assert.equal(result.code, EXIT_PRECONDITION);
301
+ assert.match(result.stderr, /bypassPermissions/);
302
+ assert.equal(readText(settingsPath(cwd)), before);
303
+ });
304
+
305
+ it('refuses bypassPermissions in local settings with zero writes', (t) => {
306
+ const cwd = makeTempProject(t);
307
+ seedWorkflowStamp(cwd);
308
+ ensureClaudeDir(cwd);
309
+ writeJson(localSettingsPath(cwd), { permissions: { defaultMode: BYPASS_MODE } });
310
+ const before = readText(localSettingsPath(cwd));
311
+ const result = runMain(['--apply'], cwd);
312
+
313
+ assert.equal(result.code, EXIT_PRECONDITION);
314
+ assert.match(result.stderr, /bypassPermissions/);
315
+ assert.equal(existsSync(settingsPath(cwd)), false);
316
+ assert.equal(readText(localSettingsPath(cwd)), before);
317
+ });
318
+
319
+ it('stops loudly on malformed JSON in either settings file with zero writes', (t) => {
320
+ const cases = [
321
+ { rel: SETTINGS_FILE, expectProjectWrite: true },
322
+ { rel: SETTINGS_LOCAL_FILE, expectProjectWrite: false },
323
+ ];
324
+
325
+ for (const { rel, expectProjectWrite } of cases) {
326
+ const cwd = makeTempProject(t);
327
+ seedWorkflowStamp(cwd);
328
+ ensureClaudeDir(cwd);
329
+ writeText(pathOf(cwd, rel), '{not json\n');
330
+ const before = readText(pathOf(cwd, rel));
331
+ const result = runMain(['--apply'], cwd);
332
+
333
+ assert.equal(result.code, EXIT_PRECONDITION, rel);
334
+ assert.match(result.stderr, /malformed JSON/, rel);
335
+ assert.equal(readText(pathOf(cwd, rel)), before, rel);
336
+ if (!expectProjectWrite) assert.equal(existsSync(settingsPath(cwd)), false, rel);
337
+ }
338
+ });
339
+
340
+ it('stops on non-array permissions.allow in either settings file', (t) => {
341
+ const cases = [SETTINGS_FILE, SETTINGS_LOCAL_FILE];
342
+
343
+ for (const rel of cases) {
344
+ const cwd = makeTempProject(t);
345
+ seedWorkflowStamp(cwd);
346
+ ensureClaudeDir(cwd);
347
+ writeJson(pathOf(cwd, rel), { permissions: { allow: READ_ALLOW } });
348
+ const before = readText(pathOf(cwd, rel));
349
+ const result = runMain(['--apply'], cwd);
350
+
351
+ assert.equal(result.code, EXIT_PRECONDITION, rel);
352
+ assert.match(result.stderr, /permissions\.allow must be an array/, rel);
353
+ assert.equal(readText(pathOf(cwd, rel)), before, rel);
354
+ if (rel === SETTINGS_LOCAL_FILE) assert.equal(existsSync(settingsPath(cwd)), false, rel);
355
+ }
356
+ });
357
+
358
+ it('refuses a symlinked .claude dir and creates an absent one on apply', (t) => {
359
+ const symlinkCwd = makeTempProject(t);
360
+ seedWorkflowStamp(symlinkCwd);
361
+ mkdirSync(pathOf(symlinkCwd, 'real-claude'));
362
+ symlinkSync(pathOf(symlinkCwd, 'real-claude'), pathOf(symlinkCwd, CLAUDE_DIR), 'dir');
363
+ const symlinkResult = runMain(['--apply'], symlinkCwd);
364
+
365
+ const absentCwd = makeTempProject(t);
366
+ seedWorkflowStamp(absentCwd);
367
+ const absentResult = runMain(['--apply'], absentCwd);
368
+
369
+ assert.equal(symlinkResult.code, EXIT_PRECONDITION);
370
+ assert.match(symlinkResult.stderr, /\.claude is a symlink/);
371
+ assert.equal(existsSync(settingsPath(symlinkCwd)), false);
372
+ assert.equal(absentResult.code, EXIT_OK);
373
+ assert.equal(existsSync(settingsPath(absentCwd)), true);
374
+ });
375
+
376
+ it('never writes settings.local.json', (t) => {
377
+ const presentCwd = makeTempProject(t);
378
+ seedWorkflowStamp(presentCwd);
379
+ ensureClaudeDir(presentCwd);
380
+ const localOriginal = '{"permissions":{"defaultMode":"plan"}}\n';
381
+ writeText(localSettingsPath(presentCwd), localOriginal);
382
+ const presentResult = runMain(['--apply', '--accept-edits'], presentCwd);
383
+
384
+ const absentCwd = makeTempProject(t);
385
+ seedWorkflowStamp(absentCwd);
386
+ const absentResult = runMain(['--apply'], absentCwd);
387
+
388
+ assert.equal(presentResult.code, EXIT_OK);
389
+ assert.equal(readText(localSettingsPath(presentCwd)), localOriginal);
390
+ assert.equal(absentResult.code, EXIT_OK);
391
+ assert.equal(existsSync(localSettingsPath(absentCwd)), false);
392
+ });
393
+
394
+ it('enforces the workflow stamp only on apply', (t) => {
395
+ const missingCwd = makeTempProject(t);
396
+ const missingApply = runMain(['--apply'], missingCwd);
397
+ const missingDryRun = runMain(['--dry-run'], missingCwd);
398
+
399
+ const wrongCwd = makeTempProject(t);
400
+ seedWorkflowStamp(wrongCwd, '0.0.0');
401
+ ensureClaudeDir(wrongCwd);
402
+ const original = '{"custom":1}\n';
403
+ writeText(settingsPath(wrongCwd), original);
404
+ const wrongApply = runMain(['--apply'], wrongCwd);
405
+ const wrongDryRun = runMain(['--dry-run'], wrongCwd);
406
+
407
+ assert.equal(missingApply.code, EXIT_PRECONDITION);
408
+ assert.match(missingApply.stderr, /found none/);
409
+ assert.equal(existsSync(settingsPath(missingCwd)), false);
410
+ assert.equal(missingDryRun.code, EXIT_OK);
411
+ assert.match(missingDryRun.stdout, /would add read-only core entries/);
412
+ assert.equal(wrongApply.code, EXIT_PRECONDITION);
413
+ assert.match(wrongApply.stderr, /found 0\.0\.0/);
414
+ assert.equal(readText(settingsPath(wrongCwd)), original);
415
+ assert.equal(wrongDryRun.code, EXIT_OK);
416
+ });
417
+
418
+ it('maps bad args to usage exit code', () => {
419
+ assert.equal(runMainWithoutCwd(['--wat']).code, EXIT_USAGE);
420
+ assert.equal(runMainWithoutCwd(['--dry-run', '--apply']).code, EXIT_USAGE);
421
+ assert.equal(runMainWithoutCwd(['--cwd']).code, EXIT_USAGE);
422
+ assert.deepEqual(parseArgs([]), {
423
+ help: false,
424
+ dryRun: true,
425
+ apply: false,
426
+ acceptEdits: false,
427
+ cwd: undefined,
428
+ });
429
+ });
430
+
431
+ it('is idempotent on a second apply', (t) => {
432
+ const cwd = makeTempProject(t);
433
+ seedWorkflowStamp(cwd);
434
+ const first = runMain(['--apply'], cwd);
435
+ const firstBytes = readText(settingsPath(cwd));
436
+ const second = runMain(['--apply'], cwd);
437
+ const secondBytes = readText(settingsPath(cwd));
438
+
439
+ assert.equal(first.code, EXIT_OK);
440
+ assert.equal(second.code, EXIT_OK);
441
+ assert.equal(secondBytes, firstBytes);
442
+ assert.match(second.stdout, /added read-only core entries: 0/);
443
+ assertCorePresentOnce(readJson(settingsPath(cwd)).permissions.allow);
444
+ });
445
+
446
+ it('refuses an unsafe project mode even when a safe local override masks it', (t) => {
447
+ const cwd = makeTempProject(t);
448
+ seedWorkflowStamp(cwd);
449
+ ensureClaudeDir(cwd);
450
+ // Committed project mode is unknown/unsafe; a safe local override must NOT let velocity write
451
+ // (merge-don't-clobber would otherwise preserve the unsafe project mode for everyone).
452
+ writeJson(settingsPath(cwd), { permissions: { defaultMode: 'wideOpen', allow: [READ_ALLOW] } });
453
+ writeJson(localSettingsPath(cwd), { permissions: { defaultMode: 'default' } });
454
+ const before = readText(settingsPath(cwd));
455
+ const result = runMain(['--apply'], cwd);
456
+
457
+ assert.equal(result.code, EXIT_PRECONDITION);
458
+ assert.match(result.stderr, /unsafe or unknown permissions\.defaultMode/);
459
+ assert.equal(readText(settingsPath(cwd)), before);
460
+ });
461
+
462
+ it('refuses a symlinked settings.json on BOTH dry-run and apply (no false prediction)', (t) => {
463
+ const cwd = makeTempProject(t);
464
+ seedWorkflowStamp(cwd);
465
+ ensureClaudeDir(cwd);
466
+ writeJson(pathOf(cwd, 'real-settings.json'), { custom: 1 });
467
+ symlinkSync(pathOf(cwd, 'real-settings.json'), settingsPath(cwd), 'file');
468
+ const dryRun = runMain(['--dry-run'], cwd);
469
+ const apply = runMain(['--apply'], cwd);
470
+
471
+ assert.equal(dryRun.code, EXIT_PRECONDITION);
472
+ assert.equal(apply.code, EXIT_PRECONDITION);
473
+ assert.match(dryRun.stderr, /not a regular file/);
474
+ assert.deepEqual(readJson(pathOf(cwd, 'real-settings.json')), { custom: 1 });
475
+ });
476
+
477
+ it('degrades gracefully when package.json is malformed (advisory only, still writes)', (t) => {
478
+ const cwd = makeTempProject(t);
479
+ seedWorkflowStamp(cwd);
480
+ writeText(pathOf(cwd, 'package.json'), '{ broken json\n');
481
+ const result = runMain(['--apply'], cwd);
482
+
483
+ assert.equal(result.code, EXIT_OK);
484
+ assertCorePresentOnce(readJson(settingsPath(cwd)).permissions.allow);
485
+ });
486
+
487
+ it('always prints the honest residual notice (locks the release honesty contract)', (t) => {
488
+ const cwd = makeTempProject(t);
489
+ seedWorkflowStamp(cwd);
490
+ const dry = runMain(['--dry-run'], cwd);
491
+ assert.match(dry.stdout, /trust-posture convenience, NOT a sandbox/);
492
+ assert.match(dry.stdout, /commit\/push\/publish are never allowlisted/);
493
+ assert.match(dry.stdout, /substitution\/redirection residual is not closed/);
494
+ assert.match(dry.stdout, /deferred PreToolUse hook/);
495
+ });
496
+ });