@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
@@ -0,0 +1,159 @@
1
+ /**
2
+ * What the auto-review classifier is shown of the conversation.
3
+ *
4
+ * Three kinds of entry and no more: the person's own messages, the agent's
5
+ * prose, and one short line per tool call, written by the tool itself
6
+ * (`ToolDefinition.classifierInput`). Tool RESULTS are never included. A result
7
+ * is whatever a command printed or a page said, which is exactly where an
8
+ * injected instruction would sit, and a classifier that read it could be
9
+ * argued out of a block by the text it is supposed to be guarding against.
10
+ * Kernel-written context (project instructions, reminders, compaction summaries)
11
+ * is left out too: it was not written by the person.
12
+ */
13
+
14
+ import type { ToolManager } from '../../../toolsets/manager.js'
15
+ import type { Message } from '../../../types/message/index.js'
16
+
17
+ /** One entry of the projected transcript, before it is rendered. */
18
+ export type TranscriptEntry =
19
+ | { readonly kind: 'user'; readonly text: string }
20
+ | { readonly kind: 'assistant'; readonly text: string }
21
+ | { readonly kind: 'tool'; readonly name: string; readonly input: string }
22
+
23
+ export interface ProjectionOptions {
24
+ /** Where tool definitions are looked up for `classifierInput`. Absent: every call is compact JSON. */
25
+ readonly tools?: Pick<ToolManager, 'get'>
26
+ /** Tool-call ids to leave out, because they are the action under review. */
27
+ readonly excludeCallIds?: ReadonlySet<string>
28
+ /** The most characters one entry keeps. Default 2000. */
29
+ readonly maxEntryChars?: number
30
+ }
31
+
32
+ const DEFAULT_ENTRY_CHARS = 2_000
33
+
34
+ /**
35
+ * A file a host inlined into the person's message (`@path`). Its content is
36
+ * whatever the file says, not the person's words, so a second guard removes it
37
+ * even from a message that carries no provenance; the host's own marking
38
+ * (`source`) is the first.
39
+ */
40
+ const INLINED_FILE_BLOCK = /<file\b[^>]*>[\s\S]*?<\/file>/gi
41
+
42
+ function clip(text: string, limit: number): string {
43
+ return text.length <= limit
44
+ ? text
45
+ : `${text.slice(0, limit)}... [${text.length - limit} more characters]`
46
+ }
47
+
48
+ function parseArguments(raw: string): unknown {
49
+ try {
50
+ return JSON.parse(raw)
51
+ } catch {
52
+ return raw
53
+ }
54
+ }
55
+
56
+ /** The line shown for a call, and whether it had to be cut to fit. */
57
+ export interface ProjectedCall {
58
+ readonly text: string
59
+ /** The call is longer than the limit: `text` is a prefix and the rest was never shown. */
60
+ readonly truncated: boolean
61
+ }
62
+
63
+ /**
64
+ * The line shown for a call: the tool's own account of it, or the input as
65
+ * compact JSON when the tool has none or its account is empty or throws.
66
+ * `truncated` is true when what the tool (or the JSON) said was longer than
67
+ * `limit`, so a caller judging THIS call can refuse to judge a prefix of it.
68
+ */
69
+ export function projectAction(
70
+ name: string,
71
+ input: unknown,
72
+ tools: Pick<ToolManager, 'get'> | undefined,
73
+ limit: number = DEFAULT_ENTRY_CHARS,
74
+ ): ProjectedCall {
75
+ const tool = tools?.get(name) ?? tools?.get(name.toLowerCase())
76
+ let text: string | undefined
77
+ if (tool?.classifierInput !== undefined) {
78
+ try {
79
+ const described = tool.classifierInput(input as never)
80
+ if (typeof described === 'string' && described.trim() !== '') text = described
81
+ } catch {
82
+ // A tool that cannot describe its own call is shown as its input.
83
+ }
84
+ }
85
+ if (text === undefined) {
86
+ try {
87
+ text = JSON.stringify(input) ?? ''
88
+ } catch {
89
+ text = String(input)
90
+ }
91
+ }
92
+ return { text: clip(text, limit), truncated: text.length > limit }
93
+ }
94
+
95
+ /** {@link projectAction}'s text, for a line of history where a cut is only context. */
96
+ export function projectCall(
97
+ name: string,
98
+ input: unknown,
99
+ tools: Pick<ToolManager, 'get'> | undefined,
100
+ limit: number = DEFAULT_ENTRY_CHARS,
101
+ ): string {
102
+ return projectAction(name, input, tools, limit).text
103
+ }
104
+
105
+ /** The conversation as the classifier sees it, oldest first. */
106
+ export function projectTranscript(
107
+ messages: readonly Message[],
108
+ options: ProjectionOptions = {},
109
+ ): TranscriptEntry[] {
110
+ const limit = options.maxEntryChars ?? DEFAULT_ENTRY_CHARS
111
+ const entries: TranscriptEntry[] = []
112
+ for (const message of messages) {
113
+ if (message.role === 'user') {
114
+ // A user-role message the kernel wrote (instructions, reminders, a goal
115
+ // round) is not the person speaking.
116
+ if (message.source !== undefined) continue
117
+ if (typeof message.content === 'string') {
118
+ const spoken = message.content.replace(INLINED_FILE_BLOCK, ' ').trim()
119
+ if (spoken !== '') entries.push({ kind: 'user', text: clip(spoken, limit) })
120
+ }
121
+ continue
122
+ }
123
+ if (message.role !== 'assistant') continue
124
+ if (typeof message.content === 'string' && message.content.trim() !== '') {
125
+ entries.push({ kind: 'assistant', text: clip(message.content, limit) })
126
+ }
127
+ for (const call of message.toolCalls ?? []) {
128
+ if (options.excludeCallIds?.has(call.id)) continue
129
+ entries.push({
130
+ kind: 'tool',
131
+ name: call.function.name,
132
+ input: projectCall(
133
+ call.function.name,
134
+ parseArguments(call.function.arguments),
135
+ options.tools,
136
+ limit,
137
+ ),
138
+ })
139
+ }
140
+ }
141
+ return entries
142
+ }
143
+
144
+ /**
145
+ * One entry as a line of JSON, with `<` escaped so that nothing in it can
146
+ * close or open an element of the prompt around it.
147
+ */
148
+ export function renderEntry(entry: TranscriptEntry): string {
149
+ const body =
150
+ entry.kind === 'tool' ? { tool: entry.name, input: entry.input } : { [entry.kind]: entry.text }
151
+ return JSON.stringify(body).replaceAll('<', '\\u003c')
152
+ }
153
+
154
+ /** The size of a rendered transcript, for the decision whether it is too long. */
155
+ export function renderedLength(lines: readonly string[]): number {
156
+ let total = 0
157
+ for (const line of lines) total += line.length + 1
158
+ return total
159
+ }
@@ -0,0 +1,50 @@
1
+ /** Reading the classifier's answer. Anything that is not clearly a verdict is not one. */
2
+
3
+ export interface ParsedVerdict {
4
+ readonly block: boolean
5
+ /** The name of the rule the classifier says applies, for a block. */
6
+ readonly rule?: string
7
+ /** One plain sentence, for a block. */
8
+ readonly reason?: string
9
+ }
10
+
11
+ const MAX_FIELD_CHARS = 300
12
+
13
+ function field(text: string, name: string): string | undefined {
14
+ const pattern = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'gi')
15
+ let last: string | undefined
16
+ for (const match of text.matchAll(pattern)) last = match[1]
17
+ const value = last?.replace(/\s+/g, ' ').trim()
18
+ if (value === undefined || value === '') return undefined
19
+ return value.length > MAX_FIELD_CHARS ? `${value.slice(0, MAX_FIELD_CHARS)}...` : value
20
+ }
21
+
22
+ /**
23
+ * The verdict in `text`, or `undefined` when there is none to be read.
24
+ *
25
+ * Reasoning in a `<thinking>` element is removed first, so a verdict element
26
+ * the model merely discussed there does not count. Verdicts outside it must
27
+ * agree: an answer that both blocks and allows has no verdict, which the caller
28
+ * treats as a block, so a reply cannot be steered into allow by a verdict
29
+ * element the model was only quoting. The closing tag is optional because the first pass stops on it. A
30
+ * `<thinking>` that never closes means the answer was cut off before it
31
+ * decided, and reads as no verdict.
32
+ */
33
+ export function parseVerdict(text: string): ParsedVerdict | undefined {
34
+ let visible = text.replace(/<thinking>[\s\S]*?<\/thinking>/gi, ' ')
35
+ if (/<thinking>/i.test(visible)) return undefined
36
+ visible = visible.trim()
37
+ const decisions = new Set<string>()
38
+ for (const match of visible.matchAll(/<verdict>\s*(block|allow)\b/gi)) {
39
+ decisions.add((match[1] as string).toLowerCase())
40
+ }
41
+ if (decisions.size !== 1) return undefined
42
+ if (decisions.has('allow')) return { block: false }
43
+ const rule = field(visible, 'rule')
44
+ const reason = field(visible, 'reason')
45
+ return {
46
+ block: true,
47
+ ...(rule !== undefined ? { rule } : {}),
48
+ ...(reason !== undefined ? { reason } : {}),
49
+ }
50
+ }
@@ -1019,13 +1019,40 @@ export class ToolExecutor {
1019
1019
  !hostModifiedCallIds?.has(toolCall.id),
1020
1020
  )
