@namzu/sdk 52.4.0 → 52.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.
Files changed (152) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/authorization/gate.d.ts +11 -0
  3. package/dist/authorization/gate.d.ts.map +1 -1
  4. package/dist/authorization/gate.js +35 -0
  5. package/dist/authorization/gate.js.map +1 -1
  6. package/dist/authorization/removal.js +11 -6
  7. package/dist/authorization/removal.js.map +1 -1
  8. package/dist/authorization/rules.d.ts.map +1 -1
  9. package/dist/authorization/rules.js +30 -2
  10. package/dist/authorization/rules.js.map +1 -1
  11. package/dist/authorization/shell-paths.d.ts +20 -0
  12. package/dist/authorization/shell-paths.d.ts.map +1 -1
  13. package/dist/authorization/shell-paths.js +254 -52
  14. package/dist/authorization/shell-paths.js.map +1 -1
  15. package/dist/bridge/acp/server.d.ts +8 -0
  16. package/dist/bridge/acp/server.d.ts.map +1 -1
  17. package/dist/bridge/acp/server.js +7 -0
  18. package/dist/bridge/acp/server.js.map +1 -1
  19. package/dist/manager/resident/outbox.d.ts +4 -4
  20. package/dist/public-runtime.d.ts +2 -1
  21. package/dist/public-runtime.d.ts.map +1 -1
  22. package/dist/public-runtime.js +4 -1
  23. package/dist/public-runtime.js.map +1 -1
  24. package/dist/public-types.d.ts +1 -0
  25. package/dist/public-types.d.ts.map +1 -1
  26. package/dist/runtime/query/auto-review/classifier.d.ts +130 -0
  27. package/dist/runtime/query/auto-review/classifier.d.ts.map +1 -0
  28. package/dist/runtime/query/auto-review/classifier.js +244 -0
  29. package/dist/runtime/query/auto-review/classifier.js.map +1 -0
  30. package/dist/runtime/query/auto-review/index.d.ts +7 -0
  31. package/dist/runtime/query/auto-review/index.d.ts.map +1 -0
  32. package/dist/runtime/query/auto-review/index.js +4 -0
  33. package/dist/runtime/query/auto-review/index.js.map +1 -0
  34. package/dist/runtime/query/auto-review/prompt.d.ts +20 -0
  35. package/dist/runtime/query/auto-review/prompt.d.ts.map +1 -0
  36. package/dist/runtime/query/auto-review/prompt.js +89 -0
  37. package/dist/runtime/query/auto-review/prompt.js.map +1 -0
  38. package/dist/runtime/query/auto-review/rules.d.ts +49 -0
  39. package/dist/runtime/query/auto-review/rules.d.ts.map +1 -0
  40. package/dist/runtime/query/auto-review/rules.js +81 -0
  41. package/dist/runtime/query/auto-review/rules.js.map +1 -0
  42. package/dist/runtime/query/auto-review/transcript.d.ts +59 -0
  43. package/dist/runtime/query/auto-review/transcript.d.ts.map +1 -0
  44. package/dist/runtime/query/auto-review/transcript.js +116 -0
  45. package/dist/runtime/query/auto-review/transcript.js.map +1 -0
  46. package/dist/runtime/query/auto-review/verdict.d.ts +21 -0
  47. package/dist/runtime/query/auto-review/verdict.d.ts.map +1 -0
  48. package/dist/runtime/query/auto-review/verdict.js +45 -0
  49. package/dist/runtime/query/auto-review/verdict.js.map +1 -0
  50. package/dist/runtime/query/executor.d.ts +7 -0
  51. package/dist/runtime/query/executor.d.ts.map +1 -1
  52. package/dist/runtime/query/executor.js +26 -2
  53. package/dist/runtime/query/executor.js.map +1 -1
  54. package/dist/runtime/query/index.js +2 -2
  55. package/dist/runtime/query/index.js.map +1 -1
  56. package/dist/runtime/query/iteration/phases/context.d.ts +0 -9
  57. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  58. package/dist/runtime/query/iteration/phases/context.js +11 -2
  59. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  60. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  61. package/dist/runtime/query/iteration/phases/tool-review.js +39 -0
  62. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  63. package/dist/runtime/query/path-escalation.d.ts.map +1 -1
  64. package/dist/runtime/query/path-escalation.js +43 -10
  65. package/dist/runtime/query/path-escalation.js.map +1 -1
  66. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  67. package/dist/runtime/query/resume-pending.js +14 -0
  68. package/dist/runtime/query/resume-pending.js.map +1 -1
  69. package/dist/runtime/query/review-policy.d.ts +88 -1
  70. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  71. package/dist/runtime/query/review-policy.js +504 -11
  72. package/dist/runtime/query/review-policy.js.map +1 -1
  73. package/dist/tools/builtins/bash.d.ts.map +1 -1
  74. package/dist/tools/builtins/bash.js +2 -0
  75. package/dist/tools/builtins/bash.js.map +1 -1
  76. package/dist/tools/builtins/classifier-input.d.ts +9 -0
  77. package/dist/tools/builtins/classifier-input.d.ts.map +1 -0
  78. package/dist/tools/builtins/classifier-input.js +20 -0
  79. package/dist/tools/builtins/classifier-input.js.map +1 -0
  80. package/dist/tools/builtins/code-analysis.d.ts +2 -0
  81. package/dist/tools/builtins/code-analysis.d.ts.map +1 -1
  82. package/dist/tools/builtins/code-analysis.js +196 -119
  83. package/dist/tools/builtins/code-analysis.js.map +1 -1
  84. package/dist/tools/builtins/computer-use.d.ts +10 -10
  85. package/dist/tools/builtins/edit-apply.d.ts.map +1 -1
  86. package/dist/tools/builtins/edit-apply.js +30 -12
  87. package/dist/tools/builtins/edit-apply.js.map +1 -1
  88. package/dist/tools/builtins/edit-match.d.ts +47 -0
  89. package/dist/tools/builtins/edit-match.d.ts.map +1 -0
  90. package/dist/tools/builtins/edit-match.js +262 -0
  91. package/dist/tools/builtins/edit-match.js.map +1 -0
  92. package/dist/tools/builtins/edit.d.ts.map +1 -1
  93. package/dist/tools/builtins/edit.js +12 -0
  94. package/dist/tools/builtins/edit.js.map +1 -1
  95. package/dist/tools/builtins/monitor.d.ts.map +1 -1
  96. package/dist/tools/builtins/monitor.js +3 -0
  97. package/dist/tools/builtins/monitor.js.map +1 -1
  98. package/dist/tools/builtins/web.d.ts.map +1 -1
  99. package/dist/tools/builtins/web.js +3 -0
  100. package/dist/tools/builtins/web.js.map +1 -1
  101. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  102. package/dist/tools/builtins/write-file.js +6 -0
  103. package/dist/tools/builtins/write-file.js.map +1 -1
  104. package/dist/tools/defineTool.d.ts +2 -0
  105. package/dist/tools/defineTool.d.ts.map +1 -1
  106. package/dist/tools/defineTool.js +1 -0
  107. package/dist/tools/defineTool.js.map +1 -1
  108. package/dist/types/acp/index.d.ts +7 -0
  109. package/dist/types/acp/index.d.ts.map +1 -1
  110. package/dist/types/code-analysis/index.d.ts +54 -3
  111. package/dist/types/code-analysis/index.d.ts.map +1 -1
  112. package/dist/types/hitl/index.d.ts +45 -1
  113. package/dist/types/hitl/index.d.ts.map +1 -1
  114. package/dist/types/hitl/index.js.map +1 -1
  115. package/dist/types/tool/index.d.ts +16 -0
  116. package/dist/types/tool/index.d.ts.map +1 -1
  117. package/dist/types/tool/index.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/authorization/gate.ts +35 -0
  120. package/src/authorization/removal.ts +12 -6
  121. package/src/authorization/rules.ts +27 -2
  122. package/src/authorization/shell-paths.ts +264 -51
  123. package/src/bridge/acp/server.ts +20 -0
  124. package/src/public-runtime.ts +18 -0
  125. package/src/public-types.ts +11 -0
  126. package/src/runtime/query/auto-review/classifier.ts +404 -0
  127. package/src/runtime/query/auto-review/index.ts +23 -0
  128. package/src/runtime/query/auto-review/prompt.ts +97 -0
  129. package/src/runtime/query/auto-review/rules.ts +106 -0
  130. package/src/runtime/query/auto-review/transcript.ts +159 -0
  131. package/src/runtime/query/auto-review/verdict.ts +50 -0
  132. package/src/runtime/query/executor.ts +29 -2
  133. package/src/runtime/query/index.ts +2 -2
  134. package/src/runtime/query/iteration/phases/context.ts +17 -3
  135. package/src/runtime/query/iteration/phases/tool-review.ts +41 -0
  136. package/src/runtime/query/path-escalation.ts +44 -9
  137. package/src/runtime/query/resume-pending.ts +14 -0
  138. package/src/runtime/query/review-policy.ts +591 -14
  139. package/src/tools/builtins/bash.ts +3 -0
  140. package/src/tools/builtins/classifier-input.ts +19 -0
  141. package/src/tools/builtins/code-analysis.ts +211 -119
  142. package/src/tools/builtins/edit-apply.ts +31 -12
  143. package/src/tools/builtins/edit-match.ts +274 -0
  144. package/src/tools/builtins/edit.ts +13 -0
  145. package/src/tools/builtins/monitor.ts +4 -0
  146. package/src/tools/builtins/web.ts +3 -0
  147. package/src/tools/builtins/write-file.ts +6 -0
  148. package/src/tools/defineTool.ts +3 -0
  149. package/src/types/acp/index.ts +7 -0
  150. package/src/types/code-analysis/index.ts +51 -3
  151. package/src/types/hitl/index.ts +49 -1
  152. package/src/types/tool/index.ts +17 -0
