@namzu/sdk 52.4.0 → 52.5.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/CHANGELOG.md +27 -0
- package/dist/authorization/gate.d.ts +11 -0
- package/dist/authorization/gate.d.ts.map +1 -1
- package/dist/authorization/gate.js +35 -0
- package/dist/authorization/gate.js.map +1 -1
- package/dist/authorization/removal.js +11 -6
- package/dist/authorization/removal.js.map +1 -1
- package/dist/authorization/rules.d.ts.map +1 -1
- package/dist/authorization/rules.js +30 -2
- package/dist/authorization/rules.js.map +1 -1
- package/dist/authorization/shell-paths.d.ts +20 -0
- package/dist/authorization/shell-paths.d.ts.map +1 -1
- package/dist/authorization/shell-paths.js +254 -52
- package/dist/authorization/shell-paths.js.map +1 -1
- package/dist/bridge/acp/server.d.ts +8 -0
- package/dist/bridge/acp/server.d.ts.map +1 -1
- package/dist/bridge/acp/server.js +7 -0
- package/dist/bridge/acp/server.js.map +1 -1
- package/dist/manager/resident/outbox.d.ts +4 -4
- package/dist/public-runtime.d.ts +2 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +4 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +1 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/auto-review/classifier.d.ts +130 -0
- package/dist/runtime/query/auto-review/classifier.d.ts.map +1 -0
- package/dist/runtime/query/auto-review/classifier.js +244 -0
- package/dist/runtime/query/auto-review/classifier.js.map +1 -0
- package/dist/runtime/query/auto-review/index.d.ts +7 -0
- package/dist/runtime/query/auto-review/index.d.ts.map +1 -0
- package/dist/runtime/query/auto-review/index.js +4 -0
- package/dist/runtime/query/auto-review/index.js.map +1 -0
- package/dist/runtime/query/auto-review/prompt.d.ts +20 -0
- package/dist/runtime/query/auto-review/prompt.d.ts.map +1 -0
- package/dist/runtime/query/auto-review/prompt.js +89 -0
- package/dist/runtime/query/auto-review/prompt.js.map +1 -0
- package/dist/runtime/query/auto-review/rules.d.ts +49 -0
- package/dist/runtime/query/auto-review/rules.d.ts.map +1 -0
- package/dist/runtime/query/auto-review/rules.js +81 -0
- package/dist/runtime/query/auto-review/rules.js.map +1 -0
- package/dist/runtime/query/auto-review/transcript.d.ts +59 -0
- package/dist/runtime/query/auto-review/transcript.d.ts.map +1 -0
- package/dist/runtime/query/auto-review/transcript.js +116 -0
- package/dist/runtime/query/auto-review/transcript.js.map +1 -0
- package/dist/runtime/query/auto-review/verdict.d.ts +21 -0
- package/dist/runtime/query/auto-review/verdict.d.ts.map +1 -0
- package/dist/runtime/query/auto-review/verdict.js +45 -0
- package/dist/runtime/query/auto-review/verdict.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +7 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +26 -2
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.js +2 -2
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +0 -9
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js +11 -2
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +39 -0
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/path-escalation.d.ts.map +1 -1
- package/dist/runtime/query/path-escalation.js +43 -10
- package/dist/runtime/query/path-escalation.js.map +1 -1
- package/dist/runtime/query/resume-pending.d.ts.map +1 -1
- package/dist/runtime/query/resume-pending.js +14 -0
- package/dist/runtime/query/resume-pending.js.map +1 -1
- package/dist/runtime/query/review-policy.d.ts +88 -1
- package/dist/runtime/query/review-policy.d.ts.map +1 -1
- package/dist/runtime/query/review-policy.js +504 -11
- package/dist/runtime/query/review-policy.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +2 -0
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/classifier-input.d.ts +9 -0
- package/dist/tools/builtins/classifier-input.d.ts.map +1 -0
- package/dist/tools/builtins/classifier-input.js +20 -0
- package/dist/tools/builtins/classifier-input.js.map +1 -0
- package/dist/tools/builtins/code-analysis.d.ts +2 -0
- package/dist/tools/builtins/code-analysis.d.ts.map +1 -1
- package/dist/tools/builtins/code-analysis.js +196 -119
- package/dist/tools/builtins/code-analysis.js.map +1 -1
- package/dist/tools/builtins/computer-use.d.ts +10 -10
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +12 -0
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/monitor.d.ts.map +1 -1
- package/dist/tools/builtins/monitor.js +3 -0
- package/dist/tools/builtins/monitor.js.map +1 -1
- package/dist/tools/builtins/web.d.ts.map +1 -1
- package/dist/tools/builtins/web.js +3 -0
- package/dist/tools/builtins/web.js.map +1 -1
- package/dist/tools/builtins/write-file.d.ts.map +1 -1
- package/dist/tools/builtins/write-file.js +6 -0
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/defineTool.d.ts +2 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +1 -0
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/types/acp/index.d.ts +7 -0
- package/dist/types/acp/index.d.ts.map +1 -1
- package/dist/types/code-analysis/index.d.ts +54 -3
- package/dist/types/code-analysis/index.d.ts.map +1 -1
- package/dist/types/hitl/index.d.ts +45 -1
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +16 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/authorization/gate.ts +35 -0
- package/src/authorization/removal.ts +12 -6
- package/src/authorization/rules.ts +27 -2
- package/src/authorization/shell-paths.ts +264 -51
- package/src/bridge/acp/server.ts +20 -0
- package/src/public-runtime.ts +18 -0
- package/src/public-types.ts +11 -0
- package/src/runtime/query/auto-review/classifier.ts +404 -0
- package/src/runtime/query/auto-review/index.ts +23 -0
- package/src/runtime/query/auto-review/prompt.ts +97 -0
- package/src/runtime/query/auto-review/rules.ts +106 -0
- package/src/runtime/query/auto-review/transcript.ts +159 -0
- package/src/runtime/query/auto-review/verdict.ts +50 -0
- package/src/runtime/query/executor.ts +29 -2
- package/src/runtime/query/index.ts +2 -2
- package/src/runtime/query/iteration/phases/context.ts +17 -3
- package/src/runtime/query/iteration/phases/tool-review.ts +41 -0
- package/src/runtime/query/path-escalation.ts +44 -9
- package/src/runtime/query/resume-pending.ts +14 -0
- package/src/runtime/query/review-policy.ts +591 -14
- package/src/tools/builtins/bash.ts +3 -0
- package/src/tools/builtins/classifier-input.ts +19 -0
- package/src/tools/builtins/code-analysis.ts +211 -119
- package/src/tools/builtins/edit.ts +13 -0
- package/src/tools/builtins/monitor.ts +4 -0
- package/src/tools/builtins/web.ts +3 -0
- package/src/tools/builtins/write-file.ts +6 -0
- package/src/tools/defineTool.ts +3 -0
- package/src/types/acp/index.ts +7 -0
- package/src/types/code-analysis/index.ts +51 -3
- package/src/types/hitl/index.ts +49 -1
- 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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
//
|
|
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
|
|
187
|
-
|
|
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
|
}
|