1021
1021
  calls.set(toolCall.id, call)
1022
- const escalation = await this.escalationOf(call)
1022
+ const escalation = await this.escalationOrUnfinished(call)
1023
1023
  if (escalation) escalations.set(toolCall.id, escalation)
1024
1024
  }
1025
1025
  this.config.abortSignal.throwIfAborted()
1026
1026
  return this.publishPreparedBatch(calls, escalations)
1027
1027
  }
1028
1028
 
1029
+ /**
1030
+ * {@link escalationOf}, except that a check which itself throws is not
1031
+ * read as "nothing found". The failure becomes an escalation of its own
1032
+ * (`unfinishedCheck`), which no mode approves in advance: a call whose
1033
+ * safety checks did not run is the one a person must look at.
1034
+ */
1035
+ private async escalationOrUnfinished(
1036
+ call: PreparedDirectCall,
1037
+ ): Promise<ToolCallEscalation | undefined> {
1038
+ try {
1039
+ return await this.escalationOf(call)
1040
+ } catch (error) {
1041
+ this.config.abortSignal.throwIfAborted()
1042
+ // A fixed sentence and a code, never the error's own message: this text
1043
+ // goes into the audit trail and the prompt a person or the classifier
1044
+ // reads, and a message can carry a path or a command line.
1045
+ const code = (error as { code?: unknown } | null)?.code
1046
+ const label =
1047
+ typeof code === 'string' && /^[\w.-]{1,40}$/.test(code)
1048
+ ? code
1049
+ : error instanceof Error && /^\w{1,40}$/.test(error.name)
1050
+ ? error.name
1051
+ : 'unknown'
1052
+ return { unfinishedCheck: `the safety checks did not finish (${label})` }
1053
+ }
1054
+ }
1055
+
1029
1056
  /**
1030
1057
  * What a prepared call reaches past the turn's boundary, decided on the
1031
1058
  * value that will execute — after repairs and pre-tool hooks, so a hook
@@ -1180,7 +1207,7 @@ export class ToolExecutor {
1180
1207
  if (changedCallIds.has(toolCall.id)) {
1181
1208
  const call = await prepareDirectCall(this.admissionHost(), toolCall, false)
1182
1209
  calls.set(toolCall.id, call)
1183
- const escalation = await this.escalationOf(call)
1210
+ const escalation = await this.escalationOrUnfinished(call)
1184
1211
  if (escalation) escalations.set(toolCall.id, escalation)
1185
1212
  else escalations.delete(toolCall.id)
1186
1213
  }
@@ -1535,7 +1535,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1535
1535
  sessionId: ctx.sessionId,
1536
1536
  turnId: ctx.turnId,
1537
1537
  toolUseId,
1538
- parkHandler: (request) => approvalPolicy.current.handler(request),
1538
+ parkHandler: (request, context) => approvalPolicy.current.handler(request, context),
1539
1539
  recorder: questionParks,
1540
1540
  pendingAnswers,
1541
1541
  }),
@@ -1769,7 +1769,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1769
1769
  log: ctx.log,
1770
1770
  // Read through the box on every call, so a swap lands on the next
1771
1771
  // question rather than the next turn.
1772
- resumeHandler: (request) => approvalPolicy.current.handler(request),
1772
+ resumeHandler: (request, context) => approvalPolicy.current.handler(request, context),
1773
1773
  takeApprovalPolicyChange: () => approvalPolicy.takeUnannouncedChange(),
1774
1774
  promptContributions,
1775
1775
  ...(params.steering ? { steering: params.steering } : {}),
@@ -15,7 +15,11 @@ import type { ActivityStore } from '../../../../store/activity/memory.js'
15
15
  import type { ToolManager } from '../../../../toolsets/manager.js'
16
16
  import type { TaskScheduler } from '../../../../types/agent/scheduler.js'
17
17
  import type { WorkingMemoryProvider } from '../../../../types/agent/working-memory.js'
18
- import type { HITLResumeDecision, ResumeHandler } from '../../../../types/hitl/index.js'
18
+ import type {
19
+ HITLResumeDecision,
20
+ ResumeContext,
21
+ ResumeHandler,
22
+ } from '../../../../types/hitl/index.js'
19
23
  import type { CheckpointId } from '../../../../types/ids/index.js'
20
24
  import type { DurableInboundSource } from '../../../../types/message/inbound-delivery.js'
21
25
  import type { LLMProvider } from '../../../../types/provider/index.js'
@@ -419,6 +423,15 @@ export async function awaitDecisionDurably(
419
423
  * turns into `setStopReason('cancelled') + markCancelled + stop`. Fails closed:
420
424
  * a resume-handler rejection also resolves to `abort` rather than hanging.
421
425
  */
