@mono-agent/agent-runtime 0.19.1 → 0.20.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 (51) hide show
  1. package/MIGRATION.md +1 -1
  2. package/README.md +101 -4
  3. package/package.json +1 -1
  4. package/src/agent/sandbox-seam.js +16 -2
  5. package/src/agent/tools/bash.js +26 -4
  6. package/src/agent/tools/edit.js +72 -5
  7. package/src/agent/tools/exec.js +22 -4
  8. package/src/agent/tools/glob.js +65 -9
  9. package/src/agent/tools/grep.js +66 -11
  10. package/src/agent/tools/node-repl.js +5 -2
  11. package/src/agent/tools/pi-bridge.js +263 -48
  12. package/src/agent/tools/read.js +50 -10
  13. package/src/agent/tools/shared/path-resolver.js +67 -2
  14. package/src/agent/tools/shared/process-jobs.js +188 -0
  15. package/src/agent/tools/shared/process-runner.js +541 -30
  16. package/src/agent/tools/shared/protected-filesystem.js +150 -0
  17. package/src/agent/tools/web-search.js +63 -8
  18. package/src/agent/tools/write.js +52 -6
  19. package/src/ai/providers/acp.js +4 -0
  20. package/src/ai/providers/claude-cli.js +35 -2
  21. package/src/ai/providers/claude-sdk.js +12 -0
  22. package/src/ai/providers/codex-app.js +15 -2
  23. package/src/ai/providers/pi-native/stream-subscriber.js +29 -2
  24. package/src/ai/providers/pi-native/turn-runner.js +3 -0
  25. package/src/ai/providers/pi-native.js +7 -1
  26. package/src/ai/runtime/capabilities.js +2 -0
  27. package/src/ai/runtime/router.js +78 -6
  28. package/src/ai/streaming/codex-events.js +15 -0
  29. package/src/ai/streaming/opencode-events.js +5 -0
  30. package/src/ai/tool-lifecycle.js +347 -0
  31. package/src/ai/types.js +58 -0
  32. package/src/runtime.js +35 -21
  33. package/types/agent/sandbox-seam.d.ts +19 -6
  34. package/types/agent/tools/bash.d.ts +11 -26
  35. package/types/agent/tools/edit.d.ts +3 -2
  36. package/types/agent/tools/exec.d.ts +13 -26
  37. package/types/agent/tools/glob.d.ts +3 -2
  38. package/types/agent/tools/grep.d.ts +3 -2
  39. package/types/agent/tools/pi-bridge.d.ts +11 -4
  40. package/types/agent/tools/read.d.ts +3 -2
  41. package/types/agent/tools/shared/path-resolver.d.ts +8 -0
  42. package/types/agent/tools/shared/process-jobs.d.ts +64 -0
  43. package/types/agent/tools/shared/process-runner.d.ts +45 -3
  44. package/types/agent/tools/shared/protected-filesystem.d.ts +51 -0
  45. package/types/agent/tools/write.d.ts +3 -2
  46. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -0
  47. package/types/ai/runtime/capabilities.d.ts +3 -0
  48. package/types/ai/streaming/codex-events.d.ts +1 -0
  49. package/types/ai/streaming/opencode-events.d.ts +1 -0
  50. package/types/ai/tool-lifecycle.d.ts +43 -0
  51. package/types/ai/types.d.ts +118 -0
package/MIGRATION.md CHANGED
@@ -490,7 +490,7 @@ a compatibility subpath.
490
490
 
491
491
  ## Version
492
492
 
493
- This guide describes the published `0.19.x` package contract. Keep
493
+ This guide describes the published `0.20.x` package contract. Keep
494
494
  `@mono-agent/agent-runtime`, `@mono-agent/runtime-adapter`, and other
495
495
  `@mono-agent/*` packages on the same lockstep version when upgrading. The paired
496
496
  runtime adapter no longer exposes `piReasoningSummary` in its run-options type.
package/README.md CHANGED
@@ -53,6 +53,18 @@ console.log(result.text);
53
53
  `Glob` and `Grep` prefer the packaged `@vscode/ripgrep` binary on supported
54
54
  platforms. An explicit `ripgrepPath` wins, with `PATH` as the final fallback.
55
55
 