@@ -21,10 +21,18 @@
21
21
  * person (`prompt`).
22
22
  */
23
23
 
24
+ import { unknownProgramInLine } from '../../authorization/program.js'
25
+ import type { ShellWord } from '../../authorization/shell-lexer.js'
26
+ import {
27
+ type ShellInvocation,
28
+ readShellLine,
29
+ shellPathAccess,
30
+ } from '../../authorization/shell-paths.js'
24
31
  import { isTrustedReadOnly } from '../../tools/trusted-read-only.js'
25
32
  import type { ToolManager } from '../../toolsets/manager.js'
26
33
  import type {
27
34
  HITLResumeDecision,
35
+ ResumeContext,
28
36
  ResumeHandler,
29
37
  ToolCallDeclined,
30
38
  ToolCallSummary,
@@ -32,7 +40,9 @@ import type {
32
40
  import type { ApprovalPolicy } from '../../types/hitl/policy.js'
33
41
  import type { SessionId, TurnId } from '../../types/ids/index.js'
34
42
  import { PLAN_MODE_REFUSAL } from '../../types/permission/index.js'
43
+ import { type AutoReviewClassifier, autoReviewBlockedFeedback } from './auto-review/classifier.js'
35
44
  import { DECLINED_TOOL_CALL_FEEDBACK } from './declined.js'
45
+ import { shellEscalations } from './path-escalation.js'
36
46
 
37
47
  export type ReviewMode =
38
48
  /** Ask a person. The default when a `prompt` is supplied. */
@@ -68,6 +78,32 @@ export type ReviewMode =
68
78
  * an unattended turn can actually be reasoned about.
69
79
  */
70
80
  | 'strict'
81
+ /**
82
+ * Run everything except what no mode may approve in advance.
83
+ *
84
+ * Where `auto` approves what remains after the questions a person must
85
+ * answer (a path outside the roots, a program nobody can name, a protected
86
+ * write), `bypass` also approves those. It still asks, or refuses when
87
+ * nobody can be asked: a `deny` rule (the gate refuses before this is
88
+ * reached), an `ask` rule or a hook that asked for a person, a catastrophic
89
+ * removal, a tool's own `requiresApproval`, a sandbox escape, and a call
90
+ * whose safety checks did not finish. The dangerous-pattern floor refuses in
91
+ * the gate, unchanged. With nobody to ask, whatever would have been asked is
92
+ * refused.
93
+ */
94
+ | 'bypass'
95
+ /**
96
+ * Let a classifier model decide what a person would otherwise be asked.
97
+ *
98
+ * Needs a {@link ReviewPolicyOptions.classifier}. A call that `prompt` would
99
+ * ask about goes to it instead, and it allows or blocks; the questions that
100
+ * are a person's to answer (the same set `bypass` keeps) still go to one,
101
+ * or are refused when nobody can be asked. Calls that would not be asked
102
+ * about in `prompt`, calls `accept-edits` would approve, and the tools on
103
+ * {@link CLASSIFIER_SAFE_TOOLS} never reach it. See
104
+ * `runtime/query/auto-review/`.
105
+ */
106
+ | 'classify'
71
107
 
72
108
  export const REVIEW_MODES: readonly ReviewMode[] = [
73
109
  'prompt',
@@ -75,6 +111,8 @@ export const REVIEW_MODES: readonly ReviewMode[] = [
75
111
  'auto',
76
112
  'strict',
77
113
  'plan',
114
+ 'bypass',
115
+ 'classify',
78
116
  ]
79
117
 
80
118
  export function isReviewMode(value: unknown): value is ReviewMode {
@@ -248,6 +286,219 @@ export const PROTECTED_PATH_UNATTENDED_REFUSAL =
248
286
  export const DANGEROUS_REMOVAL_UNATTENDED_REFUSAL =
249
287
  'Refused: a call in this batch removes something this check treats as catastrophic (the file system root, a top-level or home directory, the working directory or a parent of it, a glob across directories, or a target a variable or substitution may leave empty), which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Name the exact directory to remove with a literal path.'
250
288
 
289
+ /**
290
+ * What the model is told when a batch carries a call a rule or a hook asked a
291
+ * person to approve and nobody can be asked.
292
+ */
293
+ export const ASK_RULE_UNATTENDED_REFUSAL =
294
+ 'Refused: a call in this batch is one a rule or a hook says a person must approve, and nobody can be asked in this session. Nothing in this batch ran. Run it in a session where a person can be asked, or have the operator change the rule.'
295
+
296
+ /**
297
+ * What the model is told when the checks that decide whether a call reaches
298
+ * past its boundary did not finish and nobody can be asked.
299
+ */
300
+ export const UNFINISHED_CHECK_UNATTENDED_REFUSAL =
301
+ 'Refused: the safety checks on a call in this batch did not finish, so it cannot be shown to be safe, and a person has to approve it and nobody can be asked in this session. Nothing in this batch ran. Try a simpler form of the call.'
302
+
303
+ /** What the model is told when the conversation is too long for the classifier to read. */
304
+ export const CLASSIFIER_TRANSCRIPT_TOO_LONG_FEEDBACK =
305
+ 'Blocked by auto mode: the conversation has become too long for the safety check to read, so this action was not run. The conversation is being shortened; try the action again.'
306
+
307
+ /**
308
+ * Tools the classifier is never asked about: they read, plan or keep the
309
+ * agent's own notes, and cannot change anything outside the session.
310
+ */
311
+ export const CLASSIFIER_SAFE_TOOLS: ReadonlySet<string> = new Set([
312
+ 'read',
313
+ 'grep',
314
+ 'glob',
315
+ 'ls',
316
+ 'lsp',
317
+ 'code_analysis',
318
+ 'search_tools',
319
+ 'view_image',
320
+ 'task_create',
321
+ 'task_update',
322
+ 'task_list',
323
+ 'agent_task_list',
324
+ 'get_goal',
325
+ 'update_goal',
326
+ 'exit_plan_mode',
327
+ ])
328
+
329
+ /**
330
+ * Programs an allow rule must not stand in for the classifier on: each runs
331
+ * whatever it is handed, so a rule that let `python` through would approve any
332
+ * script without the classifier reading it. Matched on the program's base name
333
+ * with any `.exe` removed, so `/usr/bin/python3` and `Python3.11` count.
334
+ */
335
+ const CLASSIFIER_LAUNCHERS: ReadonlySet<string> = new Set([
336
+ 'node',
337
+ 'nodejs',
338
+ 'deno',
339
+ 'tsx',
340
+ 'ts-node',
341
+ 'ruby',
342
+ 'perl',
343
+ 'php',
344
+ 'lua',
345
+ 'rscript',
346
+ 'julia',
347
+ 'osascript',
348
+ 'npx',
349
+ 'bunx',
350
+ 'uvx',
351
+ 'pipx',
352
+ 'bash',
353
+ 'sh',
354
+ 'dash',
355
+ 'ash',
356
+ 'ksh',
357
+ 'zsh',
358
+ 'fish',
359
+ 'csh',
360
+ 'tcsh',
361
+ 'pwsh',
362
+ 'powershell',
363
+ 'cmd',
364
+ 'busybox',
365
+ 'ssh',
366
+ 'eval',
367
+ 'exec',
368
+ 'env',
369
+ 'xargs',
370
+ 'sudo',
371
+ 'doas',
372
+ 'make',
373
+ 'gmake',
374
+ 'awk',
375
+ 'gawk',
376
+ 'mawk',
377
+ 'nawk',
378
+ ])
379
+
380
+ /** Interpreters named by a pattern rather than a list: `python`, `python3`, `python3.11`. */
381
+ const CLASSIFIER_LAUNCHER_PATTERNS: readonly RegExp[] = [/^python[0-9.]*$/, /^pypy[0-9.]*$/]
382
+
383
+ /**
384
+ * Package managers and what they run. `npm` starts a script only through
385
+ * these verbs; the others treat any verb that is not one of their own commands
386
+ * as a script name (`pnpm build`, `yarn test`, `bun app.ts`).
387
+ */
388
+ const NPM_SCRIPT_VERBS: ReadonlySet<string> = new Set([
389
+ 'run',
390
+ 'run-script',
391
+ 'rum',
392
+ 'urn',
393
+ 'test',
394
+ 't',
395
+ 'tst',
396
+ 'start',
397
+ 'stop',
398
+ 'restart',
399
+ 'exec',
400
+ 'x',
401
+ 'init',
402
+ 'create',
403
+ 'innit',
404
+ 'explore',
405
+ ])
406
+ const SCRIPT_RUNNERS_WITH_OWN_VERBS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
407
+ [
408
+ 'pnpm',
409
+ new Set(['install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un', 'update', 'up', 'upgrade']),
410
+ ],
411
+ ['yarn', new Set(['install', 'add', 'remove', 'upgrade', 'up', 'info', 'why', 'list', 'config'])],
412
+ [
413
+ 'bun',
414
+ new Set(['install', 'i', 'add', 'a', 'remove', 'rm', 'update', 'pm', 'outdated', 'info']),
415
+ ],
416
+ ])
417
+ /** Package-manager and tool flags whose value is the next word, so the verb is not that word. */
418
+ const FLAGS_WITH_A_VALUE: ReadonlySet<string> = new Set([
419
+ '-C',
420
+ '--dir',
421
+ '--cwd',
422
+ '--prefix',
423
+ '--filter',
424
+ '-F',
425
+ '-w',
426
+ '--workspace',
427
+ '--registry',
428
+ '--userconfig',
429
+ '--cache',
430
+ '--loglevel',
431
+ '--config',
432
+ '--cd',
433
+ ])
434
+
435
+ /** The first word of `args` that is not an option or an option's value. */
436
+ function verbOf(args: readonly ShellWord[]): string | undefined {
437
+ for (let i = 0; i < args.length; i += 1) {
438
+ const value = (args[i] as ShellWord).value
439
+ if (value === '--') return args[i + 1]?.value
440
+ if (!value.startsWith('-')) return value
441
+ if (!value.includes('=') && FLAGS_WITH_A_VALUE.has(value)) i += 1
442
+ }
443
+ return undefined
444
+ }
445
+
446
+ /** Whether a program in a line runs code it is handed, so that an allow rule cannot stand in for a reviewer. */
447
+ function launchesCode(invocation: ShellInvocation): boolean {
448
+ const program = (invocation.program.split(/[\\/]/).pop() ?? invocation.program)
449
+ .toLowerCase()
450
+ .replace(/\.exe$/, '')
451
+ if (CLASSIFIER_LAUNCHERS.has(program)) return true
452
+ if (CLASSIFIER_LAUNCHER_PATTERNS.some((pattern) => pattern.test(program))) return true
453
+ const verb = verbOf(invocation.args)
454
+ switch (program) {
455
+ case 'npm':
456
+ return verb !== undefined && NPM_SCRIPT_VERBS.has(verb)
457
+ case 'pnpm':
458
+ case 'yarn':
459
+ case 'bun': {
460
+ // A bare `yarn` runs the install; anything else that is not the manager's
461
+ // own command is a script, or for bun a file.
462
+ if (verb === undefined) return false
463
+ return !(SCRIPT_RUNNERS_WITH_OWN_VERBS.get(program) as ReadonlySet<string>).has(verb)
464
+ }
465
+ case 'uv':
466
+ return verb === 'run' || verb === 'tool' || verb === 'x'
467
+ case 'poetry':
468
+ case 'pipenv':
469
+ case 'cargo':
470
+ case 'go':
471
+ return verb === 'run'
472
+ case 'git':
473
+ // `-c key=value` can name a program git then runs (core.pager, core.sshCommand).
474
+ return invocation.args.some((arg) => /^(?:-c|--config-env)/.test(arg.value))
475
+ case 'find':
476
+ return invocation.args.some((arg) =>
477
+ /^-(?:exec|execdir|ok|okdir|fprint|fprintf|fls)/.test(arg.value),
478
+ )
479
+ default:
480
+ return false
481
+ }
482
+ }
483
+
484
+ /** Tools that start other agents; an allow rule for one is not consulted under `classify`. */
485
+ const CLASSIFIER_DELEGATION_TOOLS: ReadonlySet<string> = new Set([
486
+ 'agent',
487
+ 'create_task',
488
+ 'continue_task',
489
+ ])
490
+
491
+ /** The shell commands `accept-edits` also approves, when every path they name is inside the roots. */
492
+ export const ACCEPT_EDITS_SHELL_COMMANDS: ReadonlySet<string> = new Set([
493
+ 'mkdir',
494
+ 'touch',
495
+ 'rm',
496
+ 'rmdir',
497
+ 'mv',
498
+ 'cp',
499
+ 'sed',
500
+ ])
501
+
251
502
  /**
252
503
  * What the model is told when a batch would show it the operator's screen for
253
504
  * the first time in a session and nobody can be asked.
@@ -270,19 +521,132 @@ export interface ScreenConsentRecord {
270
521
  readonly sessions: Set<string>
271
522
  }
272
523
 
273
- /** Whether the call writes a protected path or removes something catastrophic. */
524
+ /**
525
+ * Whether the call writes a protected path, removes something catastrophic, or
526
+ * could not be checked.
527
+ */
274
528
  function reachesAFloor(tc: ToolCallSummary): boolean {
275
529
  return (
276
530
  (tc.escalation?.protectedPaths?.length ?? 0) > 0 ||
277
- tc.escalation?.dangerousRemoval !== undefined
531
+ tc.escalation?.dangerousRemoval !== undefined ||
532
+ tc.escalation?.unfinishedCheck !== undefined
278
533
  )
279
534
  }
280
535
 
281
536
  /** The refusal for a batch that reaches a floor and cannot be asked about it. */
282
537
  function floorRefusal(toolCalls: readonly ToolCallSummary[]): string {
283
- return toolCalls.some((tc) => (tc.escalation?.protectedPaths?.length ?? 0) > 0)
284
- ? PROTECTED_PATH_UNATTENDED_REFUSAL
285
- : DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
538
+ if (toolCalls.some((tc) => (tc.escalation?.protectedPaths?.length ?? 0) > 0)) {
539
+ return PROTECTED_PATH_UNATTENDED_REFUSAL
540
+ }
541
+ return toolCalls.some((tc) => tc.escalation?.dangerousRemoval !== undefined)
542
+ ? DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
543
+ : UNFINISHED_CHECK_UNATTENDED_REFUSAL
544
+ }
545
+
546
+ /**
547
+ * Whether a mode needs a batch a rule already allowed to come to the policy.
548
+ *
549
+ * `plan` must refuse a change a rule would have let through, and `classify`
550
+ * must look at a call whose allow rule is too wide to vouch for it. A host
551
+ * answers its `reviewAllowedCalls` with this for the mode it is in.
552
+ */
553
+ export function modeReviewsAllowedCalls(mode: ReviewMode): boolean {
554
+ return mode === 'plan' || mode === 'classify'
555
+ }
556
+
557
+ /** Whether the mode keeps only the questions a person must answer, and leaves the rest to itself. */
558
+ function isPermissive(mode: ReviewMode): boolean {
559
+ return mode === 'bypass' || mode === 'classify'
560
+ }
561
+
562
+ /**
563
+ * Whether the call is one of the few no mode may settle in advance: a rule or
564
+ * hook asked for a person, a sandbox escape, a catastrophic removal, a check
565
+ * that did not finish, or a tool that always needs approval. The dangerous
566
+ * pattern floor and `deny` rules are not here: the gate refuses those first.
567
+ */
568
+ function isBypassImmune(tc: ToolCallSummary): boolean {
569
+ return (
570
+ tc.authorization?.askRule === true ||
571
+ tc.escalation?.sandboxEscape === true ||
572
+ tc.escalation?.dangerousRemoval !== undefined ||
573
+ tc.escalation?.unfinishedCheck !== undefined ||
574
+ tc.requiresApproval === true
575
+ )
576
+ }
577
+
578
+ /** The refusal for the first immune thing in a batch nobody can be asked about, or undefined. */
579
+ function immuneRefusal(
580
+ toolCalls: readonly ToolCallSummary[],
581
+ escapeConfirmed: boolean,
582
+ ): string | undefined {
583
+ if (!escapeConfirmed && toolCalls.some((tc) => tc.escalation?.sandboxEscape === true)) {
584
+ return SANDBOX_ESCAPE_UNATTENDED_REFUSAL
585
+ }
586
+ if (toolCalls.some((tc) => tc.escalation?.dangerousRemoval !== undefined)) {
587
+ return DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
588
+ }
589
+ if (toolCalls.some((tc) => tc.escalation?.unfinishedCheck !== undefined)) {
590
+ return UNFINISHED_CHECK_UNATTENDED_REFUSAL
591
+ }
592
+ if (toolCalls.some((tc) => tc.requiresApproval === true)) {
593
+ return REQUIRES_APPROVAL_UNATTENDED_REFUSAL
594
+ }
595
+ if (toolCalls.some((tc) => tc.authorization?.askRule === true)) return ASK_RULE_UNATTENDED_REFUSAL
596
+ return undefined
597
+ }
598
+
599
+ /** One sed command a script may hold: a substitution with plain flags, or a delete, print or line number. */
600
+ const SAFE_SED_COMMAND =
601
+ /^\s*(?:\d+(?:,\d+)?|\$|\/[^/]*\/)?\s*(?:s(.)(?:(?!\1)[^\\]|\\.)*\1(?:(?!\1)[^\\]|\\.)*\1[gpiI0-9]*|[dpP=])\s*$/
602
+
603
+ /**
604
+ * Whether a `sed` invocation can only edit text: its script is made of
605
+ * substitutions and simple line commands. A script with the `e`, `w` or `r`
606
+ * commands or flags can run a program or reach another file, and a script from
607
+ * a file cannot be read here, so those are not edits this vouches for.
608
+ */
609
+ function sedOnlyEdits(
610
+ args: readonly { readonly value: string; readonly expands: boolean }[],
611
+ ): boolean {
612
+ const scripts: string[] = []
613
+ let positional = 0
614
+ for (let i = 0; i < args.length; i += 1) {
615
+ const arg = args[i] as { value: string; expands: boolean }
616
+ if (arg.expands) return false
617
+ const text = arg.value
618
+ if (text === '-e') {
619
+ const next = args[i + 1]
620
+ if (next === undefined || next.expands) return false
621
+ scripts.push(next.value)
622
+ i += 1
623
+ continue
624
+ }
625
+ if (text.startsWith('--expression=')) {
626
+ scripts.push(text.slice('--expression='.length))
627
+ continue
628
+ }
629
+ if (text === '-f' || text.startsWith('--file') || text === '--follow-symlinks') return false
630
+ if (text.startsWith('-')) {
631
+ if (
632
+ !/^-(?:[nErszi]+|i\S*|-in-place(?:=\S*)?|-quiet|-silent|-regexp-extended|-separate|-null-data)$/.test(
633
+ text,
634
+ )
635
+ ) {
636
+ return false
637
+ }
638
+ continue
639
+ }
640
+ positional += 1
641
+ if (positional === 1 && scripts.length === 0) scripts.push(text)
642
+ }
643
+ if (scripts.length === 0) return false
644
+ return scripts.every((script) =>
645
+ script
646
+ .split(/;|\n/)
647
+ .filter((part) => part.trim() !== '')
648
+ .every((part) => SAFE_SED_COMMAND.test(part)),
649
+ )
286
650
  }
287
651
 
288
652
  /** The batch a person is asked about. */
@@ -384,6 +748,39 @@ export interface ReviewPolicyOptions {
384
748
  * other mode.
385
749
  */
386
750
  readonly planModeWrite?: (call: ToolCallSummary) => boolean
751
+ /**
752
+ * The classifier `classify` mode asks. Required for that mode (building a
753
+ * `classify` policy without one throws) and ignored in every other.
754
+ */
755
+ readonly classifier?: AutoReviewClassifier
756
+ /** Tool names added to {@link CLASSIFIER_SAFE_TOOLS} for `classify`: never sent to the classifier. */
757
+ readonly safeTools?: readonly string[]
758
+ /**
759
+ * The roots the turn works in, the working directory first and then the
760
+ * added directories (or a function that reads them now). `accept-edits`,
761
+ * and `classify` through it, approve `mkdir`, `touch`, `rm`, `rmdir`, `mv`,
762
+ * `cp` and `sed` only when every path the shell line names is inside them,
763
+ * and only when the host says what they are: without this those commands
764
+ * are asked about as before.
765
+ */
766
+ readonly roots?: readonly string[] | (() => readonly string[])
767
+ /**
768
+ * Called when the classifier has blocked enough calls (see
769
+ * `AutoReviewLimits`) that the host should stop relying on it. The policy
770
+ * then asks the person about the call that tripped the limit, or ends the
771
+ * turn when there is nobody to ask. It does not change the mode: the host,
772
+ * which owns the mode and how it is shown, does that here.
773
+ */
774
+ readonly onClassifierLimit?: (event: {
775
+ readonly limit: 'consecutive' | 'total'
776
+ readonly reason: string
777
+ }) => void
778
+ /**
779
+ * Called when the conversation was too long for the classifier to read, so
780
+ * a host can start a compaction. The call is blocked once with a note telling
781
+ * the model to try again.
782
+ */
783
+ readonly onClassifierTranscriptTooLong?: () => void
387
784
  }
388
785
 
389
786
  /**
@@ -424,6 +821,17 @@ function isHookGranted(tc: ToolCallSummary): boolean {
424
821
  export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHandler {
425
822
  const { prompt, registry } = options
426
823
  const mode: ReviewMode = options.mode ?? (prompt ? 'prompt' : 'auto')
824
+ if (mode === 'classify' && options.classifier === undefined) {
825
+ throw new TypeError(
826
+ 'The classify review mode needs a classifier (ReviewPolicyOptions.classifier).',
827
+ )
828
+ }
829
+ const safeTools = new Set([...CLASSIFIER_SAFE_TOOLS, ...(options.safeTools ?? [])])
830
+ /** Set once the classifier has said the conversation is too long, until it judges something. */
831
+ let transcriptTooLong = false
832
+ const toolOf = (name: string) => registry?.get(name) ?? registry?.get(name.toLowerCase())
833
+ const rootsNow = (): readonly string[] =>
834
+ typeof options.roots === 'function' ? options.roots() : (options.roots ?? [])
427
835
  const exempt: ReviewExemption =
428
836
  options.exempt ??
429
837
  (registry ? (name, input) => isReviewExempt(registry, name, input) : () => false)
@@ -436,7 +844,103 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
436
844
  return tool?.capturesScreen?.(input) === true
437
845
  }
438
846
  : () => false)
