@namzu/sdk 57.1.0 → 58.1.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 +72 -0
- package/dist/agents/QueryAgent.d.ts.map +1 -1
- package/dist/agents/QueryAgent.js +2 -0
- package/dist/agents/QueryAgent.js.map +1 -1
- package/dist/agents/file-definitions.d.ts +35 -4
- package/dist/agents/file-definitions.d.ts.map +1 -1
- package/dist/agents/file-definitions.js +98 -8
- package/dist/agents/file-definitions.js.map +1 -1
- package/dist/authorization/protected-paths.d.ts +9 -0
- package/dist/authorization/protected-paths.d.ts.map +1 -1
- package/dist/authorization/protected-paths.js +29 -3
- package/dist/authorization/protected-paths.js.map +1 -1
- package/dist/compaction/context-window.d.ts +1 -1
- package/dist/compaction/context-window.d.ts.map +1 -1
- package/dist/compaction/context-window.js +8 -1
- package/dist/compaction/context-window.js.map +1 -1
- package/dist/compaction/plan.d.ts.map +1 -1
- package/dist/compaction/plan.js +2 -2
- package/dist/compaction/plan.js.map +1 -1
- package/dist/config/runtime.d.ts +10 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +2 -0
- package/dist/config/runtime.js.map +1 -1
- package/dist/connector/mcp/client.d.ts +8 -0
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +75 -2
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/constants/mcp/index.d.ts +2 -0
- package/dist/constants/mcp/index.d.ts.map +1 -1
- package/dist/constants/mcp/index.js +2 -0
- package/dist/constants/mcp/index.js.map +1 -1
- package/dist/constants/plugin/index.d.ts +2 -0
- package/dist/constants/plugin/index.d.ts.map +1 -1
- package/dist/constants/plugin/index.js +2 -0
- package/dist/constants/plugin/index.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +25 -4
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +2 -1
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/persona/assembler.d.ts.map +1 -1
- package/dist/persona/assembler.js +2 -2
- package/dist/persona/assembler.js.map +1 -1
- package/dist/plugin/index.d.ts +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/lifecycle.d.ts +8 -1
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +9 -2
- package/dist/plugin/lifecycle.js.map +1 -1
- package/dist/plugin/shell-hook-http.d.ts +39 -0
- package/dist/plugin/shell-hook-http.d.ts.map +1 -0
- package/dist/plugin/shell-hook-http.js +163 -0
- package/dist/plugin/shell-hook-http.js.map +1 -0
- package/dist/plugin/shell-hook.d.ts +93 -11
- package/dist/plugin/shell-hook.d.ts.map +1 -1
- package/dist/plugin/shell-hook.js +223 -39
- package/dist/plugin/shell-hook.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +2 -2
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +1 -0
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +2 -0
- package/dist/public-tools.js.map +1 -1
- package/dist/public-types.d.ts +1 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/executor.d.ts +1 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +34 -9
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +2 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +25 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +47 -10
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +18 -8
- package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction-breaker.js +33 -9
- package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +7 -5
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- 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 +70 -3
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/path-escalation.d.ts +2 -0
- package/dist/runtime/query/path-escalation.d.ts.map +1 -1
- package/dist/runtime/query/path-escalation.js +7 -4
- package/dist/runtime/query/path-escalation.js.map +1 -1
- package/dist/runtime/query/plugin-hooks.js +2 -2
- package/dist/runtime/query/plugin-hooks.js.map +1 -1
- package/dist/runtime/query/prepare-turn.d.ts +2 -0
- package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
- package/dist/runtime/query/prepare-turn.js +25 -0
- package/dist/runtime/query/prepare-turn.js.map +1 -1
- package/dist/runtime/query/review-policy.d.ts +3 -2
- package/dist/runtime/query/review-policy.d.ts.map +1 -1
- package/dist/runtime/query/review-policy.js +32 -13
- package/dist/runtime/query/review-policy.js.map +1 -1
- package/dist/session/retention/archive.js +1 -1
- package/dist/session/retention/archive.js.map +1 -1
- package/dist/session/workspace/driver.d.ts +6 -0
- package/dist/session/workspace/driver.d.ts.map +1 -1
- package/dist/session/workspace/git-worktree.d.ts.map +1 -1
- package/dist/session/workspace/git-worktree.js +28 -0
- package/dist/session/workspace/git-worktree.js.map +1 -1
- package/dist/skills/index.d.ts +1 -1
- package/dist/skills/index.d.ts.map +1 -1
- package/dist/skills/index.js +1 -1
- package/dist/skills/index.js.map +1 -1
- package/dist/skills/loader.d.ts +7 -0
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +53 -0
- package/dist/skills/loader.js.map +1 -1
- package/dist/skills/registry.d.ts +1 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +2 -1
- package/dist/skills/registry.js.map +1 -1
- package/dist/tools/builtins/bash-cwd.d.ts +52 -0
- package/dist/tools/builtins/bash-cwd.d.ts.map +1 -0
- package/dist/tools/builtins/bash-cwd.js +127 -0
- package/dist/tools/builtins/bash-cwd.js.map +1 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +41 -6
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/bounded-file-read.d.ts +10 -0
- package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -0
- package/dist/tools/builtins/bounded-file-read.js +67 -0
- package/dist/tools/builtins/bounded-file-read.js.map +1 -0
- package/dist/tools/builtins/grep.d.ts +6 -1
- package/dist/tools/builtins/grep.d.ts.map +1 -1
- package/dist/tools/builtins/grep.js +187 -24
- package/dist/tools/builtins/grep.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts +1 -0
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +113 -28
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/read-media.d.ts +68 -0
- package/dist/tools/builtins/read-media.d.ts.map +1 -0
- package/dist/tools/builtins/read-media.js +397 -0
- package/dist/tools/builtins/read-media.js.map +1 -0
- package/dist/tools/builtins/search-tools.js +1 -1
- package/dist/tools/builtins/search-tools.js.map +1 -1
- package/dist/tools/builtins/skill.d.ts +4 -2
- package/dist/tools/builtins/skill.d.ts.map +1 -1
- package/dist/tools/builtins/skill.js +61 -2
- package/dist/tools/builtins/skill.js.map +1 -1
- package/dist/tools/builtins/sleep-guard.d.ts +15 -0
- package/dist/tools/builtins/sleep-guard.d.ts.map +1 -0
- package/dist/tools/builtins/sleep-guard.js +33 -0
- package/dist/tools/builtins/sleep-guard.js.map +1 -0
- package/dist/tools/builtins/view-image.d.ts +23 -1
- package/dist/tools/builtins/view-image.d.ts.map +1 -1
- package/dist/tools/builtins/view-image.js +66 -58
- package/dist/tools/builtins/view-image.js.map +1 -1
- package/dist/tools/file-read-tracker.d.ts.map +1 -1
- package/dist/tools/file-read-tracker.js +31 -0
- package/dist/tools/file-read-tracker.js.map +1 -1
- package/dist/toolsets/manager.js +3 -3
- package/dist/toolsets/manager.js.map +1 -1
- package/dist/types/agent/query.d.ts +2 -0
- package/dist/types/agent/query.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +6 -2
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/agent/task.js.map +1 -1
- package/dist/types/connector/mcp.d.ts +46 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/hitl/index.d.ts +1 -1
- package/dist/types/plugin/index.d.ts +72 -5
- package/dist/types/plugin/index.d.ts.map +1 -1
- package/dist/types/plugin/index.js +12 -1
- package/dist/types/plugin/index.js.map +1 -1
- package/dist/types/session/sub-session.d.ts +6 -2
- package/dist/types/session/sub-session.d.ts.map +1 -1
- package/dist/types/skills/index.d.ts +36 -0
- package/dist/types/skills/index.d.ts.map +1 -1
- package/dist/types/skills/index.js +9 -0
- package/dist/types/skills/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +22 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/types/workspace/ref.d.ts +2 -0
- package/dist/types/workspace/ref.d.ts.map +1 -1
- package/dist/utils/frontmatter.d.ts +12 -0
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +6 -0
- package/dist/utils/frontmatter.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/QueryAgent.ts +2 -0
- package/src/agents/file-definitions.ts +126 -6
- package/src/authorization/protected-paths.ts +30 -2
- package/src/compaction/context-window.ts +10 -0
- package/src/compaction/plan.ts +12 -2
- package/src/config/runtime.ts +2 -0
- package/src/connector/mcp/client.ts +87 -1
- package/src/constants/mcp/index.ts +3 -0
- package/src/constants/plugin/index.ts +2 -0
- package/src/manager/agent/lifecycle.ts +24 -5
- package/src/manager/session/turn-recorder.ts +2 -1
- package/src/persona/assembler.ts +2 -2
- package/src/plugin/index.ts +5 -0
- package/src/plugin/lifecycle.ts +16 -2
- package/src/plugin/shell-hook-http.ts +181 -0
- package/src/plugin/shell-hook.ts +386 -89
- package/src/public-runtime.ts +2 -0
- package/src/public-tools.ts +2 -0
- package/src/public-types.ts +5 -0
- package/src/runtime/query/executor.ts +40 -10
- package/src/runtime/query/index.ts +33 -0
- package/src/runtime/query/iteration/index.ts +51 -11
- package/src/runtime/query/iteration/phases/compaction-breaker.ts +38 -9
- package/src/runtime/query/iteration/phases/compaction.ts +27 -5
- package/src/runtime/query/iteration/phases/context.ts +2 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +74 -3
- package/src/runtime/query/path-escalation.ts +11 -5
- package/src/runtime/query/plugin-hooks.ts +2 -2
- package/src/runtime/query/prepare-turn.ts +33 -0
- package/src/runtime/query/review-policy.ts +45 -16
- package/src/session/retention/archive.ts +1 -1
- package/src/session/workspace/driver.ts +6 -0
- package/src/session/workspace/git-worktree.ts +31 -0
- package/src/skills/index.ts +6 -1
- package/src/skills/loader.ts +54 -0
- package/src/skills/registry.ts +7 -6
- package/src/tools/builtins/bash-cwd.ts +156 -0
- package/src/tools/builtins/bash.ts +79 -34
- package/src/tools/builtins/bounded-file-read.ts +80 -0
- package/src/tools/builtins/grep.ts +198 -28
- package/src/tools/builtins/read-file.ts +151 -30
- package/src/tools/builtins/read-media.ts +483 -0
- package/src/tools/builtins/search-tools.ts +1 -1
- package/src/tools/builtins/skill.ts +91 -2
- package/src/tools/builtins/sleep-guard.ts +31 -0
- package/src/tools/builtins/view-image.ts +74 -62
- package/src/tools/file-read-tracker.ts +30 -0
- package/src/toolsets/manager.ts +3 -3
- package/src/types/agent/query.ts +2 -0
- package/src/types/agent/task.ts +6 -2
- package/src/types/connector/mcp.ts +48 -0
- package/src/types/hitl/index.ts +1 -1
- package/src/types/plugin/index.ts +102 -16
- package/src/types/session/sub-session.ts +6 -2
- package/src/types/skills/index.ts +48 -0
- package/src/types/tool/index.ts +22 -0
- package/src/types/workspace/ref.ts +2 -0
- package/src/utils/frontmatter.ts +21 -0
|
@@ -34,6 +34,8 @@ export interface PathEscalations {
|
|
|
34
34
|
export interface PathEscalationContext {
|
|
35
35
|
/** The working directory first, then the added directories. */
|
|
36
36
|
readonly roots: readonly string[]
|
|
37
|
+
/** Actual shell start directory; roots remain the original authorization boundary. */
|
|
38
|
+
readonly cwd?: string
|
|
37
39
|
/** Whether to report paths outside the roots (`outsideRootAccess: 'review'`, no sandbox). */
|
|
38
40
|
readonly reviewOutside: boolean
|
|
39
41
|
}
|
|
@@ -150,7 +152,7 @@ export async function shellEscalations(
|
|
|
150
152
|
): Promise<PathEscalations> {
|
|
151
153
|
const reading = readShellLine(line, dialect)
|
|
152
154
|
const access = shellPathAccess(reading)
|
|
153
|
-
const
|
|
155
|
+
const cwd = context.cwd ?? context.roots[0]
|
|
154
156
|
const outside = new Set<string>()
|
|
155
157
|
const protectedPaths = new Set<string>()
|
|
156
158
|
const home = homedir()
|
|
@@ -222,14 +224,18 @@ export async function shellEscalations(
|
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
226
|
}
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
+
const removalAt = (directory: string | undefined) =>
|
|
228
|
+
directory === undefined
|
|
227
229
|
? undefined
|
|
228
230
|
: dangerousRemoval(reading, access.changesDirectory, {
|
|
229
|
-
cwd,
|
|
230
|
-
additionalDirectories:
|
|
231
|
+
cwd: directory,
|
|
232
|
+
additionalDirectories: context.roots,
|
|
231
233
|
home,
|
|
232
234
|
})
|
|
235
|
+
// Moving into an added directory must not make removal of the original
|
|
236
|
+
// working directory cease to be catastrophic. Keep both conservative floors.
|
|
237
|
+
const removal =
|
|
238
|
+
removalAt(cwd) ?? (cwd !== context.roots[0] ? removalAt(context.roots[0]) : undefined)
|
|
233
239
|
return {
|
|
234
240
|
outsidePaths: [...outside],
|
|
235
241
|
protectedPaths: [...protectedPaths],
|
|
@@ -19,9 +19,9 @@ export function applyLifecycleHookResults(
|
|
|
19
19
|
case 'continue':
|
|
20
20
|
continue
|
|
21
21
|
case 'annotate':
|
|
22
|
-
if (event !== 'user_prompt_submit') {
|
|
22
|
+
if (event !== 'user_prompt_submit' && event !== 'subagent_start') {
|
|
23
23
|
throw new Error(
|
|
24
|
-
`Plugin hook ${event} returned 'annotate', which only user_prompt_submit
|
|
24
|
+
`Plugin hook ${event} returned 'annotate', which only user_prompt_submit and subagent_start accept`,
|
|
25
25
|
)
|
|
26
26
|
}
|
|
27
27
|
if (result.text.trim().length > 0) annotations.push(result.text.trim())
|
|
@@ -89,6 +89,8 @@ export interface PreparedTurn {
|
|
|
89
89
|
readonly approvalPolicy: SessionApprovalPolicy
|
|
90
90
|
readonly eventTranslator: EventTranslator
|
|
91
91
|
readonly executeUserInterruptHooks: (terminalError: unknown) => Promise<void>
|
|
92
|
+
/** Tell `turn_failure` hooks the root turn failed; never throws, never fires for a cancellation. */
|
|
93
|
+
readonly executeTurnFailureHooks: (terminalError: unknown) => Promise<void>
|
|
92
94
|
/**
|
|
93
95
|
* Repair reports this turn owes its log.
|
|
94
96
|
*
|
|
@@ -823,6 +825,36 @@ export async function prepareTurn(params: QueryParams): Promise<PreparedTurn> {
|
|
|
823
825
|
}
|
|
824
826
|
}
|
|
825
827
|
|
|
828
|
+
const executeTurnFailureHooks = async (terminalError: unknown): Promise<void> => {
|
|
829
|
+
// A cancellation is the operator's own act, a child's failure reaches its
|
|
830
|
+
// parent as a tool result, and the turn is already failed: this is a
|
|
831
|
+
// notice, so nothing it does may change how the turn settles.
|
|
832
|
+
if (
|
|
833
|
+
!params.pluginManager ||
|
|
834
|
+
params.parentSessionId !== undefined ||
|
|
835
|
+
ctx.abortController.signal.aborted ||
|
|
836
|
+
isCallerAbortError(terminalError, ctx.abortController.signal)
|
|
837
|
+
) {
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
await params.pluginManager.executeHooks(
|
|
842
|
+
'turn_failure',
|
|
843
|
+
{
|
|
844
|
+
sessionId: ctx.sessionId,
|
|
845
|
+
turnId: ctx.turnId,
|
|
846
|
+
error: toErrorMessage(terminalError),
|
|
847
|
+
},
|
|
848
|
+
eventTranslator.emitEvent,
|
|
849
|
+
)
|
|
850
|
+
} catch (error) {
|
|
851
|
+
ctx.log.error('Turn failure hooks did not settle cleanly', {
|
|
852
|
+
[NAMZU.TURN_ID]: ctx.turnId,
|
|
853
|
+
...errorAttributes(error),
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
826
858
|
return {
|
|
827
859
|
turnConfig,
|
|
828
860
|
budget,
|
|
@@ -836,6 +868,7 @@ export async function prepareTurn(params: QueryParams): Promise<PreparedTurn> {
|
|
|
836
868
|
approvalPolicy,
|
|
837
869
|
eventTranslator,
|
|
838
870
|
executeUserInterruptHooks,
|
|
871
|
+
executeTurnFailureHooks,
|
|
839
872
|
pendingHistoryRepairs,
|
|
840
873
|
initialMessages,
|
|
841
874
|
historyIds,
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
readShellLine,
|
|
29
29
|
shellPathAccess,
|
|
30
30
|
} from '../../authorization/shell-paths.js'
|
|
31
|
+
import { effectiveBashWorkingDirectory } from '../../tools/builtins/bash-cwd.js'
|
|
32
|
+
import { BashTool } from '../../tools/builtins/bash.js'
|
|
31
33
|
import { isTrustedReadOnly } from '../../tools/trusted-read-only.js'
|
|
32
34
|
import type { ToolManager } from '../../toolsets/manager.js'
|
|
33
35
|
import type {
|
|
@@ -83,10 +85,11 @@ export type ReviewMode =
|
|
|
83
85
|
*
|
|
84
86
|
* Where `auto` approves what remains after the questions a person must
|
|
85
87
|
* answer (a path outside the roots, a program nobody can name, a protected
|
|
86
|
-
* write), `bypass`
|
|
88
|
+
* write), `bypass` approves outside paths and unknown programs but not protected
|
|
89
|
+
* writes. It still asks, or refuses when
|
|
87
90
|
* nobody can be asked: a `deny` rule (the gate refuses before this is
|
|
88
91
|
* 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
|
|
92
|
+
* removal, a protected write, a tool's own `requiresApproval`, a sandbox escape, and a call
|
|
90
93
|
* whose safety checks did not finish. The dangerous-pattern floor refuses in
|
|
91
94
|
* the gate, unchanged. With nobody to ask, whatever would have been asked is
|
|
92
95
|
* refused.
|
|
@@ -571,14 +574,15 @@ function isPermissive(mode: ReviewMode): boolean {
|
|
|
571
574
|
|
|
572
575
|
/**
|
|
573
576
|
* Whether the call is one of the few no mode may settle in advance: a rule or
|
|
574
|
-
* hook asked for a person, a sandbox escape, a
|
|
575
|
-
* that did not finish, or a tool that always needs approval. The dangerous
|
|
577
|
+
* hook asked for a person, a sandbox escape, a protected write, a catastrophic
|
|
578
|
+
* removal, a check that did not finish, or a tool that always needs approval. The dangerous
|
|
576
579
|
* pattern floor and `deny` rules are not here: the gate refuses those first.
|
|
577
580
|
*/
|
|
578
581
|
function isBypassImmune(tc: ToolCallSummary): boolean {
|
|
579
582
|
return (
|
|
580
583
|
tc.authorization?.askRule === true ||
|
|
581
584
|
tc.escalation?.sandboxEscape === true ||
|
|
585
|
+
(tc.escalation?.protectedPaths?.length ?? 0) > 0 ||
|
|
582
586
|
tc.escalation?.dangerousRemoval !== undefined ||
|
|
583
587
|
tc.escalation?.unfinishedCheck !== undefined ||
|
|
584
588
|
tc.requiresApproval === true
|
|
@@ -593,6 +597,9 @@ function immuneRefusal(
|
|
|
593
597
|
if (!escapeConfirmed && toolCalls.some((tc) => tc.escalation?.sandboxEscape === true)) {
|
|
594
598
|
return SANDBOX_ESCAPE_UNATTENDED_REFUSAL
|
|
595
599
|
}
|
|
600
|
+
if (toolCalls.some((tc) => (tc.escalation?.protectedPaths?.length ?? 0) > 0)) {
|
|
601
|
+
return PROTECTED_PATH_UNATTENDED_REFUSAL
|
|
602
|
+
}
|
|
596
603
|
if (toolCalls.some((tc) => tc.escalation?.dangerousRemoval !== undefined)) {
|
|
597
604
|
return DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
|
|
598
605
|
}
|
|
@@ -879,7 +886,10 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
879
886
|
* path, a path in a variable, a program nobody can name or a `sed` script
|
|
880
887
|
* that can run something is not that.
|
|
881
888
|
*/
|
|
882
|
-
const shellFileCommandsInRoots = async (
|
|
889
|
+
const shellFileCommandsInRoots = async (
|
|
890
|
+
tc: ToolCallSummary,
|
|
891
|
+
sessionId?: string,
|
|
892
|
+
): Promise<boolean> => {
|
|
883
893
|
const rootList = rootsNow()
|
|
884
894
|
if (rootList.length === 0) return false
|
|
885
895
|
if (
|
|
@@ -906,23 +916,39 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
906
916
|
if (invocation.program === 'sed' && !sedOnlyEdits(invocation.args)) return false
|
|
907
917
|
}
|
|
908
918
|
if (shellPathAccess(reading).uses.some((use) => use.unresolved)) return false
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
919
|
+
const cwd =
|
|
920
|
+
tool.execute === BashTool.execute && rootList[0] !== undefined
|
|
921
|
+
? effectiveBashWorkingDirectory(sessionId, rootList[0])
|
|
922
|
+
: undefined
|
|
923
|
+
const found = await shellEscalations(line, dialect, {
|
|
924
|
+
roots: rootList,
|
|
925
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
926
|
+
reviewOutside: true,
|
|
927
|
+
})
|
|
928
|
+
// A standalone review request carries no sandbox attribution. Retain the
|
|
929
|
+
// original-root reading too: missing execution metadata can only ask more,
|
|
930
|
+
// never approve a call the old reading would have sent to a person.
|
|
931
|
+
const baseline =
|
|
932
|
+
cwd !== undefined && cwd !== rootList[0]
|
|
933
|
+
? await shellEscalations(line, dialect, { roots: rootList, reviewOutside: true })
|
|
934
|
+
: found
|
|
935
|
+
return [found, baseline].every(
|
|
936
|
+
(reading) =>
|
|
937
|
+
reading.outsidePaths.length === 0 &&
|
|
938
|
+
reading.protectedPaths.length === 0 &&
|
|
939
|
+
reading.dangerousRemoval === undefined,
|
|
914
940
|
)
|
|
915
941
|
}
|
|
916
942
|
|
|
917
943
|
/** What `accept-edits` approves without asking: edits, exempt reads and file commands inside the roots. */
|
|
918
|
-
const acceptEditsAllows = async (tc: ToolCallSummary): Promise<boolean> =>
|
|
944
|
+
const acceptEditsAllows = async (tc: ToolCallSummary, sessionId?: string): Promise<boolean> =>
|
|
919
945
|
!tc.authorization?.explicitReview &&
|
|
920
946
|
tc.escalation === undefined &&
|
|
921
947
|
!tc.isDestructive &&
|
|
922
948
|
tc.requiresApproval !== true &&
|
|
923
949
|
(ACCEPT_EDITS_TOOLS.has(tc.name) ||
|
|
924
950
|
exempt(tc.name, tc.input) ||
|
|
925
|
-
(await shellFileCommandsInRoots(tc)))
|
|
951
|
+
(await shellFileCommandsInRoots(tc, sessionId)))
|
|
926
952
|
|
|
927
953
|
/**
|
|
928
954
|
* Whether a rule's `allow` is one the classifier should not defer to: the
|
|
@@ -944,7 +970,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
944
970
|
}
|
|
945
971
|
|
|
946
972
|
/** Whether `classify` decides this call without the classifier: it would not be asked, or is plainly safe. */
|
|
947
|
-
const neverSent = async (tc: ToolCallSummary): Promise<boolean> => {
|
|
973
|
+
const neverSent = async (tc: ToolCallSummary, sessionId?: string): Promise<boolean> => {
|
|
948
974
|
if (tc.authorization?.decision === 'allow' && !allowRuleTooWide(tc)) return true
|
|
949
975
|
// A name on the safe list is safe for what it does in the session; a read of
|
|
950
976
|
// /etc/shadow is still a read of /etc/shadow, and a person's own ask is
|
|
@@ -965,7 +991,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
965
991
|
) {
|
|
966
992
|
return true
|
|
967
993
|
}
|
|
968
|
-
return acceptEditsAllows(tc)
|
|
994
|
+
return acceptEditsAllows(tc, sessionId)
|
|
969
995
|
}
|
|
970
996
|
|
|
971
997
|
return async (request, context?: ResumeContext): Promise<HITLResumeDecision> => {
|
|
@@ -1043,7 +1069,10 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
1043
1069
|
request.toolCalls.map((tc) =>
|
|
1044
1070
|
// The mode covers a write although the flag is set; the classifier
|
|
1045
1071
|
// still judges one, so only this branch lifts the flag.
|
|
1046
|
-
acceptEditsAllows(
|
|
1072
|
+
acceptEditsAllows(
|
|
1073
|
+
isFileWrite(tc.name) ? { ...tc, isDestructive: false } : tc,
|
|
1074
|
+
request.sessionId,
|
|
1075
|
+
),
|
|
1047
1076
|
),
|
|
1048
1077
|
)
|
|
1049
1078
|
).every(Boolean)
|
|
@@ -1093,7 +1122,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
1093
1122
|
const transcript = context?.transcript() ?? []
|
|
1094
1123
|
for (const tc of live) {
|
|
1095
1124
|
// The questions a person must answer are asked of a person, never of a model.
|
|
1096
|
-
if (isBypassImmune(tc) || (await neverSent(tc))) continue
|
|
1125
|
+
if (isBypassImmune(tc) || (await neverSent(tc, request.sessionId))) continue
|
|
1097
1126
|
const decision = await classifier.review({
|
|
1098
1127
|
call: tc,
|
|
1099
1128
|
transcript,
|
|
@@ -223,7 +223,7 @@ export class ArchivalManager {
|
|
|
223
223
|
|
|
224
224
|
// 6. Dispose the workspace (idempotent — driver contract tolerates
|
|
225
225
|
// already-disposed refs; a missing ref is a no-op).
|
|
226
|
-
if (workspace && sub.workspaceRetention
|
|
226
|
+
if (workspace && sub.workspaceRetention === undefined) {
|
|
227
227
|
try {
|
|
228
228
|
const driver = this.deps.workspaceRegistry.get(workspace.meta.backend)
|
|
229
229
|
await driver.dispose(workspace)
|
|
@@ -47,6 +47,12 @@ export interface WorkspaceInspection {
|
|
|
47
47
|
exists: boolean
|
|
48
48
|
currentRef: string
|
|
49
49
|
isDirty: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Whether the workspace moved off the commit it started on. Absent when the
|
|
52
|
+
* backend cannot tell; a caller deciding whether to keep it treats absent
|
|
53
|
+
* as `true`.
|
|
54
|
+
*/
|
|
55
|
+
hasNewCommits?: boolean
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/**
|
|
@@ -147,11 +147,19 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
|
|
|
147
147
|
})
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// The commit the checkout starts on, so a later look can tell a child that
|
|
151
|
+
// committed from one that did not. Left out when it cannot be read: a ref
|
|
152
|
+
// without it is always treated as changed.
|
|
153
|
+
const baseCommit = await this.exec('git', ['-C', worktreePath, 'rev-parse', 'HEAD'])
|
|
154
|
+
.then((r) => r.stdout.trim() || undefined)
|
|
155
|
+
.catch(() => undefined)
|
|
156
|
+
|
|
150
157
|
const meta: GitWorktreeBackendMeta = {
|
|
151
158
|
backend: 'git-worktree',
|
|
152
159
|
repoRoot,
|
|
153
160
|
branch,
|
|
154
161
|
worktreePath,
|
|
162
|
+
...(baseCommit ? { baseCommit } : {}),
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
this.log.info('git-worktree created', {
|
|
@@ -202,6 +210,25 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
|
|
|
202
210
|
[NAMZU.SESSION_ID]: ref.id,
|
|
203
211
|
'namzu.session.path': owned.worktreePath,
|
|
204
212
|
})
|
|
213
|
+
// A branch still on its starting commit holds nothing, so it goes with
|
|
214
|
+
// its checkout. Compare and delete under Git's ref lock: a branch that
|
|
215
|
+
// advanced after the worktree listing must keep its new commits.
|
|
216
|
+
if (owned.baseCommit !== undefined && registered.head === owned.baseCommit) {
|
|
217
|
+
await this.exec('git', [
|
|
218
|
+
'-C',
|
|
219
|
+
owned.repoRoot,
|
|
220
|
+
'update-ref',
|
|
221
|
+
'--no-deref',
|
|
222
|
+
'-d',
|
|
223
|
+
`refs/heads/${owned.branch}`,
|
|
224
|
+
owned.baseCommit,
|
|
225
|
+
]).catch((cause) =>
|
|
226
|
+
this.log.warn('git-worktree branch left behind', {
|
|
227
|
+
'namzu.session.branch': owned.branch,
|
|
228
|
+
'namzu.session.cause': cause instanceof Error ? cause.message : String(cause),
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
}
|
|
205
232
|
} catch (cause) {
|
|
206
233
|
// Two concurrent disposals can both observe the entry before one wins.
|
|
207
234
|
// Re-read after failure and accept only repository evidence that the
|
|
@@ -282,10 +309,13 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
|
|
|
282
309
|
throw new WorkspaceBackendError({ op: 'inspect:status', kind: this.kind, cause })
|
|
283
310
|
}
|
|
284
311
|
|
|
312
|
+
const baseCommit = owned.baseCommit
|
|
285
313
|
return {
|
|
286
314
|
exists: true,
|
|
287
315
|
currentRef: entry.branch ?? entry.head ?? ref.meta.branch,
|
|
288
316
|
isDirty: statusStdout.trim().length > 0,
|
|
317
|
+
// Unknown (no recorded start, or HEAD unreadable) reads as "has commits".
|
|
318
|
+
hasNewCommits: baseCommit === undefined || entry.head !== baseCommit,
|
|
289
319
|
}
|
|
290
320
|
}
|
|
291
321
|
|
|
@@ -329,6 +359,7 @@ export class GitWorktreeDriver implements WorkspaceBackendDriver {
|
|
|
329
359
|
repoRoot,
|
|
330
360
|
branch,
|
|
331
361
|
worktreePath,
|
|
362
|
+
...(ref.meta.baseCommit ? { baseCommit: ref.meta.baseCommit } : {}),
|
|
332
363
|
}
|
|
333
364
|
} catch (cause) {
|
|
334
365
|
throw new WorkspaceBackendError({ op: `${op}:ownership`, kind: this.kind, cause })
|
package/src/skills/index.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
SKILL_FRONTMATTER_KEYS,
|
|
3
|
+
SKILL_FRONTMATTER_TOLERATED_KEYS,
|
|
4
|
+
loadSkill,
|
|
5
|
+
discoverSkills,
|
|
6
|
+
} from './loader.js'
|
|
2
7
|
export { SkillCollisionError, SkillRegistry, resolveSkillChain } from './registry.js'
|
package/src/skills/loader.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
+
import type { ReasoningEffort } from '../types/provider/chat.js'
|
|
3
4
|
import type {
|
|
4
5
|
Skill,
|
|
5
6
|
SkillDisclosureLevel,
|
|
@@ -31,8 +32,28 @@ export const SKILL_FRONTMATTER_KEYS: readonly string[] = Object.freeze([
|
|
|
31
32
|
'invocation',
|
|
32
33
|
'disable-model-invocation',
|
|
33
34
|
'metadata',
|
|
35
|
+
'when_to_use',
|
|
36
|
+
'context',
|
|
37
|
+
'agent',
|
|
38
|
+
'model',
|
|
39
|
+
'effort',
|
|
40
|
+
'background',
|
|
34
41
|
])
|
|
35
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The keys added after other tools' skills already used them. A value in one
|
|
45
|
+
* that this reader cannot take (`when_to_use: >-`, `model: [a, b]`) leaves the
|
|
46
|
+
* key absent instead of refusing the skill.
|
|
47
|
+
*/
|
|
48
|
+
export const SKILL_FRONTMATTER_TOLERATED_KEYS: readonly string[] = [
|
|
49
|
+
'when_to_use',
|
|
50
|
+
'context',
|
|
51
|
+
'agent',
|
|
52
|
+
'model',
|
|
53
|
+
'effort',
|
|
54
|
+
'background',
|
|
55
|
+
]
|
|
56
|
+
|
|
36
57
|
const READS_SKILL_KEY = (key: string): boolean => SKILL_FRONTMATTER_KEYS.includes(key)
|
|
37
58
|
|
|
38
59
|
/**
|
|
@@ -44,6 +65,7 @@ const READS_SKILL_KEY = (key: string): boolean => SKILL_FRONTMATTER_KEYS.include
|
|
|
44
65
|
export const SKILL_FRONTMATTER_OPTIONS = {
|
|
45
66
|
lists: ['allowed-tools'],
|
|
46
67
|
readsKey: READS_SKILL_KEY,
|
|
68
|
+
tolerates: (key: string): boolean => SKILL_FRONTMATTER_TOLERATED_KEYS.includes(key),
|
|
47
69
|
} as const
|
|
48
70
|
|
|
49
71
|
/**
|
|
@@ -161,6 +183,27 @@ function toSkillMetadata(parsed: ParsedFrontmatter, dirPath: string): SkillMetad
|
|
|
161
183
|
}
|
|
162
184
|
}
|
|
163
185
|
|
|
186
|
+
// The fields that shape how the skill RUNS are read leniently, the other
|
|
187
|
+
// way round from the access fields above: a value this loader does not
|
|
188
|
+
// understand (`effort: 5000`, `context: sideways`) leaves the skill at its
|
|
189
|
+
// plain behaviour rather than refusing a skill written for another agent.
|
|
190
|
+
const whenToUse = scalarAt(values, 'when_to_use')?.trim()
|
|
191
|
+
if (whenToUse) skillMetadata.whenToUse = whenToUse
|
|
192
|
+
|
|
193
|
+
if (scalarAt(values, 'context') === 'fork') skillMetadata.context = 'fork'
|
|
194
|
+
const agent = scalarAt(values, 'agent')?.trim()
|
|
195
|
+
if (agent) skillMetadata.agent = agent
|
|
196
|
+
const model = scalarAt(values, 'model')?.trim()
|
|
197
|
+
if (model && model.toLowerCase() !== 'inherit') skillMetadata.model = model
|
|
198
|
+
const effort = scalarAt(values, 'effort')?.trim().toLowerCase()
|
|
199
|
+
if (effort && (REASONING_EFFORTS as readonly string[]).includes(effort)) {
|
|
200
|
+
skillMetadata.effort = effort as ReasoningEffort
|
|
201
|
+
}
|
|
202
|
+
const background = scalarAt(values, 'background')
|
|
203
|
+
if (background === 'true' || background === 'false') {
|
|
204
|
+
skillMetadata.background = background === 'true'
|
|
205
|
+
}
|
|
206
|
+
|
|
164
207
|
const extra = mappingAt(values, 'metadata')
|
|
165
208
|
if (extra && Object.keys(extra).length > 0) {
|
|
166
209
|
skillMetadata.metadata = { ...extra }
|
|
@@ -169,6 +212,17 @@ function toSkillMetadata(parsed: ParsedFrontmatter, dirPath: string): SkillMetad
|
|
|
169
212
|
return skillMetadata
|
|
170
213
|
}
|
|
171
214
|
|
|
215
|
+
const REASONING_EFFORTS: readonly ReasoningEffort[] = [
|
|
216
|
+
'none',
|
|
217
|
+
'minimal',
|
|
218
|
+
'low',
|
|
219
|
+
'medium',
|
|
220
|
+
'high',
|
|
221
|
+
'xhigh',
|
|
222
|
+
'max',
|
|
223
|
+
'ultra',
|
|
224
|
+
]
|
|
225
|
+
|
|
172
226
|
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
173
227
|
|
|
174
228
|
function validateSkillName(name: string, dirPath: string): void {
|
package/src/skills/registry.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { stat } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { RegistryCollisionError } from '../registry/collision.js'
|
|
4
|
-
import
|
|
5
|
-
Skill,
|
|
6
|
-
SkillChain,
|
|
7
|
-
SkillDisclosureLevel,
|
|
8
|
-
SkillLoadResult,
|
|
4
|
+
import {
|
|
5
|
+
type Skill,
|
|
6
|
+
type SkillChain,
|
|
7
|
+
type SkillDisclosureLevel,
|
|
8
|
+
type SkillLoadResult,
|
|
9
|
+
skillModelDescription,
|
|
9
10
|
} from '../types/skills/index.js'
|
|
10
11
|
import { SCOPE_ATTRIBUTE } from '../utils/log/types.js'
|
|
11
12
|
import { type Logger, resolveLogger } from '../utils/logger.js'
|
|
@@ -273,7 +274,7 @@ export class SkillRegistry {
|
|
|
273
274
|
const skill = loaded.skill
|
|
274
275
|
entries.push({
|
|
275
276
|
registeredName,
|
|
276
|
-
description: skill.metadata
|
|
277
|
+
description: skillModelDescription(skill.metadata),
|
|
277
278
|
location: join(skill.dirPath, SKILL_FILENAME),
|
|
278
279
|
directory: skill.dirPath,
|
|
279
280
|
...(skill.metadata.allowedTools === undefined
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory a `bash` call starts in, carried from one call to the next.
|
|
3
|
+
*
|
|
4
|
+
* A model that runs `cd packages/app` expects the next call to be there; shell
|
|
5
|
+
* state (variables, functions, aliases) does not carry over, but the directory
|
|
6
|
+
* does. After a foreground command the shell writes its physical directory to a
|
|
7
|
+
* private file, and the next call of the same session starts there.
|
|
8
|
+
*
|
|
9
|
+
* Two limits keep this from widening what a command can reach. The new
|
|
10
|
+
* directory has to lie inside the turn's roots (the working directory, the
|
|
11
|
+
* directories added for the session, any path this call was approved for); a
|
|
12
|
+
* command that leaves them is put back at the session's working directory and
|
|
13
|
+
* the result says so. And a directory that no longer exists is dropped without
|
|
14
|
+
* ceremony. Nothing here applies to a sandboxed command or to Windows shells,
|
|
15
|
+
* whose paths a host process cannot use as they are printed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import { statSync } from 'node:fs'
|
|
20
|
+
import { rm } from 'node:fs/promises'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
import { type CommandShell, hostCommandShell } from '../command-shell.js'
|
|
25
|
+
import { pathOutsideRoots, toolRoots } from '../paths.js'
|
|
26
|
+
import { readBoundedFile } from './bounded-file-read.js'
|
|
27
|
+
|
|
28
|
+
interface Carried {
|
|
29
|
+
/** The working directory the session started with; the fallback. */
|
|
30
|
+
readonly base: string
|
|
31
|
+
cwd: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sessions remembered; the oldest is forgotten first. */
|
|
35
|
+
const MAX_SESSIONS = 256
|
|
36
|
+
const carried = new Map<string, Carried>()
|
|
37
|
+
|
|
38
|
+
/** The notice appended to a result when the command left the allowed directories. */
|
|
39
|
+
export function cwdResetNotice(base: string): string {
|
|
40
|
+
return `Shell cwd was reset to ${base}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Single-quote `text` for a POSIX shell. */
|
|
44
|
+
function shellQuote(text: string): string {
|
|
45
|
+
return `'${text.replace(/'/g, `'\\''`)}'`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runs `command` unchanged and records the directory it ended in. The exit
|
|
50
|
+
* status is the command's own. A command that ends the shell itself (`exit`)
|
|
51
|
+
* records nothing, so the directory stays as it was.
|
|
52
|
+
*/
|
|
53
|
+
export function withCwdCapture(command: string, file: string): string {
|
|
54
|
+
return `eval ${shellQuote(command)}\n__namzu_status=$?\npwd -P >| ${shellQuote(file)} 2>/dev/null\nexit $__namzu_status`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function newCwdFile(): string {
|
|
58
|
+
return join(tmpdir(), `namzu-cwd-${randomUUID()}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where this session's next foreground call starts. Synchronous: the spawn must not wait on it. */
|
|
62
|
+
export function startingDirectory(sessionId: string, base: string): string {
|
|
63
|
+
const entry = carried.get(sessionId)
|
|
64
|
+
if (entry === undefined) return base
|
|
65
|
+
if (entry.base !== base) {
|
|
66
|
+
carried.delete(sessionId)
|
|
67
|
+
return base
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
if (statSync(entry.cwd).isDirectory()) return entry.cwd
|
|
71
|
+
} catch {
|
|
72
|
+
// Removed or unreadable: start again from the base.
|
|
73
|
+
}
|
|
74
|
+
entry.cwd = base
|
|
75
|
+
return base
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The cwd used by bash execution and host authorization. Hosts must resolve it
|
|
80
|
+
* for each call, not cache the session's initial directory. Sandboxed commands
|
|
81
|
+
* (including sandbox escapes) and Windows never use the carried POSIX cwd.
|
|
82
|
+
*/
|
|
83
|
+
export function effectiveBashWorkingDirectory(
|
|
84
|
+
sessionId: string | undefined,
|
|
85
|
+
workingDirectory: string,
|
|
86
|
+
options: {
|
|
87
|
+
sandboxed?: boolean
|
|
88
|
+
shell?: Pick<CommandShell, 'path' | 'windows'>
|
|
89
|
+
} = {},
|
|
90
|
+
): string {
|
|
91
|
+
const shell = options.shell ?? hostCommandShell()
|
|
92
|
+
if (options.sandboxed || sessionId === undefined || shell.windows || shell.path === undefined)
|
|
93
|
+
return workingDirectory
|
|
94
|
+
return startingDirectory(sessionId, workingDirectory)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads what the finished command recorded and keeps it for the next call.
|
|
99
|
+
* Returns the notice to append when the directory was outside the roots.
|
|
100
|
+
*/
|
|
101
|
+
export async function recordDirectory(
|
|
102
|
+
sessionId: string,
|
|
103
|
+
file: string,
|
|
104
|
+
context: Parameters<typeof toolRoots>[0],
|
|
105
|
+
): Promise<string | undefined> {
|
|
106
|
+
let reported: string
|
|
107
|
+
try {
|
|
108
|
+
reported = (await readBoundedFile(file, 64 * 1024)).toString('utf8').trim()
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined
|
|
111
|
+
} finally {
|
|
112
|
+
await rm(file, { force: true })
|
|
113
|
+
}
|
|
114
|
+
if (reported === '') return undefined
|
|
115
|
+
const base = context.workingDirectory
|
|
116
|
+
const outside = await pathOutsideRoots(toolRoots(context), reported)
|
|
117
|
+
const next = outside === undefined ? reported : base
|
|
118
|
+
carried.delete(sessionId)
|
|
119
|
+
if (next !== base) {
|
|
120
|
+
carried.set(sessionId, { base, cwd: next })
|
|
121
|
+
if (carried.size > MAX_SESSIONS) {
|
|
122
|
+
const oldest = carried.keys().next().value
|
|
123
|
+
if (oldest !== undefined) carried.delete(oldest)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return outside === undefined ? undefined : cwdResetNotice(base)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** For tests: forget every session. */
|
|
130
|
+
export function forgetCarriedDirectories(): void {
|
|
131
|
+
carried.clear()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Enumerable symbol crosses the executor's context copies, never the model's
|
|
135
|
+
// JSON input. No public ToolContext field or caller-authored cwd is trusted.
|
|
136
|
+
const REVIEWED_CWD = Symbol('namzu.bash.reviewed-cwd')
|
|
137
|
+
|
|
138
|
+
export function pinBashWorkingDirectory(context: object, cwd: string): void {
|
|
139
|
+
Object.defineProperty(context, REVIEWED_CWD, { value: cwd, enumerable: true, configurable: true })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function bashWorkingDirectoryForExecution(context: {
|
|
143
|
+
sessionId?: string
|
|
144
|
+
workingDirectory: string
|
|
145
|
+
sandbox?: unknown
|
|
146
|
+
}): string {
|
|
147
|
+
const current = effectiveBashWorkingDirectory(context.sessionId, context.workingDirectory, {
|
|
148
|
+
sandboxed: context.sandbox !== undefined,
|
|
149
|
+
})
|
|
150
|
+
const reviewed = (context as { [REVIEWED_CWD]?: string })[REVIEWED_CWD]
|
|
151
|
+
if (reviewed !== undefined && current !== reviewed)
|
|
152
|
+
throw new Error(
|
|
153
|
+
'Shell working directory changed after this call was reviewed. Nothing ran. Retry the call so its paths can be checked in the current directory.',
|
|
154
|
+
)
|
|
155
|
+
return reviewed ?? current
|
|
156
|
+
}
|