426
+ /** What a resume handler may read besides the request; see {@link ResumeContext}. */
427
+ function resumeContextOf(ctx: IterationContext): ResumeContext {
428
+ const signal = ctx.abortController?.signal
429
+ return {
430
+ transcript: () => ctx.recorder.messages,
431
+ ...(signal ? { signal } : {}),
432
+ }
433
+ }
434
+
422
435
  export async function awaitDecisionOrAbort(
423
436
  ctx: IterationContext,
424
437
  request: Parameters<ResumeHandler>[0],
@@ -427,7 +440,8 @@ export async function awaitDecisionOrAbort(
427
440
  // No abort signal wired (e.g. a minimal test harness) → behave exactly as a
428
441
  // direct resumeHandler await, no race. In production TurnContextFactory always
429
442
  // provides the controller, so the race below is live.
430
- if (!signal) return ctx.resumeHandler(request)
443
+ const resumeContext = resumeContextOf(ctx)
444
+ if (!signal) return ctx.resumeHandler(request, resumeContext)
431
445
  const abortDecision: HITLResumeDecision = {
432
446
  action: 'abort',
433
447
  reason: 'run aborted while parked for HITL',
@@ -441,7 +455,7 @@ export async function awaitDecisionOrAbort(
441
455
  resolve(abortDecision)
442
456
  }
443
457
  signal.addEventListener('abort', onAbort, { once: true })
444
- Promise.resolve(ctx.resumeHandler(request)).then(
458
+ Promise.resolve(ctx.resumeHandler(request, resumeContext)).then(
445
459
  (decision) => {
446
460
  if (settled) return
447
461
  settled = true
@@ -1,3 +1,4 @@
1
+ import { allowRuleScope } from '../../../../authorization/gate.js'
1
2
  import type { AuthorizationGate } from '../../../../authorization/index.js'
2
3
  import { isTrustedReadOnly } from '../../../../tools/trusted-read-only.js'
3
4
  import type { ToolSourceRef } from '../../../../toolsets/types.js'
@@ -22,6 +23,9 @@ interface VerificationAwareContext extends IterationContext {
22
23
 
23
24
  /** Why an escalated call cannot be waved through by the rule that would otherwise allow it. */
24
25
  function escalationReason(escalation: ToolCallSummary['escalation']): string {
26
+ if (escalation?.unfinishedCheck !== undefined) {
27
+ return `the safety checks on this call did not finish (${escalation.unfinishedCheck})`
28
+ }
25
29
  if (escalation?.unknownProgram !== undefined) {
26
30
  return `this call's program cannot be verified ahead of time (${escalation.unknownProgram})`
27
31
  }
@@ -153,6 +157,8 @@ export async function* runToolReview(
153
157
  decision: 'review' as const,
154
158
  reason: hookAskedReason(hookPermission.reason),
155
159
  explicitReview: true as const,
160
+ // Immune to a mode that auto-approves, as a rule's ask is.
161
+ askRule: true as const,
156
162
  }
157
163
  : { decision: 'review' as const },
158
164
  ...(requiresApproval ? { requiresApproval: true as const } : {}),
@@ -410,6 +416,17 @@ export async function* runToolReview(
410
416
  reason,
411
417
  })
412
418
  }
419
+ if (tc.escalation?.unfinishedCheck !== undefined) {
420
+ await ctx.recorder.recordAudit({
421
+ what: {
422
+ action: 'unfinished_check',
423
+ tool: tc.name,
424
+ resource: tc.escalation.unfinishedCheck,
425
+ },
426
+ outcome: 'refused',
427
+ reason,
428
+ })
429
+ }
413
430
  }
414
431
 
415
432
  // Gate-denied ids survive the whole function: a later human approval
@@ -450,6 +467,9 @@ export async function* runToolReview(
450
467
  }),
451
468
  }))
452
469
  for (const gr of gateResults) {
470
+ // Before the overrides below: only a rule that was WRITTEN to ask a
471
+ // person, not an allow demoted to a question, is an ask rule.
472
+ const ruleAsked = gr.gateResult.decision === 'review' && gr.gateResult.matchedRule !== null
453
473
  // An `allow` rule was written about a tool, not about a path outside
454
474
  // the working directory or a command outside the sandbox, which
455
475
  // were refused outright when it was written. So an escalated call
@@ -491,6 +511,10 @@ export async function* runToolReview(
491
511
  ...(gateResult.decision === 'review' && (gateResult.matchedRule || hookAsked)
492
512
  ? { explicitReview: true as const }
493
513
  : {}),
514
+ ...(ruleAsked || hookAsked ? { askRule: true as const } : {}),
515
+ ...(gateResult.decision === 'allow' && allowRuleScope(gateResult.matchedRule)
516
+ ? { allowScope: allowRuleScope(gateResult.matchedRule) }
517
+ : {}),
494
518
  }
495
519
  }
496
520
 
@@ -601,6 +625,12 @@ export async function* runToolReview(
601
625
  gateDenied.size === 0 &&
602
626
  escalated().length === 0 &&
603
627
  requiresApproval().length === 0 &&
628
+ // A remembered grant is an answer to an earlier question, never to an
629
+ // operator's ask rule or a hook's ask: those exist to put THIS call to a
630
+ // person each time.
631
+ toolCallSummaries.every(
632
+ (tc) => !tc.authorization?.askRule && !tc.authorization?.explicitReview,
633
+ ) &&
604
634
  toolCallSummaries.every((tc) => ctx.toolGrants?.covers(tc))
605
635
  ) {
606
636
  ctx.log.debug('Every tool call is covered by an approval already granted', {
@@ -781,6 +811,17 @@ export async function* runToolReview(
781
811
  reason: "the turn's review approved this call",
782
812
  })
783
813
  }
814
+ if (tc.escalation?.unfinishedCheck !== undefined) {
815
+ await ctx.recorder.recordAudit({
816
+ what: {
817
+ action: 'unfinished_check',
818
+ tool: tc.name,
819
+ resource: tc.escalation.unfinishedCheck,
820
+ },
821
+ outcome: 'approved',
822
+ reason: "the turn's review approved this call",
823
+ })
824
+ }
784
825
  }
785
826
  }
