auto-mode 0.2.0 → 0.4.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,1016 @@
1
+ import { expect, test } from 'claude-code/testing';
2
+
3
+ test('it preserves an existing denial and its rule', async ($, on) => {
4
+ const decided = {
5
+ decision: 'deny',
6
+ reason: 'Existing permission decision',
7
+ rule: 'Bash(git push:*)',
8
+ hook: 'PreToolUse',
9
+ } as const;
10
+
11
+ let calls = 0;
12
+
13
+ on('session.cwd', () => ({ value: '/repo' }));
14
+ on('tool.check', () => decided);
15
+ on('classic.SessionStart', () => ({}));
16
+
17
+ on('process.run', () => {
18
+ calls += 1;
19
+
20
+ return {
21
+ value: {
22
+ exitCode: 0,
23
+ stdout:
24
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
25
+ stderr: 'synthetic-private-fragment',
26
+ isStdoutTruncated: false,
27
+ isStderrTruncated: false,
28
+ },
29
+ };
30
+ });
31
+
32
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
33
+ await $.classic.SessionStart({
34
+ source: 'startup',
35
+ session_id: 'session-1',
36
+ cwd: '/repo',
37
+ transcript_path: '/repo/transcript.jsonl',
38
+ });
39
+
40
+ const result = await $.tool.check({
41
+ tool: 'Bash',
42
+ input: { command: 'git push origin feature', timeout: 120_000 },
43
+ });
44
+
45
+ expect(result).toStrictEqual(decided);
46
+ expect(calls).toBe(0);
47
+ });
48
+
49
+ test('it preserves an existing allowance without a second evaluator', async ($, on) => {
50
+ const decided = {
51
+ decision: 'allow',
52
+ reason: 'Existing permission decision',
53
+ rule: 'Bash(git push:*)',
54
+ hook: 'PreToolUse',
55
+ } as const;
56
+
57
+ let calls = 0;
58
+
59
+ on('session.cwd', () => ({ value: '/repo' }));
60
+ on('tool.check', () => decided);
61
+ on('classic.SessionStart', () => ({}));
62
+
63
+ on('process.run', () => {
64
+ calls += 1;
65
+
66
+ return {
67
+ value: {
68
+ exitCode: 0,
69
+ stdout:
70
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"[Data Exfiltration] Refuse the transfer."}}}',
71
+ stderr: 'synthetic-private-fragment',
72
+ isStdoutTruncated: false,
73
+ isStderrTruncated: false,
74
+ },
75
+ };
76
+ });
77
+
78
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
79
+ await $.classic.SessionStart({
80
+ source: 'startup',
81
+ session_id: 'session-1',
82
+ cwd: '/repo',
83
+ transcript_path: '/repo/transcript.jsonl',
84
+ });
85
+
86
+ const result = await $.tool.check({
87
+ tool: 'Bash',
88
+ input: { command: 'git push origin feature', timeout: 120_000 },
89
+ });
90
+
91
+ expect(result).toStrictEqual(decided);
92
+ expect(calls).toBe(0);
93
+ });
94
+
95
+ test('it approves an ask after Jev allows the action', async ($, on) => {
96
+ const decided = {
97
+ decision: 'ask',
98
+ reason: 'Existing permission decision',
99
+ rule: 'Bash(git push:*)',
100
+ hook: 'PreToolUse',
101
+ } as const;
102
+
103
+ let calls = 0;
104
+ let invocation: unknown;
105
+ let stdin = '';
106
+ let timeoutMs = 0;
107
+
108
+ on('session.cwd', () => ({ value: '/repo' }));
109
+ on('tool.check', () => decided);
110
+ on('classic.SessionStart', () => ({}));
111
+
112
+ on('process.run', (_api, e) => {
113
+ calls += 1;
114
+ invocation = e;
115
+ stdin = e.init?.stdin ?? '';
116
+ timeoutMs = e.init?.timeoutMs ?? 0;
117
+
118
+ return {
119
+ value: {
120
+ exitCode: 0,
121
+ stdout:
122
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
123
+ stderr: 'synthetic-private-fragment',
124
+ isStdoutTruncated: false,
125
+ isStderrTruncated: false,
126
+ },
127
+ };
128
+ });
129
+
130
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
131
+ await $.classic.SessionStart({
132
+ source: 'startup',
133
+ session_id: 'session-1',
134
+ cwd: '/repo',
135
+ transcript_path: '/repo/transcript.jsonl',
136
+ });
137
+
138
+ const result = await $.tool.check({
139
+ tool: 'Bash',
140
+ input: { command: 'git push origin feature', timeout: 120_000 },
141
+ });
142
+
143
+ expect(result).toStrictEqual({ decision: 'allow' });
144
+ expect(calls).toBe(1);
145
+ expect(timeoutMs).toBeGreaterThan(0);
146
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
147
+
148
+ expect(JSON.parse(stdin)).toStrictEqual({
149
+ hook_event_name: 'PermissionRequest',
150
+ prompt_id: 'mod-check',
151
+ session_id: 'session-1',
152
+ cwd: '/repo',
153
+ transcript_path: '/repo/transcript.jsonl',
154
+ tool_name: 'Bash',
155
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
156
+ });
157
+
158
+ expect(invocation).toMatchObject({
159
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
160
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
161
+ });
162
+ });
163
+
164
+ test('it refuses an ask after Jev denies the action', async ($, on) => {
165
+ const decided = {
166
+ decision: 'ask',
167
+ reason: 'Existing permission decision',
168
+ rule: 'Bash(git push:*)',
169
+ hook: 'PreToolUse',
170
+ } as const;
171
+
172
+ let calls = 0;
173
+ let invocation: unknown;
174
+ let stdin = '';
175
+ let timeoutMs = 0;
176
+
177
+ on('session.cwd', () => ({ value: '/repo' }));
178
+ on('tool.check', () => decided);
179
+ on('classic.SessionStart', () => ({}));
180
+
181
+ on('process.run', (_api, e) => {
182
+ calls += 1;
183
+ invocation = e;
184
+ stdin = e.init?.stdin ?? '';
185
+ timeoutMs = e.init?.timeoutMs ?? 0;
186
+
187
+ return {
188
+ value: {
189
+ exitCode: 0,
190
+ stdout:
191
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"[Data Exfiltration] Refuse the transfer."}}}',
192
+ stderr: 'synthetic-private-fragment',
193
+ isStdoutTruncated: false,
194
+ isStderrTruncated: false,
195
+ },
196
+ };
197
+ });
198
+
199
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
200
+ await $.classic.SessionStart({
201
+ source: 'startup',
202
+ session_id: 'session-1',
203
+ cwd: '/repo',
204
+ transcript_path: '/repo/transcript.jsonl',
205
+ });
206
+
207
+ const result = await $.tool.check({
208
+ tool: 'Bash',
209
+ input: { command: 'git push origin feature', timeout: 120_000 },
210
+ });
211
+
212
+ expect(result).toStrictEqual({
213
+ decision: 'deny',
214
+ reason: '[Data Exfiltration] Refuse the transfer.',
215
+ });
216
+
217
+ expect(calls).toBe(1);
218
+ expect(timeoutMs).toBeGreaterThan(0);
219
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
220
+
221
+ expect(JSON.parse(stdin)).toStrictEqual({
222
+ hook_event_name: 'PermissionRequest',
223
+ prompt_id: 'mod-check',
224
+ session_id: 'session-1',
225
+ cwd: '/repo',
226
+ transcript_path: '/repo/transcript.jsonl',
227
+ tool_name: 'Bash',
228
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
229
+ });
230
+
231
+ expect(invocation).toMatchObject({
232
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
233
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
234
+ });
235
+ });
236
+
237
+ test('it retains manual approval when Jev returns no opinion', async ($, on) => {
238
+ const decided = {
239
+ decision: 'ask',
240
+ reason: 'Existing permission decision',
241
+ rule: 'Bash(git push:*)',
242
+ hook: 'PreToolUse',
243
+ } as const;
244
+
245
+ let calls = 0;
246
+ let invocation: unknown;
247
+ let stdin = '';
248
+ let timeoutMs = 0;
249
+
250
+ on('session.cwd', () => ({ value: '/repo' }));
251
+ on('tool.check', () => decided);
252
+ on('classic.SessionStart', () => ({}));
253
+
254
+ on('process.run', (_api, e) => {
255
+ calls += 1;
256
+ invocation = e;
257
+ stdin = e.init?.stdin ?? '';
258
+ timeoutMs = e.init?.timeoutMs ?? 0;
259
+
260
+ return {
261
+ value: {
262
+ exitCode: 0,
263
+ stdout: '',
264
+ stderr: 'synthetic-private-fragment',
265
+ isStdoutTruncated: false,
266
+ isStderrTruncated: false,
267
+ },
268
+ };
269
+ });
270
+
271
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
272
+ await $.classic.SessionStart({
273
+ source: 'startup',
274
+ session_id: 'session-1',
275
+ cwd: '/repo',
276
+ transcript_path: '/repo/transcript.jsonl',
277
+ });
278
+
279
+ const result = await $.tool.check({
280
+ tool: 'Bash',
281
+ input: { command: 'git push origin feature', timeout: 120_000 },
282
+ });
283
+
284
+ expect(result).toStrictEqual(decided);
285
+ expect(calls).toBe(1);
286
+ expect(timeoutMs).toBeGreaterThan(0);
287
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
288
+
289
+ expect(JSON.parse(stdin)).toStrictEqual({
290
+ hook_event_name: 'PermissionRequest',
291
+ prompt_id: 'mod-check',
292
+ session_id: 'session-1',
293
+ cwd: '/repo',
294
+ transcript_path: '/repo/transcript.jsonl',
295
+ tool_name: 'Bash',
296
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
297
+ });
298
+
299
+ expect(invocation).toMatchObject({
300
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
301
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
302
+ });
303
+ });
304
+
305
+ test('it retains manual approval for malformed JSON without copying diagnostics', async ($, on) => {
306
+ const decided = {
307
+ decision: 'ask',
308
+ reason: 'Existing permission decision',
309
+ rule: 'Bash(git push:*)',
310
+ hook: 'PreToolUse',
311
+ } as const;
312
+
313
+ let calls = 0;
314
+ let invocation: unknown;
315
+ let stdin = '';
316
+ let timeoutMs = 0;
317
+
318
+ on('session.cwd', () => ({ value: '/repo' }));
319
+ on('tool.check', () => decided);
320
+ on('classic.SessionStart', () => ({}));
321
+
322
+ on('process.run', (_api, e) => {
323
+ calls += 1;
324
+ invocation = e;
325
+ stdin = e.init?.stdin ?? '';
326
+ timeoutMs = e.init?.timeoutMs ?? 0;
327
+
328
+ return {
329
+ value: {
330
+ exitCode: 0,
331
+ stdout: 'not-json synthetic-private-fragment',
332
+ stderr: 'synthetic-private-fragment',
333
+ isStdoutTruncated: false,
334
+ isStderrTruncated: false,
335
+ },
336
+ };
337
+ });
338
+
339
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
340
+ await $.classic.SessionStart({
341
+ source: 'startup',
342
+ session_id: 'session-1',
343
+ cwd: '/repo',
344
+ transcript_path: '/repo/transcript.jsonl',
345
+ });
346
+
347
+ const result = await $.tool.check({
348
+ tool: 'Bash',
349
+ input: { command: 'git push origin feature', timeout: 120_000 },
350
+ });
351
+
352
+ expect(result).toStrictEqual(decided);
353
+ expect(calls).toBe(1);
354
+ expect(timeoutMs).toBeGreaterThan(0);
355
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
356
+
357
+ expect(JSON.parse(stdin)).toStrictEqual({
358
+ hook_event_name: 'PermissionRequest',
359
+ prompt_id: 'mod-check',
360
+ session_id: 'session-1',
361
+ cwd: '/repo',
362
+ transcript_path: '/repo/transcript.jsonl',
363
+ tool_name: 'Bash',
364
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
365
+ });
366
+
367
+ expect(invocation).toMatchObject({
368
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
369
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
370
+ });
371
+ });
372
+
373
+ test('it retains manual approval for a verdict from another event', async ($, on) => {
374
+ const decided = {
375
+ decision: 'ask',
376
+ reason: 'Existing permission decision',
377
+ rule: 'Bash(git push:*)',
378
+ hook: 'PreToolUse',
379
+ } as const;
380
+
381
+ let calls = 0;
382
+ let invocation: unknown;
383
+ let stdin = '';
384
+ let timeoutMs = 0;
385
+
386
+ on('session.cwd', () => ({ value: '/repo' }));
387
+ on('tool.check', () => decided);
388
+ on('classic.SessionStart', () => ({}));
389
+
390
+ on('process.run', (_api, e) => {
391
+ calls += 1;
392
+ invocation = e;
393
+ stdin = e.init?.stdin ?? '';
394
+ timeoutMs = e.init?.timeoutMs ?? 0;
395
+
396
+ return {
397
+ value: {
398
+ exitCode: 0,
399
+ stdout:
400
+ '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}',
401
+ stderr: 'synthetic-private-fragment',
402
+ isStdoutTruncated: false,
403
+ isStderrTruncated: false,
404
+ },
405
+ };
406
+ });
407
+
408
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
409
+ await $.classic.SessionStart({
410
+ source: 'startup',
411
+ session_id: 'session-1',
412
+ cwd: '/repo',
413
+ transcript_path: '/repo/transcript.jsonl',
414
+ });
415
+
416
+ const result = await $.tool.check({
417
+ tool: 'Bash',
418
+ input: { command: 'git push origin feature', timeout: 120_000 },
419
+ });
420
+
421
+ expect(result).toStrictEqual(decided);
422
+ expect(calls).toBe(1);
423
+ expect(timeoutMs).toBeGreaterThan(0);
424
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
425
+
426
+ expect(JSON.parse(stdin)).toStrictEqual({
427
+ hook_event_name: 'PermissionRequest',
428
+ prompt_id: 'mod-check',
429
+ session_id: 'session-1',
430
+ cwd: '/repo',
431
+ transcript_path: '/repo/transcript.jsonl',
432
+ tool_name: 'Bash',
433
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
434
+ });
435
+
436
+ expect(invocation).toMatchObject({
437
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
438
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
439
+ });
440
+ });
441
+
442
+ test('it retains manual approval for a malformed denial', async ($, on) => {
443
+ const decided = {
444
+ decision: 'ask',
445
+ reason: 'Existing permission decision',
446
+ rule: 'Bash(git push:*)',
447
+ hook: 'PreToolUse',
448
+ } as const;
449
+
450
+ let calls = 0;
451
+ let invocation: unknown;
452
+ let stdin = '';
453
+ let timeoutMs = 0;
454
+
455
+ on('session.cwd', () => ({ value: '/repo' }));
456
+ on('tool.check', () => decided);
457
+ on('classic.SessionStart', () => ({}));
458
+
459
+ on('process.run', (_api, e) => {
460
+ calls += 1;
461
+ invocation = e;
462
+ stdin = e.init?.stdin ?? '';
463
+ timeoutMs = e.init?.timeoutMs ?? 0;
464
+
465
+ return {
466
+ value: {
467
+ exitCode: 0,
468
+ stdout:
469
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}',
470
+ stderr: 'synthetic-private-fragment',
471
+ isStdoutTruncated: false,
472
+ isStderrTruncated: false,
473
+ },
474
+ };
475
+ });
476
+
477
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
478
+ await $.classic.SessionStart({
479
+ source: 'startup',
480
+ session_id: 'session-1',
481
+ cwd: '/repo',
482
+ transcript_path: '/repo/transcript.jsonl',
483
+ });
484
+
485
+ const result = await $.tool.check({
486
+ tool: 'Bash',
487
+ input: { command: 'git push origin feature', timeout: 120_000 },
488
+ });
489
+
490
+ expect(result).toStrictEqual(decided);
491
+ expect(calls).toBe(1);
492
+ expect(timeoutMs).toBeGreaterThan(0);
493
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
494
+
495
+ expect(JSON.parse(stdin)).toStrictEqual({
496
+ hook_event_name: 'PermissionRequest',
497
+ prompt_id: 'mod-check',
498
+ session_id: 'session-1',
499
+ cwd: '/repo',
500
+ transcript_path: '/repo/transcript.jsonl',
501
+ tool_name: 'Bash',
502
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
503
+ });
504
+
505
+ expect(invocation).toMatchObject({
506
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
507
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
508
+ });
509
+ });
510
+
511
+ test('it retains manual approval for a truncated response', async ($, on) => {
512
+ const decided = {
513
+ decision: 'ask',
514
+ reason: 'Existing permission decision',
515
+ rule: 'Bash(git push:*)',
516
+ hook: 'PreToolUse',
517
+ } as const;
518
+
519
+ let calls = 0;
520
+ let invocation: unknown;
521
+ let stdin = '';
522
+ let timeoutMs = 0;
523
+
524
+ on('session.cwd', () => ({ value: '/repo' }));
525
+ on('tool.check', () => decided);
526
+ on('classic.SessionStart', () => ({}));
527
+
528
+ on('process.run', (_api, e) => {
529
+ calls += 1;
530
+ invocation = e;
531
+ stdin = e.init?.stdin ?? '';
532
+ timeoutMs = e.init?.timeoutMs ?? 0;
533
+
534
+ return {
535
+ value: {
536
+ exitCode: 0,
537
+ stdout:
538
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
539
+ stderr: 'synthetic-private-fragment',
540
+ isStdoutTruncated: true,
541
+ isStderrTruncated: false,
542
+ },
543
+ };
544
+ });
545
+
546
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
547
+ await $.classic.SessionStart({
548
+ source: 'startup',
549
+ session_id: 'session-1',
550
+ cwd: '/repo',
551
+ transcript_path: '/repo/transcript.jsonl',
552
+ });
553
+
554
+ const result = await $.tool.check({
555
+ tool: 'Bash',
556
+ input: { command: 'git push origin feature', timeout: 120_000 },
557
+ });
558
+
559
+ expect(result).toStrictEqual(decided);
560
+ expect(calls).toBe(1);
561
+ expect(timeoutMs).toBeGreaterThan(0);
562
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
563
+
564
+ expect(JSON.parse(stdin)).toStrictEqual({
565
+ hook_event_name: 'PermissionRequest',
566
+ prompt_id: 'mod-check',
567
+ session_id: 'session-1',
568
+ cwd: '/repo',
569
+ transcript_path: '/repo/transcript.jsonl',
570
+ tool_name: 'Bash',
571
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
572
+ });
573
+
574
+ expect(invocation).toMatchObject({
575
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
576
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
577
+ });
578
+ });
579
+
580
+ test('it retains manual approval when the child exits nonzero', async ($, on) => {
581
+ const decided = {
582
+ decision: 'ask',
583
+ reason: 'Existing permission decision',
584
+ rule: 'Bash(git push:*)',
585
+ hook: 'PreToolUse',
586
+ } as const;
587
+
588
+ let calls = 0;
589
+ let invocation: unknown;
590
+ let stdin = '';
591
+ let timeoutMs = 0;
592
+
593
+ on('session.cwd', () => ({ value: '/repo' }));
594
+ on('tool.check', () => decided);
595
+ on('classic.SessionStart', () => ({}));
596
+
597
+ on('process.run', (_api, e) => {
598
+ calls += 1;
599
+ invocation = e;
600
+ stdin = e.init?.stdin ?? '';
601
+ timeoutMs = e.init?.timeoutMs ?? 0;
602
+
603
+ return {
604
+ value: {
605
+ exitCode: 1,
606
+ stdout:
607
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
608
+ stderr: 'synthetic-private-fragment',
609
+ isStdoutTruncated: false,
610
+ isStderrTruncated: false,
611
+ },
612
+ };
613
+ });
614
+
615
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
616
+ await $.classic.SessionStart({
617
+ source: 'startup',
618
+ session_id: 'session-1',
619
+ cwd: '/repo',
620
+ transcript_path: '/repo/transcript.jsonl',
621
+ });
622
+
623
+ const result = await $.tool.check({
624
+ tool: 'Bash',
625
+ input: { command: 'git push origin feature', timeout: 120_000 },
626
+ });
627
+
628
+ expect(result).toStrictEqual(decided);
629
+ expect(calls).toBe(1);
630
+ expect(timeoutMs).toBeGreaterThan(0);
631
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
632
+
633
+ expect(JSON.parse(stdin)).toStrictEqual({
634
+ hook_event_name: 'PermissionRequest',
635
+ prompt_id: 'mod-check',
636
+ session_id: 'session-1',
637
+ cwd: '/repo',
638
+ transcript_path: '/repo/transcript.jsonl',
639
+ tool_name: 'Bash',
640
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
641
+ });
642
+
643
+ expect(invocation).toMatchObject({
644
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
645
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
646
+ });
647
+ });
648
+
649
+ test('it retains manual approval when the child times out', async ($, on) => {
650
+ const decided = {
651
+ decision: 'ask',
652
+ reason: 'Existing permission decision',
653
+ rule: 'Bash(git push:*)',
654
+ hook: 'PreToolUse',
655
+ } as const;
656
+
657
+ let calls = 0;
658
+ let invocation: unknown;
659
+ let stdin = '';
660
+ let timeoutMs = 0;
661
+
662
+ on('session.cwd', () => ({ value: '/repo' }));
663
+ on('tool.check', () => decided);
664
+ on('classic.SessionStart', () => ({}));
665
+
666
+ on('process.run', (_api, e) => {
667
+ calls += 1;
668
+ invocation = e;
669
+ stdin = e.init?.stdin ?? '';
670
+ timeoutMs = e.init?.timeoutMs ?? 0;
671
+ throw new Error('synthetic child timeout; private diagnostics');
672
+ });
673
+
674
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
675
+ await $.classic.SessionStart({
676
+ source: 'startup',
677
+ session_id: 'session-1',
678
+ cwd: '/repo',
679
+ transcript_path: '/repo/transcript.jsonl',
680
+ });
681
+
682
+ const result = await $.tool.check({
683
+ tool: 'Bash',
684
+ input: { command: 'git push origin feature', timeout: 120_000 },
685
+ });
686
+
687
+ expect(result).toStrictEqual(decided);
688
+ expect(calls).toBe(1);
689
+ expect(timeoutMs).toBeGreaterThan(0);
690
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
691
+
692
+ expect(JSON.parse(stdin)).toStrictEqual({
693
+ hook_event_name: 'PermissionRequest',
694
+ prompt_id: 'mod-check',
695
+ session_id: 'session-1',
696
+ cwd: '/repo',
697
+ transcript_path: '/repo/transcript.jsonl',
698
+ tool_name: 'Bash',
699
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
700
+ });
701
+
702
+ expect(invocation).toMatchObject({
703
+ argv: ['auto-mode', 'run', '--jev-only', '--evaluation-deadline', expect.any(String)],
704
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
705
+ });
706
+ });
707
+
708
+ test('it retains manual approval before the session context arrives', async ($, on) => {
709
+ const decided = {
710
+ decision: 'ask',
711
+ reason: 'Existing permission decision',
712
+ rule: 'Bash(git push:*)',
713
+ hook: 'PreToolUse',
714
+ } as const;
715
+
716
+ let calls = 0;
717
+
718
+ on('session.cwd', () => ({ value: '/repo' }));
719
+ on('tool.check', () => decided);
720
+
721
+ on('process.run', () => {
722
+ calls += 1;
723
+
724
+ return {
725
+ value: {
726
+ exitCode: 0,
727
+ stdout:
728
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
729
+ stderr: 'synthetic-private-fragment',
730
+ isStdoutTruncated: false,
731
+ isStderrTruncated: false,
732
+ },
733
+ };
734
+ });
735
+
736
+ const result = await $.tool.check({
737
+ tool: 'Bash',
738
+ input: { command: 'git push origin feature', timeout: 120_000 },
739
+ });
740
+
741
+ expect(result).toStrictEqual(decided);
742
+ expect(calls).toBe(0);
743
+ });
744
+
745
+ test(
746
+ 'it passes a configured executable as one argument without a shell',
747
+ { options: { command: '/opt/auto mode/bin/auto-mode' } },
748
+ async ($, on) => {
749
+ const decided = {
750
+ decision: 'ask',
751
+ reason: 'Existing permission decision',
752
+ rule: 'Bash(git push:*)',
753
+ hook: 'PreToolUse',
754
+ } as const;
755
+
756
+ let calls = 0;
757
+ let invocation: unknown;
758
+ let stdin = '';
759
+ let timeoutMs = 0;
760
+
761
+ on('session.cwd', () => ({ value: '/repo' }));
762
+ on('tool.check', () => decided);
763
+ on('classic.SessionStart', () => ({}));
764
+
765
+ on('process.run', (_api, e) => {
766
+ calls += 1;
767
+ invocation = e;
768
+ stdin = e.init?.stdin ?? '';
769
+ timeoutMs = e.init?.timeoutMs ?? 0;
770
+
771
+ return {
772
+ value: {
773
+ exitCode: 0,
774
+ stdout:
775
+ '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
776
+ stderr: 'synthetic-private-fragment',
777
+ isStdoutTruncated: false,
778
+ isStderrTruncated: false,
779
+ },
780
+ };
781
+ });
782
+
783
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
784
+ await $.classic.SessionStart({
785
+ source: 'startup',
786
+ session_id: 'session-1',
787
+ cwd: '/repo',
788
+ transcript_path: '/repo/transcript.jsonl',
789
+ });
790
+
791
+ const result = await $.tool.check({
792
+ tool: 'Bash',
793
+ input: { command: 'git push origin feature', timeout: 120_000 },
794
+ });
795
+
796
+ expect(result).toStrictEqual({ decision: 'allow' });
797
+ expect(calls).toBe(1);
798
+ expect(timeoutMs).toBeGreaterThan(0);
799
+ expect(timeoutMs).toBeLessThanOrEqual(8000);
800
+
801
+ expect(JSON.parse(stdin)).toStrictEqual({
802
+ hook_event_name: 'PermissionRequest',
803
+ prompt_id: 'mod-check',
804
+ session_id: 'session-1',
805
+ cwd: '/repo',
806
+ transcript_path: '/repo/transcript.jsonl',
807
+ tool_name: 'Bash',
808
+ tool_input: { command: 'git push origin feature', timeout: 120_000 },
809
+ });
810
+
811
+ expect(invocation).toMatchObject({
812
+ argv: [
813
+ '/opt/auto mode/bin/auto-mode',
814
+ 'run',
815
+ '--jev-only',
816
+ '--evaluation-deadline',
817
+ expect.any(String),
818
+ ],
819
+ init: { timeoutMs: expect.any(Number), stdin: expect.any(String) },
820
+ });
821
+ },
822
+ );
823
+
824
+ test('it refreshes the context from the current user prompt after reload', async ($, on) => {
825
+ on('classic.UserPromptSubmit', () => ({}));
826
+ on('session.cwd', () => ({ value: '/new-repo' }));
827
+ on('tool.check', () => ({ decision: 'ask' }));
828
+
829
+ let stdin = '';
830
+
831
+ on('process.run', (_api, e) => {
832
+ stdin = e.init?.stdin ?? '';
833
+
834
+ return {
835
+ value: {
836
+ exitCode: 0,
837
+ stdout: '',
838
+ stderr: '',
839
+ isStdoutTruncated: false,
840
+ isStderrTruncated: false,
841
+ },
842
+ };
843
+ });
844
+
845
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
846
+ await $.classic.UserPromptSubmit({
847
+ prompt: 'Edit the test fixture.',
848
+ session_id: 'new-session',
849
+ cwd: '/new-repo',
850
+ transcript_path: '/new-repo/current.jsonl',
851
+ });
852
+
853
+ const result = await $.tool.check({
854
+ tool: 'Write',
855
+ input: { file_path: '/new-repo/fixture', content: 'green' },
856
+ });
857
+
858
+ expect(result).toStrictEqual({ decision: 'ask' });
859
+
860
+ expect(JSON.parse(stdin)).toMatchObject({
861
+ session_id: 'new-session',
862
+ cwd: '/new-repo',
863
+ transcript_path: '/new-repo/current.jsonl',
864
+ });
865
+ });
866
+
867
+ test('it preserves the complete action without truncation', async ($, on) => {
868
+ const content = 'x'.repeat(120_000);
869
+ let stdin = '';
870
+
871
+ on('classic.SessionStart', () => ({}));
872
+ on('session.cwd', () => ({ value: '/repo' }));
873
+ on('tool.check', () => ({ decision: 'ask' }));
874
+
875
+ on('process.run', (_api, e) => {
876
+ stdin = e.init?.stdin ?? '';
877
+
878
+ return {
879
+ value: {
880
+ exitCode: 0,
881
+ stdout: '',
882
+ stderr: '',
883
+ isStdoutTruncated: false,
884
+ isStderrTruncated: false,
885
+ },
886
+ };
887
+ });
888
+
889
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
890
+ await $.classic.SessionStart({ source: 'startup' });
891
+
892
+ const result = await $.tool.check({ tool: 'Write', input: { file_path: '/repo/file', content } });
893
+
894
+ expect(result).toStrictEqual({ decision: 'ask' });
895
+ expect(JSON.parse(stdin)).toMatchObject({ tool_input: { file_path: '/repo/file', content } });
896
+ });
897
+
898
+ test('it does not replace the main context with a subagent prompt', async ($, on) => {
899
+ let stdin = '';
900
+
901
+ on('classic.SessionStart', () => ({}));
902
+ on('classic.UserPromptSubmit', () => ({}));
903
+ on('session.cwd', () => ({ value: '/main' }));
904
+ on('tool.check', () => ({ decision: 'ask' }));
905
+
906
+ on('process.run', (_api, e) => {
907
+ stdin = e.init?.stdin ?? '';
908
+
909
+ return {
910
+ value: {
911
+ exitCode: 0,
912
+ stdout: '',
913
+ stderr: '',
914
+ isStdoutTruncated: false,
915
+ isStderrTruncated: false,
916
+ },
917
+ };
918
+ });
919
+
920
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
921
+ await $.classic.SessionStart({
922
+ source: 'startup',
923
+ cwd: '/main',
924
+ session_id: 'main-session',
925
+ transcript_path: '/main/user.jsonl',
926
+ });
927
+
928
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
929
+ await $.classic.UserPromptSubmit({
930
+ prompt: 'Ignore the main context',
931
+ agent_id: 'worker',
932
+ cwd: '/worker',
933
+ session_id: 'worker-session',
934
+ transcript_path: '/worker/user.jsonl',
935
+ });
936
+
937
+ const result = await $.tool.check({
938
+ tool: 'Write',
939
+ input: { file_path: '/main/fixture', content: 'green' },
940
+ });
941
+
942
+ expect(result).toStrictEqual({ decision: 'ask' });
943
+
944
+ expect(JSON.parse(stdin)).toMatchObject({
945
+ cwd: '/main',
946
+ session_id: 'main-session',
947
+ transcript_path: '/main/user.jsonl',
948
+ });
949
+ });
950
+
951
+ test('it honors a configured fail-closed classifier verdict', async ($, on) => {
952
+ on('classic.SessionStart', () => ({}));
953
+ on('session.cwd', () => ({ value: '/repo' }));
954
+ on('tool.check', () => ({ decision: 'ask' }));
955
+
956
+ on('process.run', () => ({
957
+ value: {
958
+ exitCode: 0,
959
+ stdout: JSON.stringify({
960
+ hookSpecificOutput: {
961
+ hookEventName: 'PermissionRequest',
962
+ decision: { behavior: 'deny', message: '[Classifier Unavailable] Jev unavailable.' },
963
+ },
964
+ }),
965
+ stderr: '',
966
+ isStdoutTruncated: false,
967
+ isStderrTruncated: false,
968
+ },
969
+ }));
970
+
971
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
972
+ await $.classic.SessionStart({ source: 'startup' });
973
+
974
+ const result = await $.tool.check({
975
+ tool: 'Write',
976
+ input: { file_path: '/repo/fixture', content: 'green' },
977
+ });
978
+
979
+ expect(result).toStrictEqual({
980
+ decision: 'deny',
981
+ reason: '[Classifier Unavailable] Jev unavailable.',
982
+ });
983
+ });
984
+
985
+ test('it reads the current directory between two calls without a new prompt', async ($, on) => {
986
+ let cwd = '/first';
987
+ const inputs: unknown[] = [];
988
+
989
+ on('classic.SessionStart', () => ({}));
990
+ on('session.cwd', () => ({ value: cwd }));
991
+ on('tool.check', () => ({ decision: 'ask' }));
992
+
993
+ on('process.run', (_api, e) => {
994
+ inputs.push(JSON.parse(e.init?.stdin ?? ''));
995
+
996
+ return {
997
+ value: {
998
+ exitCode: 0,
999
+ stdout: '',
1000
+ stderr: '',
1001
+ isStdoutTruncated: false,
1002
+ isStderrTruncated: false,
1003
+ },
1004
+ };
1005
+ });
1006
+
1007
+ // oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
1008
+ await $.classic.SessionStart({ source: 'startup', cwd: '/first' });
1009
+ await $.tool.check({ tool: 'Bash', input: { command: 'rm fixture.txt' } });
1010
+
1011
+ cwd = '/second';
1012
+
1013
+ await $.tool.check({ tool: 'Bash', input: { command: 'rm fixture.txt' } });
1014
+
1015
+ expect(inputs).toMatchObject([{ cwd: '/first' }, { cwd: '/second' }]);
1016
+ });