439
- return async (request): Promise<HITLResumeDecision> => {
847
+ /**
848
+ * Whether a shell call only makes, touches, removes, moves, copies or edits
849
+ * files, and every path it names is inside the roots: what `accept-edits`
850
+ * approves beyond `edit` and `write`. A catastrophic removal, a protected
851
+ * path, a path in a variable, a program nobody can name or a `sed` script
852
+ * that can run something is not that.
853
+ */
854
+ const shellFileCommandsInRoots = async (tc: ToolCallSummary): Promise<boolean> => {
855
+ const rootList = rootsNow()
856
+ if (rootList.length === 0) return false
857
+ if (
858
+ tc.escalation !== undefined ||
859
+ tc.isDestructive ||
860
+ tc.requiresApproval === true ||
861
+ tc.authorization?.explicitReview ||
862
+ tc.authorization?.askRule
863
+ ) {
864
+ return false
865
+ }
866
+ const tool = toolOf(tc.name)
867
+ const argument = tool?.commandArgument
868
+ if (tool === undefined || argument === undefined) return false
869
+ const line = (tc.input as Record<string, unknown> | null)?.[argument]
870
+ if (typeof line !== 'string') return false
871
+ const dialect = tool.commandDialect?.({ sandboxed: false }) ?? 'sh'
872
+ if (unknownProgramInLine(line, dialect) !== undefined) return false
873
+ const reading = readShellLine(line, dialect)
874
+ if (!reading.complete || reading.invocations.length === 0) return false
875
+ for (const invocation of reading.invocations) {
876
+ if (invocation.program === 'cd') continue
877
+ if (!ACCEPT_EDITS_SHELL_COMMANDS.has(invocation.program)) return false
878
+ if (invocation.program === 'sed' && !sedOnlyEdits(invocation.args)) return false
879
+ }
880
+ if (shellPathAccess(reading).uses.some((use) => use.unresolved)) return false
881
+ const found = await shellEscalations(line, dialect, { roots: rootList, reviewOutside: true })
882
+ return (
883
+ found.outsidePaths.length === 0 &&
884
+ found.protectedPaths.length === 0 &&
885
+ found.dangerousRemoval === undefined
886
+ )
887
+ }
888
+
889
+ /** What `accept-edits` approves without asking: edits, exempt reads and file commands inside the roots. */
890
+ const acceptEditsAllows = async (tc: ToolCallSummary): Promise<boolean> =>
891
+ !tc.authorization?.explicitReview &&
892
+ tc.escalation === undefined &&
893
+ !tc.isDestructive &&
894
+ tc.requiresApproval !== true &&
895
+ (ACCEPT_EDITS_TOOLS.has(tc.name) ||
896
+ exempt(tc.name, tc.input) ||
897
+ (await shellFileCommandsInRoots(tc)))
898
+
899
+ /**
900
+ * Whether a rule's `allow` is one the classifier should not defer to: the
901
+ * rule covers a whole shell tool, or the line starts an interpreter or a
902
+ * launcher that runs whatever it is given, or the tool starts other agents.
903
+ * Judged on the call as well as the rule because a rule that names a
904
+ * program ("python") is a rule about a program that can run anything.
905
+ */
906
+ const allowRuleTooWide = (tc: ToolCallSummary): boolean => {
907
+ if (CLASSIFIER_DELEGATION_TOOLS.has(tc.name.toLowerCase())) return true
908
+ const tool = toolOf(tc.name)
909
+ const argument = tool?.commandArgument
910
+ if (tool === undefined || argument === undefined) return false
911
+ if (tc.authorization?.allowScope === 'tool') return true
912
+ const line = (tc.input as Record<string, unknown> | null)?.[argument]
913
+ if (typeof line !== 'string') return true
914
+ const reading = readShellLine(line, tool.commandDialect?.({ sandboxed: false }) ?? 'sh')
915
+ return !reading.complete || reading.invocations.some(launchesCode)
916
+ }
917
+
918
+ /** Whether `classify` decides this call without the classifier: it would not be asked, or is plainly safe. */
919
+ const neverSent = async (tc: ToolCallSummary): Promise<boolean> => {
920
+ if (tc.authorization?.decision === 'allow' && !allowRuleTooWide(tc)) return true
921
+ // A name on the safe list is safe for what it does in the session; a read of
922
+ // /etc/shadow is still a read of /etc/shadow, and a person's own ask is
923
+ // still a person's ask. Anything that reaches past the boundary, or that a
924
+ // rule or hook put to a person, is not "plainly safe".
925
+ if (
926
+ safeTools.has(tc.name.toLowerCase()) &&
927
+ tc.escalation === undefined &&
928
+ !tc.authorization?.explicitReview
929
+ ) {
930
+ return true
931
+ }
932
+ if (
933
+ tc.escalation === undefined &&
934
+ !tc.isDestructive &&
935
+ !tc.authorization?.explicitReview &&
936
+ exempt(tc.name, tc.input)
937
+ ) {
938
+ return true
939
+ }
940
+ return acceptEditsAllows(tc)
941
+ }
942
+
943
+ return async (request, context?: ResumeContext): Promise<HITLResumeDecision> => {
440
944
  if (request.type !== 'tool_review') {
441
945
  return request.type === 'plan_approval' ? { action: 'approve_plan' } : { action: 'continue' }
442
946
  }
@@ -503,14 +1007,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
503
1007
  // through beside it would be approving something it did not show.
504
1008
  if (
505
1009
  mode === 'accept-edits' &&
506
- request.toolCalls.every(
507
- (tc) =>
508
- !tc.authorization?.explicitReview &&
509
- tc.escalation === undefined &&
510
- !tc.isDestructive &&
511
- tc.requiresApproval !== true &&
512
- (ACCEPT_EDITS_TOOLS.has(tc.name) || exempt(tc.name, tc.input)),
513
- )
1010
+ (await Promise.all(request.toolCalls.map(acceptEditsAllows))).every(Boolean)
514
1011
  ) {
515
1012
  return { action: 'approve_tools' }
516
1013
  }
@@ -531,6 +1028,86 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
531
1028
  return { action: 'reject_tools', feedback: PLAN_MODE_REFUSAL }
532
1029
  }
533
1030
  if (mode === 'strict') return { action: 'reject_tools', feedback: STRICT_MODE_REFUSAL }
1031
+ if (isPermissive(mode)) {
1032
+ // A person's answer on the whole batch, as a decision. An escape is
1033
+ // confirmed by id only here, only for a person's yes.
1034
+ const askPerson = async (escapes: readonly string[]): Promise<HITLResumeDecision> => {
1035
+ const answer = await ask()
1036
+ if (answer.kind === 'reject') {
1037
+ return {
1038
+ action: 'reject_tools',
1039
+ feedback: answer.feedback ?? DECLINED_TOOL_CALL_FEEDBACK,
1040
+ ...(answer.declined ? { declined: answer.declined } : {}),
1041
+ }
1042
+ }
1043
+ if (answer.kind === 'approve-all') remembered.all = true
1044
+ return {
1045
+ action: 'approve_tools',
1046
+ ...(escapes.length > 0 ? { confirmedEscalations: escapes } : {}),
1047
+ }
1048
+ }
1049
+ const live = request.toolCalls.filter((tc) => tc.authorization?.decision !== 'deny')
1050
+ const escapes = live.filter((tc) => tc.escalation?.sandboxEscape === true).map((tc) => tc.id)
1051
+ if (mode === 'classify') {
1052
+ const classifier = options.classifier as AutoReviewClassifier
1053
+ const transcript = context?.transcript() ?? []
1054
+ for (const tc of live) {
1055
+ // The questions a person must answer are asked of a person, never of a model.
1056
+ if (isBypassImmune(tc) || (await neverSent(tc))) continue
1057
+ const decision = await classifier.review({
1058
+ call: tc,
1059
+ transcript,
1060
+ ...(registry ? { tools: registry } : {}),
1061
+ ...(context?.signal ? { signal: context.signal } : {}),
1062
+ })
1063
+ if (decision.verdict === 'allow') {
1064
+ transcriptTooLong = false
1065
+ continue
1066
+ }
1067
+ if (decision.verdict === 'too_long') {
1068
+ if (!transcriptTooLong) {
1069
+ transcriptTooLong = true
1070
+ options.onClassifierTranscriptTooLong?.()
1071
+ return { action: 'reject_tools', feedback: CLASSIFIER_TRANSCRIPT_TOO_LONG_FEEDBACK }
1072
+ }
1073
+ // Still too long after the host was told: nobody can judge it
1074
+ // automatically. A person decides, or the turn ends.
1075
+ if (!prompt) {
1076
+ return {
1077
+ action: 'abort',
1078
+ reason: 'auto mode stopped: the conversation is too long for the safety check',
1079
+ }
1080
+ }
1081
+ return askPerson(escapes)
1082
+ }
1083
+ transcriptTooLong = false
1084
+ if (decision.limitReached !== undefined) {
1085
+ options.onClassifierLimit?.({ limit: decision.limitReached, reason: decision.reason })
1086
+ if (!prompt) {
1087
+ return {
1088
+ action: 'abort',
1089
+ reason: `auto mode stopped: too many actions were blocked (${decision.reason})`,
1090
+ }
1091
+ }
1092
+ return askPerson(escapes)
1093
+ }
1094
+ return {
1095
+ action: 'reject_tools',
1096
+ feedback: autoReviewBlockedFeedback(decision.reason),
1097
+ }
1098
+ }
1099
+ }
1100
+ if (!live.some(isBypassImmune)) return { action: 'approve_tools' }
1101
+ if (!prompt) {
1102
+ const refusal = immuneRefusal(live, options.unattendedSandboxEscape === 'allow')
1103
+ if (refusal !== undefined) return { action: 'reject_tools', feedback: refusal }
1104
+ return {
1105
+ action: 'approve_tools',
1106
+ ...(escapes.length > 0 ? { confirmedEscalations: escapes } : {}),
1107
+ }
1108
+ }
1109
+ return askPerson(escapes)
1110
+ }
534
1111
  // A sandbox escape is asked about every time, in every mode that got
535
1112
  // this far — `auto` and a remembered "approve all" included, because
536
1113
  // both are answers somebody gave BEFORE this command existed. The
@@ -478,6 +478,9 @@ const BashToolBase = defineTool({
478
478
  // so a permission rule about it is a rule about several commands more often
479
479
  // than not. Naming the argument is what lets the gate read it that way.
480
480
  commandArgument: 'command',
481
+ // The classifier judges the command line, not the surrounding options.
482
+ classifierInput: (input: BashInput) =>
483
+ `${input.dangerously_disable_sandbox === true ? '[outside the sandbox] ' : ''}${input.command}`,
481
484
  // The shell that runs it, so the rules read the line as that shell will.
482
485
  commandDialect: bashToolDialect,
483
486
  // The kernel reviews a call that sets it under a sandbox every time and