786
827
 
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { homedir } from 'node:os'
19
- import { isAbsolute, relative, resolve } from 'node:path'
19
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
20
20
 
21
21
  import { isProtectedPath } from '../../authorization/protected-paths.js'
22
22
  import { dangerousRemoval } from '../../authorization/removal.js'
@@ -156,14 +156,40 @@ export async function shellEscalations(
156
156
  const protectedPaths = new Set<string>()
157
157
  const home = homedir()
158
158
  if (cwd !== undefined) {
159
+ // A link made earlier in this very line leads somewhere that cannot be
160
+ // read before the line runs: `ln -s /etc l && cd l && echo x > passwd`
161
+ // writes /etc/passwd through a name that is inside the roots. Every
162
+ // operand of an `ln` is such a name (the link is the last, but the
163
+ // check does not guess which), and a path at or under one is outside.
164
+ const madeHere = reading.invocations
165
+ .filter((invocation) => invocation.program === 'ln')
166
+ .flatMap((invocation) => invocation.args.map((arg) => arg.value))
167
+ .filter((value) => value !== '' && !value.startsWith('-'))
168
+ .map((value) => resolve(cwd, value))
159
169
  for (const use of access.uses) {
160
170
  for (const spelled of expandBraces(use.path)) {
161
171
  const settled = settle(spelled, home)
162
172
  const unresolved = use.unresolved || 'unresolved' in settled
163
173
  const base = 'path' in settled ? settled.path : spelled
164
- const absolute = isAbsolute(base) ? resolve(base) : resolve(cwd, base)
165
- if (use.access === 'write' && (await isProtectedWrite(spelled, absolute, context.roots))) {
166
- protectedPaths.add(absolute)
174
+ // Where a relative path may start from. Usually the directory the
175
+ // line began in; after a literal `cd <relative>` also the directory
176
+ // that moved to. Both are checked, because the `cd` may sit in a
177
+ // subshell or a pipeline and not apply to this command at all: a
178
+ // path is only called inside when it is inside under every reading.
179
+ const starts = [cwd]
180
+ const moved =
181
+ use.directory !== undefined && 'relative' in use.directory
182
+ ? resolve(cwd, use.directory.relative)
183
+ : undefined
184
+ if (moved !== undefined) starts.push(moved)
185
+ const absolutes = isAbsolute(base)
186
+ ? [resolve(base)]
187
+ : starts.map((from) => resolve(from, base))
188
+ if (use.access === 'write') {
189
+ for (const candidate of absolutes) {
190
+ if (await isProtectedWrite(spelled, candidate, context.roots))
191
+ protectedPaths.add(candidate)
192
+ }
167
193
  }
168
194
  if (!context.reviewOutside) continue
169
195
  if (unresolved) {
@@ -173,18 +199,27 @@ export async function shellEscalations(
173
199
  continue
174
200
  }
175
201
  if (
176
- access.changesDirectory &&
202
+ use.directory !== undefined &&
203
+ 'unknown' in use.directory &&
177
204
  use.access === 'write' &&
178
205
  !isAbsolute(base) &&
179
206
  !spelled.startsWith('~')
180
207
  ) {
181
- // A `cd` earlier in the line moves a relative target somewhere the
182
- // roots cannot be compared with.
208
+ // A `cd` earlier in the line, to somewhere the reader cannot name,
209
+ // moves a relative target out of reach of a comparison with the roots.
183
210
  outside.add(spelled)
184
211
  continue
185
212
  }
186
- const reached = await pathOutsideRoots(context.roots, beforeGlob(absolute))
187
- if (reached !== undefined) outside.add(reached)
213
+ for (const candidate of absolutes) {
214
+ if (
215
+ madeHere.some((link) => candidate === link || candidate.startsWith(`${link}${sep}`))
216
+ ) {
217
+ outside.add(candidate)
218
+ continue
219
+ }
220
+ const reached = await pathOutsideRoots(context.roots, beforeGlob(candidate))
221
+ if (reached !== undefined) outside.add(reached)
222
+ }
188
223
  }
189
224
  }
190
225
  }
@@ -535,6 +535,17 @@ export async function applyPendingResume(
535
535
  reason: "the turn's review approved this call, answered across a restart",
536
536
  })
537
537
  }
538
+ if (call.escalation?.unfinishedCheck !== undefined) {
539
+ await recorder.recordAudit({
540
+ what: {
541
+ action: 'unfinished_check',
542
+ tool: call.name,
543
+ resource: call.escalation.unfinishedCheck,
544
+ },
545
+ outcome: 'approved',
546
+ reason: "the turn's review approved this call, answered across a restart",
547
+ })
548
+ }
538
549
  }
539
550
 
540
551
  // The parked assistant message is already in the log: it goes back into
@@ -589,6 +600,9 @@ export function escalationRefusal(
589
600
  ) {
590
601
  return refusal
591
602
  }
603
+ if (escalation.unfinishedCheck !== undefined && shown?.unfinishedCheck === undefined) {
604
+ return refusal
605
+ }
592
606
  if (escalation.sandboxEscape && (!shown?.sandboxEscape || !confirmed?.includes(id))) {
593
607
  return 'Refused after resume: running this command outside the sandbox needs a person to confirm it for this call, and the durable approval did not.'
594
608
  }