auto-mode 0.4.4 → 0.6.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/README.md +39 -23
- package/dist/cli.js +23 -88
- package/dist/index.d.ts +27 -36
- package/dist/index.js +2 -2
- package/dist/{classify-locally-BdXQYjfj.js → render-verdict-9pnm4JBS.js} +746 -724
- package/mods/auto-mode/hooks/build-prompt-context.claude-check.ts +0 -1
- package/mods/auto-mode/hooks/build-prompt-context.ts +0 -2
- package/mods/auto-mode/hooks/parse-decision.ts +12 -24
- package/mods/auto-mode/hooks/register.claude-check.ts +140 -93
- package/mods/auto-mode/hooks/register.ts +21 -31
- package/mods/auto-mode/hooks/types.ts +1 -2
- package/package.json +3 -5
- package/policy/denial.md +1 -0
|
@@ -14,7 +14,6 @@ test('it keeps the original human task separate from later instructions', () =>
|
|
|
14
14
|
expect(later).toStrictEqual({
|
|
15
15
|
originalUserTask: { text: 'Build the parser', origin: 'composer' },
|
|
16
16
|
lastDirectUserMessage: { text: 'Do not push', origin: 'bridge' },
|
|
17
|
-
hasPrompt: true,
|
|
18
17
|
canCaptureOriginal: true,
|
|
19
18
|
});
|
|
20
19
|
});
|
|
@@ -14,7 +14,6 @@ export function buildPromptContext(
|
|
|
14
14
|
return {
|
|
15
15
|
originalUserTask: null,
|
|
16
16
|
lastDirectUserMessage: null,
|
|
17
|
-
hasPrompt: false,
|
|
18
17
|
canCaptureOriginal: input.source === 'startup' || input.source === 'clear',
|
|
19
18
|
};
|
|
20
19
|
}
|
|
@@ -31,7 +30,6 @@ export function buildPromptContext(
|
|
|
31
30
|
previous?.originalUserTask ??
|
|
32
31
|
(previous?.canCaptureOriginal === true ? lastDirectUserMessage : null),
|
|
33
32
|
lastDirectUserMessage,
|
|
34
|
-
hasPrompt: true,
|
|
35
33
|
canCaptureOriginal: previous?.canCaptureOriginal ?? false,
|
|
36
34
|
};
|
|
37
35
|
}
|
|
@@ -3,6 +3,8 @@ interface Decision {
|
|
|
3
3
|
readonly reason?: string;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
// The CLI ships with this mod, so any other shape is a mismatched or broken CLI,
|
|
7
|
+
// and only an exact verdict may replace the prompt.
|
|
6
8
|
export function parseDecision(stdout: string): Decision | null {
|
|
7
9
|
let body: unknown;
|
|
8
10
|
|
|
@@ -12,39 +14,25 @@ export function parseDecision(stdout: string): Decision | null {
|
|
|
12
14
|
return null;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
if (typeof body !== 'object' || body === null ||
|
|
17
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
16
18
|
return null;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
const
|
|
21
|
+
const keys = Object.keys(body).toSorted().join(',');
|
|
20
22
|
|
|
21
|
-
if (
|
|
22
|
-
typeof output !== 'object' ||
|
|
23
|
-
output === null ||
|
|
24
|
-
!('hookEventName' in output) ||
|
|
25
|
-
output.hookEventName !== 'PermissionRequest' ||
|
|
26
|
-
!('decision' in output)
|
|
27
|
-
) {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const value = output.decision;
|
|
32
|
-
|
|
33
|
-
if (typeof value !== 'object' || value === null || !('behavior' in value)) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (value.behavior === 'allow') {
|
|
23
|
+
if (keys === 'decision' && 'decision' in body && body.decision === 'allow') {
|
|
38
24
|
return { decision: 'allow' };
|
|
39
25
|
}
|
|
40
26
|
|
|
41
27
|
if (
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
|
|
45
|
-
|
|
28
|
+
keys === 'decision,reason' &&
|
|
29
|
+
'decision' in body &&
|
|
30
|
+
body.decision === 'deny' &&
|
|
31
|
+
'reason' in body &&
|
|
32
|
+
typeof body.reason === 'string' &&
|
|
33
|
+
body.reason.trim() !== ''
|
|
46
34
|
) {
|
|
47
|
-
return { decision: 'deny', reason:
|
|
35
|
+
return { decision: 'deny', reason: body.reason };
|
|
48
36
|
}
|
|
49
37
|
|
|
50
38
|
return null;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { expect, test } from 'claude-code/testing';
|
|
2
2
|
|
|
3
|
+
const NO_PROMPT_CONTEXT = {
|
|
4
|
+
agentID: null,
|
|
5
|
+
originalUserTask: null,
|
|
6
|
+
delegatedTask: null,
|
|
7
|
+
lastDirectUserMessage: null,
|
|
8
|
+
omittedTaskContext: [{ field: 'originalUserTask', reason: 'unavailable' }],
|
|
9
|
+
};
|
|
10
|
+
|
|
3
11
|
test('it preserves an existing denial and its rule', async ($, on) => {
|
|
4
12
|
const decided = {
|
|
5
13
|
decision: 'deny',
|
|
@@ -20,8 +28,7 @@ test('it preserves an existing denial and its rule', async ($, on) => {
|
|
|
20
28
|
return {
|
|
21
29
|
value: {
|
|
22
30
|
exitCode: 0,
|
|
23
|
-
stdout:
|
|
24
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
|
|
31
|
+
stdout: '{"decision":"allow"}',
|
|
25
32
|
stderr: 'synthetic-private-fragment',
|
|
26
33
|
isStdoutTruncated: false,
|
|
27
34
|
isStderrTruncated: false,
|
|
@@ -66,8 +73,7 @@ test('it preserves an existing allowance without a second evaluator', async ($,
|
|
|
66
73
|
return {
|
|
67
74
|
value: {
|
|
68
75
|
exitCode: 0,
|
|
69
|
-
stdout:
|
|
70
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"[Data Exfiltration] Refuse the transfer."}}}',
|
|
76
|
+
stdout: '{"decision":"deny","reason":"[Data Exfiltration] Refuse the transfer."}',
|
|
71
77
|
stderr: 'synthetic-private-fragment',
|
|
72
78
|
isStdoutTruncated: false,
|
|
73
79
|
isStderrTruncated: false,
|
|
@@ -118,8 +124,7 @@ test('it approves an ask after Jev allows the action', async ($, on) => {
|
|
|
118
124
|
return {
|
|
119
125
|
value: {
|
|
120
126
|
exitCode: 0,
|
|
121
|
-
stdout:
|
|
122
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
|
|
127
|
+
stdout: '{"decision":"allow"}',
|
|
123
128
|
stderr: 'synthetic-private-fragment',
|
|
124
129
|
isStdoutTruncated: false,
|
|
125
130
|
isStderrTruncated: false,
|
|
@@ -146,13 +151,11 @@ test('it approves an ask after Jev allows the action', async ($, on) => {
|
|
|
146
151
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
147
152
|
|
|
148
153
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
149
|
-
|
|
150
|
-
prompt_id: 'mod-check',
|
|
151
|
-
session_id: 'session-1',
|
|
154
|
+
sessionID: 'session-1',
|
|
152
155
|
cwd: '/repo',
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
toolName: 'Bash',
|
|
157
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
158
|
+
context: NO_PROMPT_CONTEXT,
|
|
156
159
|
});
|
|
157
160
|
|
|
158
161
|
expect(invocation).toMatchObject({
|
|
@@ -187,8 +190,7 @@ test('it refuses an ask after Jev denies the action', async ($, on) => {
|
|
|
187
190
|
return {
|
|
188
191
|
value: {
|
|
189
192
|
exitCode: 0,
|
|
190
|
-
stdout:
|
|
191
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"[Data Exfiltration] Refuse the transfer."}}}',
|
|
193
|
+
stdout: '{"decision":"deny","reason":"[Data Exfiltration] Refuse the transfer."}',
|
|
192
194
|
stderr: 'synthetic-private-fragment',
|
|
193
195
|
isStdoutTruncated: false,
|
|
194
196
|
isStderrTruncated: false,
|
|
@@ -219,13 +221,11 @@ test('it refuses an ask after Jev denies the action', async ($, on) => {
|
|
|
219
221
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
220
222
|
|
|
221
223
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
222
|
-
|
|
223
|
-
prompt_id: 'mod-check',
|
|
224
|
-
session_id: 'session-1',
|
|
224
|
+
sessionID: 'session-1',
|
|
225
225
|
cwd: '/repo',
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
226
|
+
toolName: 'Bash',
|
|
227
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
228
|
+
context: NO_PROMPT_CONTEXT,
|
|
229
229
|
});
|
|
230
230
|
|
|
231
231
|
expect(invocation).toMatchObject({
|
|
@@ -287,13 +287,11 @@ test('it retains manual approval when Jev returns no opinion', async ($, on) =>
|
|
|
287
287
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
288
288
|
|
|
289
289
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
290
|
-
|
|
291
|
-
prompt_id: 'mod-check',
|
|
292
|
-
session_id: 'session-1',
|
|
290
|
+
sessionID: 'session-1',
|
|
293
291
|
cwd: '/repo',
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
toolName: 'Bash',
|
|
293
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
294
|
+
context: NO_PROMPT_CONTEXT,
|
|
297
295
|
});
|
|
298
296
|
|
|
299
297
|
expect(invocation).toMatchObject({
|
|
@@ -355,13 +353,11 @@ test('it retains manual approval for malformed JSON without copying diagnostics'
|
|
|
355
353
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
356
354
|
|
|
357
355
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
358
|
-
|
|
359
|
-
prompt_id: 'mod-check',
|
|
360
|
-
session_id: 'session-1',
|
|
356
|
+
sessionID: 'session-1',
|
|
361
357
|
cwd: '/repo',
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
358
|
+
toolName: 'Bash',
|
|
359
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
360
|
+
context: NO_PROMPT_CONTEXT,
|
|
365
361
|
});
|
|
366
362
|
|
|
367
363
|
expect(invocation).toMatchObject({
|
|
@@ -370,7 +366,7 @@ test('it retains manual approval for malformed JSON without copying diagnostics'
|
|
|
370
366
|
});
|
|
371
367
|
});
|
|
372
368
|
|
|
373
|
-
test('it retains manual approval for a verdict
|
|
369
|
+
test('it retains manual approval for a hook-shaped verdict', async ($, on) => {
|
|
374
370
|
const decided = {
|
|
375
371
|
decision: 'ask',
|
|
376
372
|
reason: 'Existing permission decision',
|
|
@@ -424,13 +420,11 @@ test('it retains manual approval for a verdict from another event', async ($, on
|
|
|
424
420
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
425
421
|
|
|
426
422
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
427
|
-
|
|
428
|
-
prompt_id: 'mod-check',
|
|
429
|
-
session_id: 'session-1',
|
|
423
|
+
sessionID: 'session-1',
|
|
430
424
|
cwd: '/repo',
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
425
|
+
toolName: 'Bash',
|
|
426
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
427
|
+
context: NO_PROMPT_CONTEXT,
|
|
434
428
|
});
|
|
435
429
|
|
|
436
430
|
expect(invocation).toMatchObject({
|
|
@@ -439,6 +433,47 @@ test('it retains manual approval for a verdict from another event', async ($, on
|
|
|
439
433
|
});
|
|
440
434
|
});
|
|
441
435
|
|
|
436
|
+
test('it retains manual approval for a verdict with unknown or mistyped fields', async ($, on) => {
|
|
437
|
+
const outputs = [
|
|
438
|
+
'{"decision":"allow","reason":123}',
|
|
439
|
+
'{"decision":"allow","rule":"Read-only actions"}',
|
|
440
|
+
'{"decision":"deny","reason":"[Data Exfiltration] Refuse the transfer.","extra":true}',
|
|
441
|
+
'{"decision":"deny","reason":42}',
|
|
442
|
+
'["allow"]',
|
|
443
|
+
];
|
|
444
|
+
|
|
445
|
+
let stdout = '';
|
|
446
|
+
|
|
447
|
+
on('classic.SessionStart', () => ({}));
|
|
448
|
+
on('session.cwd', () => ({ value: '/repo' }));
|
|
449
|
+
on('tool.check', () => ({ decision: 'ask' }));
|
|
450
|
+
|
|
451
|
+
on('process.run', () => ({
|
|
452
|
+
value: {
|
|
453
|
+
exitCode: 0,
|
|
454
|
+
stdout,
|
|
455
|
+
stderr: '',
|
|
456
|
+
isStdoutTruncated: false,
|
|
457
|
+
isStderrTruncated: false,
|
|
458
|
+
},
|
|
459
|
+
}));
|
|
460
|
+
|
|
461
|
+
// oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
|
|
462
|
+
await $.classic.SessionStart({ source: 'startup', session_id: 'session-1', cwd: '/repo' });
|
|
463
|
+
|
|
464
|
+
const results: unknown[] = [];
|
|
465
|
+
|
|
466
|
+
for (const output of outputs) {
|
|
467
|
+
stdout = output;
|
|
468
|
+
|
|
469
|
+
const result = await $.tool.check({ tool: 'Bash', input: { command: 'rm fixture.txt' } });
|
|
470
|
+
|
|
471
|
+
results.push(result);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
expect(results).toStrictEqual(outputs.map(() => ({ decision: 'ask' })));
|
|
475
|
+
});
|
|
476
|
+
|
|
442
477
|
test('it retains manual approval for a malformed denial', async ($, on) => {
|
|
443
478
|
const decided = {
|
|
444
479
|
decision: 'ask',
|
|
@@ -465,8 +500,7 @@ test('it retains manual approval for a malformed denial', async ($, on) => {
|
|
|
465
500
|
return {
|
|
466
501
|
value: {
|
|
467
502
|
exitCode: 0,
|
|
468
|
-
stdout:
|
|
469
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}',
|
|
503
|
+
stdout: '{"decision":"deny"}',
|
|
470
504
|
stderr: 'synthetic-private-fragment',
|
|
471
505
|
isStdoutTruncated: false,
|
|
472
506
|
isStderrTruncated: false,
|
|
@@ -493,13 +527,11 @@ test('it retains manual approval for a malformed denial', async ($, on) => {
|
|
|
493
527
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
494
528
|
|
|
495
529
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
496
|
-
|
|
497
|
-
prompt_id: 'mod-check',
|
|
498
|
-
session_id: 'session-1',
|
|
530
|
+
sessionID: 'session-1',
|
|
499
531
|
cwd: '/repo',
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
532
|
+
toolName: 'Bash',
|
|
533
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
534
|
+
context: NO_PROMPT_CONTEXT,
|
|
503
535
|
});
|
|
504
536
|
|
|
505
537
|
expect(invocation).toMatchObject({
|
|
@@ -534,8 +566,7 @@ test('it retains manual approval for a truncated response', async ($, on) => {
|
|
|
534
566
|
return {
|
|
535
567
|
value: {
|
|
536
568
|
exitCode: 0,
|
|
537
|
-
stdout:
|
|
538
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
|
|
569
|
+
stdout: '{"decision":"allow"}',
|
|
539
570
|
stderr: 'synthetic-private-fragment',
|
|
540
571
|
isStdoutTruncated: true,
|
|
541
572
|
isStderrTruncated: false,
|
|
@@ -562,13 +593,11 @@ test('it retains manual approval for a truncated response', async ($, on) => {
|
|
|
562
593
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
563
594
|
|
|
564
595
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
565
|
-
|
|
566
|
-
prompt_id: 'mod-check',
|
|
567
|
-
session_id: 'session-1',
|
|
596
|
+
sessionID: 'session-1',
|
|
568
597
|
cwd: '/repo',
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
598
|
+
toolName: 'Bash',
|
|
599
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
600
|
+
context: NO_PROMPT_CONTEXT,
|
|
572
601
|
});
|
|
573
602
|
|
|
574
603
|
expect(invocation).toMatchObject({
|
|
@@ -603,8 +632,7 @@ test('it retains manual approval when the child exits nonzero', async ($, on) =>
|
|
|
603
632
|
return {
|
|
604
633
|
value: {
|
|
605
634
|
exitCode: 1,
|
|
606
|
-
stdout:
|
|
607
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
|
|
635
|
+
stdout: '{"decision":"allow"}',
|
|
608
636
|
stderr: 'synthetic-private-fragment',
|
|
609
637
|
isStdoutTruncated: false,
|
|
610
638
|
isStderrTruncated: false,
|
|
@@ -631,13 +659,11 @@ test('it retains manual approval when the child exits nonzero', async ($, on) =>
|
|
|
631
659
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
632
660
|
|
|
633
661
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
634
|
-
|
|
635
|
-
prompt_id: 'mod-check',
|
|
636
|
-
session_id: 'session-1',
|
|
662
|
+
sessionID: 'session-1',
|
|
637
663
|
cwd: '/repo',
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
664
|
+
toolName: 'Bash',
|
|
665
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
666
|
+
context: NO_PROMPT_CONTEXT,
|
|
641
667
|
});
|
|
642
668
|
|
|
643
669
|
expect(invocation).toMatchObject({
|
|
@@ -690,13 +716,11 @@ test('it retains manual approval when the child times out', async ($, on) => {
|
|
|
690
716
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
691
717
|
|
|
692
718
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
693
|
-
|
|
694
|
-
prompt_id: 'mod-check',
|
|
695
|
-
session_id: 'session-1',
|
|
719
|
+
sessionID: 'session-1',
|
|
696
720
|
cwd: '/repo',
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
721
|
+
toolName: 'Bash',
|
|
722
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
723
|
+
context: NO_PROMPT_CONTEXT,
|
|
700
724
|
});
|
|
701
725
|
|
|
702
726
|
expect(invocation).toMatchObject({
|
|
@@ -724,8 +748,7 @@ test('it retains manual approval before the session context arrives', async ($,
|
|
|
724
748
|
return {
|
|
725
749
|
value: {
|
|
726
750
|
exitCode: 0,
|
|
727
|
-
stdout:
|
|
728
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
|
|
751
|
+
stdout: '{"decision":"allow"}',
|
|
729
752
|
stderr: 'synthetic-private-fragment',
|
|
730
753
|
isStdoutTruncated: false,
|
|
731
754
|
isStderrTruncated: false,
|
|
@@ -771,8 +794,7 @@ test(
|
|
|
771
794
|
return {
|
|
772
795
|
value: {
|
|
773
796
|
exitCode: 0,
|
|
774
|
-
stdout:
|
|
775
|
-
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}',
|
|
797
|
+
stdout: '{"decision":"allow"}',
|
|
776
798
|
stderr: 'synthetic-private-fragment',
|
|
777
799
|
isStdoutTruncated: false,
|
|
778
800
|
isStderrTruncated: false,
|
|
@@ -799,13 +821,11 @@ test(
|
|
|
799
821
|
expect(timeoutMs).toBeLessThanOrEqual(8000);
|
|
800
822
|
|
|
801
823
|
expect(JSON.parse(stdin)).toStrictEqual({
|
|
802
|
-
|
|
803
|
-
prompt_id: 'mod-check',
|
|
804
|
-
session_id: 'session-1',
|
|
824
|
+
sessionID: 'session-1',
|
|
805
825
|
cwd: '/repo',
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
826
|
+
toolName: 'Bash',
|
|
827
|
+
toolInput: { command: 'git push origin feature', timeout: 120_000 },
|
|
828
|
+
context: NO_PROMPT_CONTEXT,
|
|
809
829
|
});
|
|
810
830
|
|
|
811
831
|
expect(invocation).toMatchObject({
|
|
@@ -856,11 +876,45 @@ test('it refreshes the context from the current user prompt after reload', async
|
|
|
856
876
|
});
|
|
857
877
|
|
|
858
878
|
expect(result).toStrictEqual({ decision: 'ask' });
|
|
879
|
+
expect(JSON.parse(stdin)).toMatchObject({ sessionID: 'new-session', cwd: '/new-repo' });
|
|
880
|
+
expect(stdin.includes('transcript')).toBe(false);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test('it carries the session identity and the direct user message', async ($, on) => {
|
|
884
|
+
let stdin = '';
|
|
885
|
+
|
|
886
|
+
on('classic.SessionStart', () => ({}));
|
|
887
|
+
on('prompt.submit', (_api, e) => ({ text: e.text }));
|
|
888
|
+
on('session.cwd', () => ({ value: '/repo' }));
|
|
889
|
+
on('tool.check', () => ({ decision: 'ask' }));
|
|
890
|
+
|
|
891
|
+
on('process.run', (_api, e) => {
|
|
892
|
+
stdin = e.init?.stdin ?? '';
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
value: {
|
|
896
|
+
exitCode: 0,
|
|
897
|
+
stdout: '',
|
|
898
|
+
stderr: '',
|
|
899
|
+
isStdoutTruncated: false,
|
|
900
|
+
isStderrTruncated: false,
|
|
901
|
+
},
|
|
902
|
+
};
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// oxlint-disable-next-line new-cap -- The host event API retains its event spelling.
|
|
906
|
+
await $.classic.SessionStart({ source: 'startup', session_id: 'session-1', cwd: '/repo' });
|
|
907
|
+
await $.prompt.submit({ text: 'Clean the build output.', origin: { kind: 'composer' } });
|
|
908
|
+
await $.tool.check({ tool: 'Bash', input: { command: 'rm -rf dist' } });
|
|
859
909
|
|
|
860
910
|
expect(JSON.parse(stdin)).toMatchObject({
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
911
|
+
sessionID: 'session-1',
|
|
912
|
+
context: {
|
|
913
|
+
agentID: null,
|
|
914
|
+
originalUserTask: { text: 'Clean the build output.' },
|
|
915
|
+
lastDirectUserMessage: { text: 'Clean the build output.' },
|
|
916
|
+
omittedTaskContext: [],
|
|
917
|
+
},
|
|
864
918
|
});
|
|
865
919
|
});
|
|
866
920
|
|
|
@@ -892,7 +946,7 @@ test('it preserves the complete action without truncation', async ($, on) => {
|
|
|
892
946
|
const result = await $.tool.check({ tool: 'Write', input: { file_path: '/repo/file', content } });
|
|
893
947
|
|
|
894
948
|
expect(result).toStrictEqual({ decision: 'ask' });
|
|
895
|
-
expect(JSON.parse(stdin)).toMatchObject({
|
|
949
|
+
expect(JSON.parse(stdin)).toMatchObject({ toolInput: { file_path: '/repo/file', content } });
|
|
896
950
|
});
|
|
897
951
|
|
|
898
952
|
test('it does not replace the main context with a subagent prompt', async ($, on) => {
|
|
@@ -940,12 +994,7 @@ test('it does not replace the main context with a subagent prompt', async ($, on
|
|
|
940
994
|
});
|
|
941
995
|
|
|
942
996
|
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
|
-
});
|
|
997
|
+
expect(JSON.parse(stdin)).toMatchObject({ cwd: '/main', sessionID: 'main-session' });
|
|
949
998
|
});
|
|
950
999
|
|
|
951
1000
|
test('it honors a configured fail-closed classifier verdict', async ($, on) => {
|
|
@@ -957,10 +1006,8 @@ test('it honors a configured fail-closed classifier verdict', async ($, on) => {
|
|
|
957
1006
|
value: {
|
|
958
1007
|
exitCode: 0,
|
|
959
1008
|
stdout: JSON.stringify({
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
decision: { behavior: 'deny', message: '[Classifier Unavailable] Jev unavailable.' },
|
|
963
|
-
},
|
|
1009
|
+
decision: 'deny',
|
|
1010
|
+
reason: '[Classifier Unavailable] Jev unavailable.',
|
|
964
1011
|
}),
|
|
965
1012
|
stderr: '',
|
|
966
1013
|
isStdoutTruncated: false,
|
|
@@ -3,10 +3,7 @@ import { parseDecision } from './parse-decision.ts';
|
|
|
3
3
|
import type { ModOn, ModOptions, PromptContext } from './types.ts';
|
|
4
4
|
|
|
5
5
|
export function register(on: ModOn, options: ModOptions): void {
|
|
6
|
-
let
|
|
7
|
-
readonly sessionID: string;
|
|
8
|
-
readonly transcriptPath: string;
|
|
9
|
-
} | null = null;
|
|
6
|
+
let sessionID: string | null = null;
|
|
10
7
|
|
|
11
8
|
const activeCalls = new Map<string, string | null>();
|
|
12
9
|
const delegatedTasks = new Map<string, string>();
|
|
@@ -15,9 +12,9 @@ export function register(on: ModOn, options: ModOptions): void {
|
|
|
15
12
|
|
|
16
13
|
on('classic.SessionStart', (_api, e, next) => {
|
|
17
14
|
if (e.agent_id === undefined) {
|
|
18
|
-
const isCompact = e.source === 'compact' &&
|
|
15
|
+
const isCompact = e.source === 'compact' && sessionID === e.session_id;
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
sessionID = e.session_id;
|
|
21
18
|
|
|
22
19
|
const previousPrompts = isCompact ? prompts : null;
|
|
23
20
|
|
|
@@ -33,7 +30,7 @@ export function register(on: ModOn, options: ModOptions): void {
|
|
|
33
30
|
|
|
34
31
|
on('classic.UserPromptSubmit', (_api, e, next) => {
|
|
35
32
|
if (e.agent_id === undefined) {
|
|
36
|
-
|
|
33
|
+
sessionID = e.session_id;
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
return next(e);
|
|
@@ -83,7 +80,7 @@ export function register(on: ModOn, options: ModOptions): void {
|
|
|
83
80
|
return decided;
|
|
84
81
|
}
|
|
85
82
|
|
|
86
|
-
if (next.signal.aborted ||
|
|
83
|
+
if (next.signal.aborted || sessionID === null) {
|
|
87
84
|
const status = next.signal.aborted ? 'cancelled' : 'missing session context';
|
|
88
85
|
|
|
89
86
|
$.ui.log(`${logPrefix} evaluation skipped; ${status}`, { to: 'debug' });
|
|
@@ -140,31 +137,24 @@ export function register(on: ModOn, options: ModOptions): void {
|
|
|
140
137
|
|
|
141
138
|
$.ui.log(`${logPrefix} evaluator invoked`, { to: 'debug' });
|
|
142
139
|
|
|
140
|
+
const request = {
|
|
141
|
+
sessionID,
|
|
142
|
+
...(e.tool_use_id === undefined ? {} : { toolUseID: e.tool_use_id }),
|
|
143
|
+
cwd,
|
|
144
|
+
toolName: e.tool,
|
|
145
|
+
toolInput: e.input,
|
|
146
|
+
context: {
|
|
147
|
+
agentID: agentID ?? null,
|
|
148
|
+
originalUserTask: prompts.originalUserTask,
|
|
149
|
+
delegatedTask,
|
|
150
|
+
lastDirectUserMessage: isChild ? null : prompts.lastDirectUserMessage,
|
|
151
|
+
omittedTaskContext,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
143
155
|
const result = await $.process.run(
|
|
144
156
|
[command, 'run', '--jev-only', '--evaluation-deadline', String(deadlineAt)],
|
|
145
|
-
{
|
|
146
|
-
timeoutMs: childTimeoutMs,
|
|
147
|
-
stdin: JSON.stringify({
|
|
148
|
-
hook_event_name: 'PermissionRequest',
|
|
149
|
-
prompt_id: e.tool_use_id ?? 'mod-check',
|
|
150
|
-
session_id: context.sessionID,
|
|
151
|
-
cwd,
|
|
152
|
-
...(isChild ? {} : { transcript_path: context.transcriptPath }),
|
|
153
|
-
...(prompts.hasPrompt || isChild
|
|
154
|
-
? {
|
|
155
|
-
auto_mode_context: {
|
|
156
|
-
agentID: agentID ?? null,
|
|
157
|
-
originalUserTask: prompts.originalUserTask,
|
|
158
|
-
delegatedTask,
|
|
159
|
-
lastDirectUserMessage: isChild ? null : prompts.lastDirectUserMessage,
|
|
160
|
-
omittedTaskContext,
|
|
161
|
-
},
|
|
162
|
-
}
|
|
163
|
-
: {}),
|
|
164
|
-
tool_name: e.tool,
|
|
165
|
-
tool_input: e.input,
|
|
166
|
-
}),
|
|
167
|
-
},
|
|
157
|
+
{ timeoutMs: childTimeoutMs, stdin: JSON.stringify(request) },
|
|
168
158
|
);
|
|
169
159
|
|
|
170
160
|
if (result.exitCode !== 0 || result.isStdoutTruncated || next.signal.aborted) {
|
|
@@ -45,7 +45,6 @@ export interface UserTask {
|
|
|
45
45
|
export interface PromptContext {
|
|
46
46
|
readonly originalUserTask: UserTask | null;
|
|
47
47
|
readonly lastDirectUserMessage: UserTask | null;
|
|
48
|
-
readonly hasPrompt: boolean;
|
|
49
48
|
readonly canCaptureOriginal: boolean;
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -100,7 +99,7 @@ export interface ModAPI {
|
|
|
100
99
|
readonly process: {
|
|
101
100
|
readonly run: (argv: readonly string[], init?: ProcessInput['init']) => Promise<ProcessResult>;
|
|
102
101
|
};
|
|
103
|
-
readonly prompt: { readonly submit: (input:
|
|
102
|
+
readonly prompt: { readonly submit: (input: PromptInput) => Promise<object> };
|
|
104
103
|
readonly tool: { readonly check: (input: CheckInput) => Promise<PermissionDecision> };
|
|
105
104
|
readonly classic: {
|
|
106
105
|
readonly SessionStart: (
|
package/package.json
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-mode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A permission classifier
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "A permission classifier library and Claude Code mod, so agents that are not Claude can run unattended",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
7
7
|
"claude-code",
|
|
8
|
-
"
|
|
9
|
-
"hook",
|
|
10
|
-
"muse",
|
|
8
|
+
"mod",
|
|
11
9
|
"permissions"
|
|
12
10
|
],
|
|
13
11
|
"homepage": "https://github.com/zgeoff/auto-mode#readme",
|
package/policy/denial.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Do not retry this action, and do not reach the same result another way: not with a different command, tool, script, file, or agent. Continue the task on a safer path that does not need this action. If no safer path exists for this step, finish the rest of the task first, then tell the user what you need from them for this step.
|