56
+ ### Pi-native MCP Apps bridge
57
+
58
+ Only the Pi-native bridge declares `supports_mcp_apps`. When a host supplies an
59
+ MCP Apps registry, the MCP client advertises the standard ext-apps UI MIME
60
+ capability. The host then explicitly intersects the two reviewed protocol
61
+ revisions, reads only the originating tool's declared `ui://` resource, and
62
+ receives one exact connection capability. Successful registration retains that
63
+ existing MCP client instead of creating a client per UI call; host LRU/idle
64
+ eviction closes the client, transport, and sandbox cleanup. Other runtime
65
+ backends do not advertise or receive this extension.
66
+ See [Reply files and MCP Apps](https://mono-agent-docs.vercel.app/tools/rich-replies/).
67
+
56
68
  ## Architecture
57
69
 
58
70
  The package uses a fixed registry of bridge descriptors and loads provider code
@@ -64,10 +76,14 @@ only after a run selects a matching model reference and execution mode:
64
76
  2. `resolveRuntimeBridge()` checks the six static bridge descriptors in order.
65
77
  3. The selected descriptor lazily imports its provider implementation.
66
78
  4. The bridge prepares the runtime inputs it supports, including managed or MCP
67
- tools only where that bridge can represent them, streams normalized events,
68
- and returns a provider-neutral `RuntimeResult`.
69
- 5. The host validates any domain-specific result and owns persistence or UI
70
- effects.
79
+ tools only where that bridge can represent them, and streams normalized
80
+ events through the lifecycle gate.
81
+ 5. For each managed-tool start/result, the gate awaits the host's incremental
82
+ persistence sink before publishing the same block with history metadata to
83
+ the client; provider events remain deterministically ordered even for
84
+ parallel tools.
85
+ 6. The bridge returns a provider-neutral `RuntimeResult`; the host owns storage,
86
+ recovery, and UI effects.
71
87
 
72
88
  ### Package structure
73
89
 
@@ -82,6 +98,58 @@ only after a run selects a matching model reference and execution mode:
82
98
  The detailed lifecycle, provider-session differences, and host boundary are in
83
99
  the [architecture guide](https://github.com/robertsreberski/mono-agent/blob/main/packages/agent-runtime/ARCHITECTURE.md).
84
100
 
101
+ ### Managed-tool lifecycle fidelity
102
+
103
+ `RuntimeRunOptions.toolLifecycleSink` is an awaited host-owned boundary. The
104
+ runtime sends redaction-eligible raw arguments/content plus stable provider call
105
+ id and name; the host returns only record/sequence, persistence/truncation byte
106
+ metadata, and opaque artifact references. That returned metadata is attached to
107
+ the exact normalized `tool_use` / `tool_result` event rendered by clients. Sink
108
+ failure is fail-soft and explicit (`persistence: "failed"` plus an error code),
109
+ never fake success. A callback exception cannot duplicate a client event.
110
+
111
+ Provider bridges only claim terminal distinctions their structured protocol
112
+ actually supplies:
113
+
114
+ | Bridge | Genuine tool-result distinctions | Conservative fallback |
115
+ | --- | --- | --- |
116
+ | Pi native | `success`, `error`, numeric `exit_nonzero`, structured `timeout`, structured `signal`, and abort-backed `cancelled`; host approval denial/expiry adds `rejected`/`timeout` | Unknown failed outcome → `error` |
117
+ | Codex app-server | `success`, `error`, and numeric command `exit_nonzero`; shared host abort rules still apply | Other failed item → `error`; no result before run end remains dangling for host closure/recovery |
118
+ | Claude SDK | `success` / `error`; shared host approval events can add `rejected`/`timeout` and an aborted error result can add `cancelled` | Undistinguished failed result → `error` |
119
+ | Claude Code CLI | `success` / `error`; a Codex-shaped command item with an explicit non-zero code is `exit_nonzero`; shared approval/abort rules still apply | Undistinguished failed result → `error` |
120
+ | OpenCode app-server | `success` / `error`; shared approval/abort rules still apply | Undistinguished failed result → `error` |
121
+ | ACP v1 | `completed` → `success`, `failed` → `error`; shared approval/abort rules still apply | ACP exposes no tool-level signal/exit/timeout distinction, so failed → `error` |
122
+
123
+ The runtime never derives a state from result prose. Structured timeout, signal,
124
+ non-zero exit, completed success, and a specific non-runtime, non-cancellation
125
+ provider failure win over a later outer abort. For a failed result with an
126
+ aborted outer signal, cancellation wins over a provider bridge's otherwise
127
+ generic `error` / `runtime_error` fallback. Whenever that outer abort determines
128
+ the result—including when the bridge supplied a trusted `cancelled` hint—the
129
+ failure kind comes only from host abort provenance: the cross-package own
130
+ data-property brand `channelUserCancel === true` maps to `cancelled_user`; every
131
+ unbranded reason maps to generic `cancelled`. Accessors, Proxies, inherited or
132
+ brand-shaped impostors, error prose, class/channel names, reason `kind` or `code`
133
+ strings, and raw provider result data never select host provenance.
134
+ Without an outer abort, a trusted `cancelled` hint retains a known cancellation
135
+ failure kind and otherwise falls back to `cancelled`.
136
+
137
+ This outer-abort rule matches the harness run-level classifier. The standalone
138
+ runtime keeps its zero-`@mono-agent/*` dependency boundary by recognizing the
139
+ shared brand structurally rather than importing `@mono-agent/agent-contracts`.
140
+ Its deliberate extra fidelity is tool-level only: a trusted bridge hint can
141
+ retain a cancellation subtype when no outer abort exists. The run-level harness
142
+ has no corresponding tool hint, so this does not widen a shared core contract.
143
+ A started call with no result is closed by the harness as `cancelled`, `error`,
144
+ or `interrupted` from the run boundary; process-startup recovery specifically
145
+ uses `interrupted` and does not rerun the tool.
146
+
147
+ Terminal states reuse the observability taxonomy: `success` has no failure kind;
148
+ `signal` and `interrupted` map to `process_death`; `cancelled` preserves a known
149
+ `cancelled`, `cancelled_user`, `cancelled_stale`, `cancelled_shutdown`, or
150
+ `cancelled_signal`; `rejected`, `error`, `exit_nonzero`, and `timeout` use a
151
+ provider-supplied known kind when available and otherwise `runtime_error`.
152
+
85
153
  ## Public API
86
154
 
87
155
  ### Start here
@@ -753,6 +821,7 @@ Per-call options (a non-exhaustive selection):
753
821
  | `nativeSubagents` | `object` | Caller-defined Claude native `Task` profiles. Direct Codex rejects configured teammate definitions because Codex owns its collaboration agents. |
754
822
  | `settingSources` | `("user" \| "project" \| "local")[]` | Claude Agent SDK filesystem settings opt-in. Omitted/empty disables those three sources; Anthropic managed settings still apply. |
755
823
  | `codexLoadProjectDocs` | `boolean` | Codex app-server repository-instruction opt-in. Omitted/false sets `project_doc_max_bytes=0`; true restores Codex defaults. Explicit `codexAppServerArgs` wins. |
824
+ | `codexSandboxNetworkAccess` | `boolean` | Code-only Codex app-server per-turn network control. Only strict `true` enables it for plan/default/acceptEdits; omitted or any other value disables it. |
756
825
  | `mcpServers` | `Record<string, McpServerConfig>` | Configured MCP servers (stdio / sse / http); on direct Codex, each forwarded server authorizes its own tool calls. |
757
826
  | `sandboxPolicy` | `SandboxPolicy` | Optional fail-closed sandbox policy for built-in tools and stdio MCP process startup. |
758
827
  | `webSearchConfig` | `{ backend?, endpoint? }` | Run-scoped local SearXNG/keyless WebSearch backend selection. |
@@ -818,6 +887,15 @@ Codex and its own collaboration agents should load repository instructions. If
818
887
  `codexAppServerArgs` is supplied, that explicit argument vector is authoritative
819
888
  and `codexLoadProjectDocs` does not alter it.
820
889
 
890
+ `codexSandboxNetworkAccess` is a separate code-only, provider-native control.
891
+ It is unrelated to `RuntimeRunOptions.sandboxPolicy`, which controls
892
+ mono-agent's own sandbox and is not consumed by Codex's provider-owned tool
893
+ loop. Only strict `true` enables network access for plan/read-only and
894
+ default/acceptEdits/workspace-write turns; the no-tools probe remains offline
895
+ and bypass remains danger-full-access. Combining workspace-write with network
896
+ access grants repository read and network egress in the same turn. Prefer
897
+ `permissionMode: "plan"` when only read-only browsing is needed.
898
+
821
899
  Provider-native and in-process delegation share `subagent_activity` telemetry.
822
900
  `subagent.id` is the canonical parent attachment key: the initiating parent
823
901
  tool-use id whenever the provider exposes it, or a stable synthetic key for an
@@ -907,6 +985,17 @@ by one lazily started Node.js REPL child per run. You select them via
907
985
  - The runtime context's `workspace` / `repoRoot` allow-list (paths outside both, plus `/tmp` and `process.cwd()`, are rejected)
908
986
  - Output truncation with optional artifact persistence (`{toolArtifactDir}/tool-output/{runId}/...` when `toolArtifactDir` is configured)
909
987
 
988
+ The Pi-native tool context may structurally receive a host process-job
989
+ controller. Only then do Exec and Bash add optional `background`; with no
990
+ controller their schemas and foreground path are unchanged. A background call
991
+ hands the exact prepared command to the controller and stops awaiting it. The
992
+ kernel first creates a command-agnostic detached POSIX group leader; only after
993
+ the host durably records its PID, equal PGID, and process incarnation does the
994
+ kernel release the exact target over an anonymous pipe. The kernel also owns
995
+ whole-tree completion, while the host owns durable policy, persistence, wake
996
+ delivery, and operator state. The kernel intentionally imports no workspace
997
+ contract package for this boundary.
998
+
910
999
  `NodeRepl` uses Node's default `node:repl` evaluator, so variables, `_`, `_error`, and loaded modules persist across calls in the same run. It supports multiline input and top-level `await`, resolves workspace-installed packages, and is closed with the run. Its child is prepared through the same sandbox seam as `Exec`/`Bash` and communicates through token-authenticated, length-prefixed JSON frames on ordinary stdin/stdout; abort, the fixed 120-second timeout, child exit, or hard output overflow resets the session. It deliberately has no session ids, persistent history, terminal commands, or package-install surface.
911
1000
 
912
1001
  `WebSearch` uses a configured loopback SearXNG endpoint and/or deterministic
@@ -987,6 +1076,12 @@ Behaviour:
987
1076
  - Chain exhausted → `failureKind: "provider_unavailable_exhausted"`, `failoverHistory` lists every attempt.
988
1077
  - `uniform` safety keeps the shared monotonic runtime; `per-route-native` isolates
989
1078
  route runtimes and records each bounded safety contract/status.
1079
+ - A `per-route-native` non-Pi route cannot project non-empty internal
1080
+ `sandboxPolicy.protectedRoots`; the router records `safety_unavailable` and
1081
+ advances before route resolution or provider invocation. Empty protected-root
1082
+ sets preserve ordinary provider-native behavior, while Pi routes retain the
1083
+ policy. The same invariant covers model routes reached through `Agent`
1084
+ children.
990
1085
  - Pi route telemetry distinguishes `disabled`, fail-closed `mono-agent-srt`,
991
1086
  and `mono-agent-srt-unsafe-host-fallback`; the last describes a configured
992
1087
  policy that prefers SRT but permits host execution, not which branch ran.
@@ -1198,6 +1293,8 @@ runtime fails closed.
1198
1293
  shows the code-only host hooks.
1199
1294
  - [Local-first web research](https://mono-agent-docs.vercel.app/tools/web-research/)
1200
1295
  documents SearXNG, extraction, retry, browser isolation, and sandbox policy.
1296
+ - [Reply files and MCP Apps](https://mono-agent-docs.vercel.app/tools/rich-replies/)
1297
+ documents the host bridge, browser sandbox, and lifecycle limits.
1201
1298
  - [Architecture](https://github.com/robertsreberski/mono-agent/blob/main/packages/agent-runtime/ARCHITECTURE.md)
1202
1299
  and [migration guide](https://github.com/robertsreberski/mono-agent/blob/main/packages/agent-runtime/MIGRATION.md)
1203
1300
  cover internal flow and upgrades from `0.3.x`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Agent runtime supporting Claude SDK/CLI, Codex, OpenCode, Pi SDK, and ACP v1 bridges out of the box",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
@@ -48,12 +48,20 @@
48
48
  * @property {string} command
49
49
  * @property {ReadonlyArray<string>} [args]
50
50
  * @property {string} [cwd]
51
- * @property {Object<string, string|undefined>} [env]
51
+ * @property {Record<string, string|undefined>} [env]
52
52
  * @property {boolean} [allowLocalBinding] Trusted per-command capability.
53
53
  */
54
54
 
55
55
  /**
56
- * @typedef {SandboxCommandSpec & {sandboxed: boolean, cleanup?: () => Promise<void>}} PreparedSandboxCommand
56
+ * @typedef {Object} PreparedSandboxCommand
57
+ * @property {string} command
58
+ * @property {ReadonlyArray<string>} args
59
+ * @property {string} cwd
60
+ * @property {Record<string, string|undefined>} [env]
61
+ * @property {boolean} [allowLocalBinding]
62
+ * @property {boolean} sandboxed
63
+ * @property {string} [sandboxSettingsPath]
64
+ * @property {() => Promise<void>} [cleanup]
57
65
  */
58
66
 
59
67
  /**
@@ -77,6 +85,7 @@
77
85
  * @property {SandboxNetworkPolicyLike} [network]
78
86
  * @property {ReadonlyArray<string>} [readableRoots]
79
87
  * @property {ReadonlyArray<string>} [writableRoots]
88
+ * @property {ReadonlyArray<string>} [protectedRoots] Host-internal roots denied for both reads and writes.
80
89
  * @property {ReadonlyArray<string>} [denyWrite]
81
90
  * @property {string} [root]
82
91
  */
@@ -173,11 +182,16 @@ function mergePolicies(configured, request) {
173
182
  const configuredIsReal = isRealSandboxMode(configured.mode);
174
183
  const requestIsReal = isRealSandboxMode(request.mode);
175
184
  const mode = configuredIsReal ? configured.mode : (requestIsReal ? request.mode : (request.mode ?? configured.mode));
185
+ const protectedRoots = [...new Set([
186
+ ...(configured.protectedRoots ?? []),
187
+ ...(request.protectedRoots ?? []),
188
+ ])].sort();
176
189
  return {
177
190
  ...configured,
178
191
  ...request,
179
192
  mode,
180
193
  network: mergeNetwork(configured.network, request.network),
194
+ protectedRoots,
181
195
  };
182
196
  }
183
197
 
@@ -14,6 +14,7 @@ import {
14
14
  DEFAULT_PROCESS_BUFFER_BYTES,
15
15
  runPreparedProcess,
16
16
  } from "./shared/process-runner.js";
17
+ import { handOffProcessJob } from "./shared/process-jobs.js";
17
18
  import { readToolRuntime } from "./shared/runtime-context.js";
18
19
  import { requestToolProcessEnvironment, resolveSandboxPolicy } from "./shared/tool-context.js";
19
20
 
@@ -56,8 +57,8 @@ export function normalizeProcessTimeoutMs(value, fallback = DEFAULT_BASH_TIMEOUT
56
57
  /**
57
58
  * Compatibility wrapper retained for direct callers and tests.
58
59
  *
59
- * @param {{command: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string}} params
60
- * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
60
+ * @param {{command: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string, background?: boolean}} params
61
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: import("./shared/process-jobs.js").ProcessJobsController}} [options]
61
62
  */
62
63
  export async function bashToolImpl(params, options = {}) {
63
64
  return (await bashToolRun(params, options)).text;
@@ -66,8 +67,8 @@ export async function bashToolImpl(params, options = {}) {
66
67
  /**
67
68
  * Structured Bash execution used by the Pi bridge.
68
69
  *
69
- * @param {{command: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string}} params
70
- * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
70
+ * @param {{command: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string, background?: boolean}} params
71
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: import("./shared/process-jobs.js").ProcessJobsController}} [options]
71
72
  */
72
73
  export async function bashToolRun(
73
74
  {
@@ -76,12 +77,14 @@ export async function bashToolRun(
76
77
  timeout_ms,
77
78
  max_output_chars,
78
79
  workdir,
80
+ background,
79
81
  },
80
82
  {
81
83
  signal,
82
84
  sandboxPolicy,
83
85
  sandboxEngine,
84
86
  ctx,
87
+ processJobsController,
85
88
  } = {},
86
89
  ) {
87
90
  const startedAt = Date.now();
@@ -127,6 +130,25 @@ export async function bashToolRun(
127
130
  return failed(`Error: ${error?.message || String(error)}`, "sandbox_prepare_failed", startedAt);
128
131
  }
129
132
 
133
+ if (background === true && processJobsController) {
134
+ const requestedTimeoutMs = timeout_ms !== undefined
135
+ ? timeoutMs
136
+ : (timeout === undefined ? undefined : timeoutMs);
137
+ const handedOff = await handOffProcessJob({
138
+ controller: processJobsController,
139
+ tool: "Bash",
140
+ prepared,
141
+ summary: `Bash command (${command.length} characters; content redacted)`,
142
+ timeoutMs: requestedTimeoutMs,
143
+ maxOutputChars: max_output_chars === undefined ? undefined : maxChars,
144
+ startedAt,
145
+ failed,
146
+ });
147
+ return handedOff.error || !legacyTimeoutUsed
148
+ ? handedOff
149
+ : { ...handedOff, outcome: { ...handedOff.outcome, legacyTimeoutUsed: true } };
150
+ }
151
+
130
152
  let result;
131
153
  let cleanupError;
132
154
  try {
@@ -1,15 +1,82 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { isPathAllowed, isWritablePathAllowed, resolveToolPath } from "./shared/path-resolver.js";
2
+ import {
3
+ isPathAllowed,
4
+ isPathLexicallyAllowed,
5
+ isWritablePathAllowed,
6
+ isWritablePathLexicallyAllowed,
7
+ resolveToolPath,
8
+ } from "./shared/path-resolver.js";
9
+ import {
10
+ protectedCommandSucceeded,
11
+ protectedFilesystemTargetPlan,
12
+ runProtectedFilesystemCommand,
13
+ } from "./shared/protected-filesystem.js";
14
+
15
+ const PROTECTED_EDIT_SOURCE = String.raw`
16
+ "use strict";
17
+ const { readFileSync, writeFileSync } = require("node:fs");
18
+ const chunks = [];
19
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
20
+ process.stdin.on("end", () => {
21
+ const request = JSON.parse(Buffer.concat(chunks).toString("utf8"));
22
+ const content = readFileSync(process.argv[1], "utf8");
23
+ const count = content.split(request.oldString).length - 1;
24
+ if (count === 0) return process.stdout.write(JSON.stringify({ status: "missing", count }));
25
+ if (!request.replaceAll && count > 1) return process.stdout.write(JSON.stringify({ status: "ambiguous", count }));
26
+ writeFileSync(
27
+ process.argv[1],
28
+ request.replaceAll
29
+ ? content.replaceAll(request.oldString, request.newString)
30
+ : content.replace(request.oldString, request.newString),
31
+ "utf8",
32
+ );
33
+ process.stdout.write(JSON.stringify({ status: "edited", count }));
34
+ });
35
+ `;
3
36
 
4
37
  /**
5
38
  * @param {{file_path: string, old_string: string, new_string: string, replace_all?: boolean, workdir?: string}} params
6
- * @param {{sandboxPolicy?: any, ctx?: any}} [options]
39
+ * @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
7
40
  */
8
- export async function editToolImpl({ file_path, old_string, new_string, replace_all = false, workdir }, { sandboxPolicy, ctx } = {}) {
41
+ export async function editToolImpl({ file_path, old_string, new_string, replace_all = false, workdir }, { sandboxPolicy, sandboxEngine, ctx } = {}) {
9
42
  const target = resolveToolPath(file_path, workdir, ctx);
10
43
  const pathOptions = { sandboxPolicy, ctx };
11
- if (!isPathAllowed(target, workdir, pathOptions) || !isWritablePathAllowed(target, workdir, pathOptions)) return `Error: Path not allowed: ${file_path}`;
12
- if (!existsSync(target)) return `Error: File not found: ${file_path}`;
44
+ const protectedTarget = protectedFilesystemTargetPlan(target, { sandboxPolicy, ctx });
45
+ const protectedExecution = protectedTarget !== null;
46
+ if (protectedExecution) {
47
+ if (!isPathLexicallyAllowed(target, workdir, pathOptions)
48
+ || !isWritablePathLexicallyAllowed(target, workdir, pathOptions)) {
49
+ return "Error: Protected filesystem edit was denied.";
50
+ }
51
+ } else if (!isPathAllowed(target, workdir, pathOptions)
52
+ || !isWritablePathAllowed(target, workdir, pathOptions)) {
53
+ return `Error: Path not allowed: ${file_path}`;
54
+ }
55
+ if (!protectedExecution && !existsSync(target)) return `Error: File not found: ${file_path}`;
56
+ if (protectedExecution) {
57
+ try {
58
+ const protectedResult = await runProtectedFilesystemCommand({
59
+ command: process.execPath,
60
+ args: ["--input-type=commonjs", "--eval", PROTECTED_EDIT_SOURCE, target],
61
+ cwd: protectedTarget.cwd,
62
+ }, {
63
+ sandboxPolicy,
64
+ sandboxEngine,
65
+ ctx,
66
+ input: JSON.stringify({ oldString: old_string, newString: new_string, replaceAll: replace_all }),
67
+ });
68
+ if (!protectedCommandSucceeded(protectedResult)) {
69
+ return "Error: Protected filesystem edit was denied.";
70
+ }
71
+ const result = JSON.parse(protectedResult.stdout);
72
+ if (result.status === "missing") return `Error: old_string not found in ${target}`;
73
+ if (result.status === "ambiguous") return `Error: old_string found ${result.count} times`;
74
+ if (result.status !== "edited") return "Error: Protected filesystem edit was denied.";
75
+ return `Successfully edited ${target}`;
76
+ } catch {
77
+ return "Error: Protected filesystem edit was denied.";
78
+ }
79
+ }
13
80
  const content = readFileSync(target, "utf8");
14
81
  const count = content.split(old_string).length - 1;
15
82
  if (count === 0) return `Error: old_string not found in ${target}`;
@@ -15,15 +15,18 @@ import {
15
15
  DEFAULT_PROCESS_BUFFER_BYTES,
16
16
  runPreparedProcess,
17
17
  } from "./shared/process-runner.js";
18
+ import { handOffProcessJob } from "./shared/process-jobs.js";
18
19
  import { readToolRuntime } from "./shared/runtime-context.js";
19
20
  import { requestToolProcessEnvironment, resolveSandboxPolicy } from "./shared/tool-context.js";
20
21
 
21
22
  const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
22
23
  const MAX_EXEC_ARGS = 256;
23
24
 
25
+ /** @typedef {import("./shared/process-jobs.js").ProcessJobsController} ProcessJobsController */
26
+
24
27
  /**
25
- * @param {{executable: string, args?: string[], workdir?: string, timeout_ms?: number, max_output_chars?: number}} params
26
- * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
28
+ * @param {{executable: string, args?: string[], workdir?: string, timeout_ms?: number, max_output_chars?: number, background?: boolean}} params
29
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: ProcessJobsController}} [options]
27
30
  */
28
31
  export async function execToolImpl(params, options = {}) {
29
32
  return (await execToolRun(params, options)).text;
@@ -32,8 +35,8 @@ export async function execToolImpl(params, options = {}) {
32
35
  /**
33
36
  * Execute an argv vector directly, without shell parsing.
34
37
  *
35
- * @param {{executable: string, args?: string[], workdir?: string, timeout_ms?: number, max_output_chars?: number}} params
36
- * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
38
+ * @param {{executable: string, args?: string[], workdir?: string, timeout_ms?: number, max_output_chars?: number, background?: boolean}} params
39
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: ProcessJobsController}} [options]
37
40
  */
38
41
  export async function execToolRun(
39
42
  {
@@ -42,12 +45,14 @@ export async function execToolRun(
42
45
  workdir,
43
46
  timeout_ms,
44
47
  max_output_chars,
48
+ background,
45
49
  },
46
50
  {
47
51
  signal,
48
52
  sandboxPolicy,
49
53
  sandboxEngine,
50
54
  ctx,
55
+ processJobsController,
51
56
  } = {},
52
57
  ) {
53
58
  const startedAt = Date.now();
@@ -90,6 +95,19 @@ export async function execToolRun(
90
95
  return failed(`Error: ${error?.message || String(error)}`, "sandbox_prepare_failed", startedAt);
91
96
  }
92
97
 
98
+ if (background === true && processJobsController) {
99
+ return handOffProcessJob({
100
+ controller: processJobsController,
101
+ tool: "Exec",
102
+ prepared,
103
+ summary: `Exec command (${args.length} argument${args.length === 1 ? "" : "s"}; values redacted)`,
104
+ timeoutMs: timeout_ms === undefined ? undefined : timeoutMs,
105
+ maxOutputChars: max_output_chars === undefined ? undefined : maxChars,
106
+ startedAt,
107
+ failed,
108
+ });
109
+ }
110
+
93
111
  let result;
94
112
  let cleanupError;
95
113
  try {
@@ -9,9 +9,18 @@ import {
9
9
  import { boundedInt, safeStat } from "./shared/dedup.js";
10
10
  import {
11
11
  isPathAllowed,
12
+ isPathLexicallyAllowed,
13
+ protectedRelativePaths,
12
14
  resolveToolPath,
13
15
  workspaceRoot,
14
16
  } from "./shared/path-resolver.js";
17
+ import {
18
+ normalizeProtectedSearchLine,
19
+ protectedDirectorySearchTarget,
20
+ protectedFilesystemTargetPlan,
21
+ runProtectedFilesystemCommand,
22
+ scopeProtectedSearchGlob,
23
+ } from "./shared/protected-filesystem.js";
15
24
  import {
16
25
  capLines,
17
26
  excludedGlobArgs,
@@ -26,31 +35,77 @@ const execFileAsync = promisify(execFile);
26
35
 
27
36
  /**
28
37
  * @param {{pattern: string, path?: string, limit?: number, offset?: number, max_matches?: number, max_output_chars?: number, workdir?: string}} params
29
- * @param {{sandboxPolicy?: any, ctx?: any}} [options]
38
+ * @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
30
39
  */
31
- export async function globToolImpl({ pattern, path, limit, offset = 0, max_matches, max_output_chars, workdir }, { sandboxPolicy, ctx } = {}) {
40
+ export async function globToolImpl({ pattern, path, limit, offset = 0, max_matches, max_output_chars, workdir }, { sandboxPolicy, sandboxEngine, ctx } = {}) {
32
41
  const cwd = resolveToolPath(path || workspaceRoot(workdir, ctx), workdir, ctx);
33
- if (!isPathAllowed(cwd, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${cwd}`;
34
- const stat = safeStat(cwd);
35
- if (!stat?.isDirectory()) return `Error: Glob path is not a directory: ${cwd}`;
42
+ const protectedSearch = protectedFilesystemTargetPlan(cwd, { sandboxPolicy, ctx });
43
+ const protectedExecution = protectedSearch !== null;
44
+ if (protectedExecution) {
45
+ if (!isPathLexicallyAllowed(cwd, workdir, { sandboxPolicy, ctx })) {
46
+ return "Error: Protected filesystem search was denied.";
47
+ }
48
+ } else if (!isPathAllowed(cwd, workdir, { sandboxPolicy, ctx })) {
49
+ return `Error: Path not allowed: ${cwd}`;
50
+ }
51
+ if (!protectedExecution) {
52
+ const stat = safeStat(cwd);
53
+ if (!stat?.isDirectory()) return `Error: Glob path is not a directory: ${cwd}`;
54
+ }
36
55
  const resultLimit = boundedInt(limit ?? max_matches, DEFAULT_MAX_SEARCH_LINES, { min: 1, max: 1000 });
56
+ const normalizedPattern = normalizeGlobPattern(pattern);
37
57
  const args = [
38
58
  "--files",
39
59
  "--hidden",
40
60
  "--color=never",
41
61
  "--glob",
42
- normalizeGlobPattern(pattern),
62
+ protectedExecution
63
+ ? scopeProtectedSearchGlob(normalizedPattern, protectedSearch.searchTarget)
64
+ : normalizedPattern,
43
65
  ...excludedGlobArgs(),
44
66
  ];
67
+ const searchCwd = protectedSearch?.cwd ?? cwd;
68
+ const protectedPaths = protectedRelativePaths(searchCwd, { sandboxPolicy, ctx });
69
+ for (const protectedPath of protectedPaths) {
70
+ args.push("--glob", `!${protectedPath}`, "--glob", `!${protectedPath}/**`);
71
+ }
72
+ if (protectedExecution) args.push("--", protectedDirectorySearchTarget(protectedSearch.searchTarget));
45
73
  const rgPath = resolveRgPath({ ctx });
46
74
  if (!rgPath) return ripgrepMissingMessage(ctx);
47
75
  try {
48
- const { stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER });
49
- const lines = stdout.trim().split("\n").filter(Boolean).sort((a, b) => {
76
+ let stdout;
77
+ if (!protectedExecution) {
78
+ ({ stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER }));
79
+ } else {
80
+ const protectedResult = await runProtectedFilesystemCommand({
81
+ command: rgPath,
82
+ args,
83
+ cwd: searchCwd,
84
+ }, { sandboxPolicy, sandboxEngine, ctx, maxBufferBytes: SEARCH_MAX_BUFFER });
85
+ if (protectedResult?.code === 1) return "No files found matching pattern.";
86
+ if (protectedResult === null
87
+ || protectedResult.code !== 0
88
+ || protectedResult.bufferExceeded
89
+ || protectedResult.timedOut) {
90
+ return "Error: Protected filesystem search was denied.";
91
+ }
92
+ stdout = protectedResult.stdout;
93
+ }
94
+ const lines = stdout.trim().split("\n")
95
+ .filter(Boolean)
96
+ .filter((line) => !protectedPaths.some((path) => line === path || line.startsWith(`${path}/`)))
97
+ .map((line) => protectedExecution
98
+ ? normalizeProtectedSearchLine(line, protectedSearch.searchTarget)
99
+ : line)
100
+ .sort((a, b) => {
101
+ // Do not follow a model-controlled output symlink on the host merely to
102
+ // rank results: with protected roots active, the actual search already
103
+ // crossed SRT and lexical ordering avoids a post-search metadata race.
104
+ if (protectedExecution) return a.localeCompare(b);
50
105
  const aStat = safeStat(resolve(cwd, a));
51
106
  const bStat = safeStat(resolve(cwd, b));
52
107
  return (bStat?.mtimeMs || 0) - (aStat?.mtimeMs || 0) || a.localeCompare(b);
53
- });
108
+ });
54
109
  const result = formatSearchLines(lines, {
55
110
  label: "Glob",
56
111
  noMatches: "No files found matching pattern.",
@@ -61,6 +116,7 @@ export async function globToolImpl({ pattern, path, limit, offset = 0, max_match
61
116
  });
62
117
  return result === "No files found matching pattern." ? result : `${result}\n\n${excludedPathSummary()}`;
63
118
  } catch (err) {
119
+ if (protectedExecution) return "Error: Protected filesystem search was denied.";
64
120
  if (err.code === 1) return "No files found matching pattern.";
65
121
  if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || /maxBuffer/i.test(err.message || "")) {
66
122
  return `${capLines(err.stdout || "", {