@mono-agent/agent-runtime 0.19.0 → 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
@@ -29,31 +29,76 @@ export function isWritablePathAllowed(path, workdir, options = {}) {
29
29
  return isPathAllowedFor(path, workdir, "write", options);
30
30
  }
31
31
 
32
+ // Protected filesystem operations use these metadata-free checks only as a
33
+ // lexical policy preflight. The native sandbox remains responsible for
34
+ // resolving symlinks and enforcing the real path at the operation syscall.
35
+ export function isPathLexicallyAllowed(path, workdir, options = {}) {
36
+ return isPathLexicallyAllowedFor(path, workdir, "read", options);
37
+ }
38
+
39
+ export function isWritablePathLexicallyAllowed(path, workdir, options = {}) {
40
+ return isPathLexicallyAllowedFor(path, workdir, "write", options);
41
+ }
42
+
32
43
  function isPathAllowedFor(path, workdir, access, options) {
33
44
  const ctx = options.ctx;
34
45
  const r = resolveToolPath(path, workdir, ctx);
35
46
  const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
36
47
  if (policy) {
37
48
  const field = access === "write" ? policy.writableRoots : policy.readableRoots;
38
- return insideSandboxRoots(Array.isArray(field) ? field : [], r)
49
+ return !insideProtectedRoots(Array.isArray(policy.protectedRoots) ? policy.protectedRoots : [], r)
50
+ && insideSandboxRoots(Array.isArray(field) ? field : [], r)
39
51
  && (access !== "write" || !sandboxDeniesWrite(policy, r, ctx));
40
52
  }
41
53
  const { workspace, repoRoot } = configured(ctx);
42
54
  return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
43
55
  }
44
56
 
57
+ function isPathLexicallyAllowedFor(path, workdir, access, options) {
58
+ const ctx = options.ctx;
59
+ const r = resolveToolPath(path, workdir, ctx);
60
+ const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
61
+ if (policy) {
62
+ const field = access === "write" ? policy.writableRoots : policy.readableRoots;
63
+ return !insideLexicalRoots(Array.isArray(policy.protectedRoots) ? policy.protectedRoots : [], r)
64
+ && insideLexicalRoots(Array.isArray(field) ? field : [], r)
65
+ && (access !== "write" || !sandboxLexicallyDeniesWrite(policy, r, ctx));
66
+ }
67
+ const { workspace, repoRoot } = configured(ctx);
68
+ return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
69
+ }
70
+
45
71
  export function isWorkdirAllowed(workdir, options = {}) {
46
72
  if (!workdir) return true;
47
73
  const ctx = options.ctx;
48
74
  const r = resolve(workdir);
49
75
  const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
50
76
  if (policy) {
51
- return insideSandboxRoots(Array.isArray(policy.readableRoots) ? policy.readableRoots : [], r);
77
+ return !insideProtectedRoots(Array.isArray(policy.protectedRoots) ? policy.protectedRoots : [], r)
78
+ && insideSandboxRoots(Array.isArray(policy.readableRoots) ? policy.readableRoots : [], r);
52
79
  }
53
80
  const { workspace, repoRoot } = configured(ctx);
54
81
  return insideLegacyRoots([workspace, repoRoot, process.cwd(), "/tmp"], r);
55
82
  }
56
83
 
84
+ /**
85
+ * Protected descendants of one search root, as normalized relative paths.
86
+ * Search tools use these both as ripgrep exclusions and as a defensive output
87
+ * filter; actual reads still cross the native sandbox boundary.
88
+ */
89
+ export function protectedRelativePaths(directory, options = {}) {
90
+ const ctx = options.ctx;
91
+ const policy = resolveSandboxPolicy(ctx ?? readToolRuntime(), options.sandboxPolicy);
92
+ if (!policy || !Array.isArray(policy.protectedRoots)) return [];
93
+ const root = resolve(directory);
94
+ const out = new Set();
95
+ for (const protectedRoot of normalizeRoots(policy.protectedRoots)) {
96
+ const rel = relative(root, protectedRoot);
97
+ if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) out.add(rel);
98
+ }
99
+ return [...out].sort();
100
+ }
101
+
57
102
  // Sandbox roots also enforce realpath containment so a symlink inside an
58
103
  // allowed root cannot escape to a target outside the policy.
59
104
  function insideSandboxRoots(roots, target) {
@@ -63,6 +108,16 @@ function insideSandboxRoots(roots, target) {
63
108
  && allowedRoots.some((root) => isInsidePath(root, real));
64
109
  }
65
110
 
111
+ // A protected root rejects either spelling: the lexical request and its
112
+ // existing/nearest-existing realpath. This closes symlink aliases in both
113
+ // directions without weakening ordinary readable/writable root checks.
114
+ function insideProtectedRoots(roots, target) {
115
+ const protectedRoots = normalizeRoots(roots);
116
+ const candidates = [...new Set([resolve(target), realTargetPath(target)])];
117
+ return candidates.some((candidate) =>
118
+ protectedRoots.some((root) => isInsidePath(root, candidate)));
119
+ }
120
+
66
121
  // Without a sandbox policy, keep the historical literal containment check —
67
122
  // symlinks out of the workspace (npm link et al.) stay usable by default.
68
123
  function insideLegacyRoots(roots, target) {
@@ -70,6 +125,11 @@ function insideLegacyRoots(roots, target) {
70
125
  return allowedRoots.some((root) => isInsidePath(root, target));
71
126
  }
72
127
 
128
+ function insideLexicalRoots(roots, target) {
129
+ const lexicalRoots = [...new Set(roots.filter(Boolean).map((path) => resolve(path)))];
130
+ return lexicalRoots.some((root) => isInsidePath(root, target));
131
+ }
132
+
73
133
  function normalizeRoots(paths) {
74
134
  const out = new Set();
75
135
  for (const path of paths.filter(Boolean)) {
@@ -109,6 +169,11 @@ function sandboxDeniesWrite(policy, target, ctx) {
109
169
  patterns.some((pattern) => denyWritePatternMatches(policy, pattern, candidate, ctx)));
110
170
  }
111
171
 
172
+ function sandboxLexicallyDeniesWrite(policy, target, ctx) {
173
+ const patterns = Array.isArray(policy.denyWrite) ? policy.denyWrite : [];
174
+ return patterns.some((pattern) => denyWritePatternMatches(policy, pattern, resolve(target), ctx));
175
+ }
176
+
112
177
  function denyWritePatternMatches(policy, pattern, target, ctx) {
113
178
  if (!pattern || typeof pattern !== "string") return false;
114
179
  const normalizedPattern = normalizeMatchPath(pattern);
@@ -0,0 +1,188 @@
1
+ // @ts-check
2
+
3
+ import { types as nodeUtilTypes } from "node:util";
4
+
5
+ import { startPreparedProcess } from "./process-runner.js";
6
+
7
+ /**
8
+ * Kernel-local structural controller seam. The typed public interface lives in
9
+ * runtime-adapter; this package deliberately has no workspace dependencies.
10
+ *
11
+ * @typedef {Object} ProcessJobsController
12
+ * @property {(request: {
13
+ * tool: "Exec"|"Bash",
14
+ * prepared: import("../../sandbox-seam.js").PreparedSandboxCommand,
15
+ * summary: string,
16
+ * timeoutMs?: number,
17
+ * maxOutputChars?: number,
18
+ * launch: (options?: {timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, onStdout?: (chunk: Buffer) => void, onStderr?: (chunk: Buffer) => void}) => ReturnType<typeof startPreparedProcess>,
19
+ * }) => Promise<{jobId: string, state: "queued"|"starting"|"running", startedAt: string|null}>} start
20
+ */
21
+
22
+ /**
23
+ * Transfer one prepared command to the injected host controller. From the
24
+ * instant `start()` is invoked, the controller owns cleanup on every path.
25
+ *
26
+ * @param {{
27
+ * controller: ProcessJobsController,
28
+ * tool: "Exec"|"Bash",
29
+ * prepared: import("../../sandbox-seam.js").PreparedSandboxCommand,
30
+ * summary: string,
31
+ * timeoutMs?: number,
32
+ * maxOutputChars?: number,
33
+ * startedAt: number,
34
+ * failed: (text: string, code: string, startedAt: number) => any,
35
+ * }} input
36
+ */
37
+ export async function handOffProcessJob({
38
+ controller,
39
+ tool,
40
+ prepared,
41
+ summary,
42
+ timeoutMs,
43
+ maxOutputChars,
44
+ startedAt,
45
+ failed,
46
+ }) {
47
+ const ownedPrepared = withCleanupOnce(prepared);
48
+ const boundEnvironment = mergedProcessEnvironment(ownedPrepared.env);
49
+ let launched = false;
50
+ try {
51
+ const result = await controller.start({
52
+ tool,
53
+ prepared: ownedPrepared,
54
+ summary,
55
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
56
+ ...(maxOutputChars === undefined ? {} : { maxOutputChars }),
57
+ launch(options = {}) {
58
+ if (launched) throw new Error("Process-job prepared command was already launched.");
59
+ launched = true;
60
+ return startPreparedProcess({ ...ownedPrepared, env: boundEnvironment }, {
61
+ ...options,
62
+ waitForProcessGroup: true,
63
+ exactEnvironment: true,
64
+ });
65
+ },
66
+ });
67
+ if (!validProcessJobStartResult(result)) {
68
+ if (!launched) {
69
+ try {
70
+ await ownedPrepared.cleanup?.();
71
+ } catch {
72
+ return failed(
73
+ `Error: ${PUBLIC_BACKGROUND_START_FAILURES.process_job_cleanup_incomplete}`,
74
+ "process_job_cleanup_incomplete",
75
+ startedAt,
76
+ );
77
+ }
78
+ }
79
+ return failed("Error: Process-job controller returned an invalid start result.", "process_job_controller_invalid", startedAt);
80
+ }
81
+ const payload = {
82
+ job_id: result.jobId,
83
+ state: result.state,
84
+ started_at: result.startedAt,
85
+ };
86
+ return {
87
+ text: JSON.stringify(payload),
88
+ outcome: {
89
+ status: "ok",
90
+ code: "background_started",
91
+ retryable: false,
92
+ attempts: 1,
93
+ durationMs: Date.now() - startedAt,
94
+ bytes: 0,
95
+ truncated: false,
96
+ exitCode: null,
97
+ signal: null,
98
+ timedOut: false,
99
+ background: true,
100
+ ...payload,
101
+ },
102
+ error: false,
103
+ };
104
+ } catch (error) {
105
+ const failure = publicBackgroundStartFailure(error);
106
+ return failed(
107
+ `Error: ${failure.message}`,
108
+ failure.code,
109
+ startedAt,
110
+ );
111
+ }
112
+ }
113
+
114
+ const PUBLIC_BACKGROUND_START_FAILURES = Object.freeze({
115
+ background_unsupported: "Background process jobs are unsupported for this tool call.",
116
+ background_unsupported_channel: "Background process jobs are unsupported for this channel.",
117
+ process_job_disabled: "Process jobs are disabled.",
118
+ process_job_controller_unavailable: "The process-job controller is unavailable.",
119
+ process_job_platform_unsupported: "Process jobs are unsupported on this platform.",
120
+ process_job_not_found: "The process job was not found.",
121
+ process_job_conflict: "The process job is no longer in the required state.",
122
+ process_job_capacity: "Process-job capacity is full.",
123
+ process_job_conversation_capacity: "This conversation reached its process-job capacity.",
124
+ process_job_queue_full: "The process-job queue is full.",
125
+ process_job_queue_expired: "The process job expired before launch.",
126
+ process_job_chain_depth_exceeded: "The process-job chain-depth limit was reached.",
127
+ process_job_spawn_failed: "The process job could not be launched.",
128
+ process_job_failed: "The process job failed.",
129
+ process_job_timeout: "The process job exceeded its runtime limit.",
130
+ process_job_cancelled: "The process job was cancelled.",
131
+ process_job_agent_restarted: "The process job was interrupted by an agent restart.",
132
+ process_job_cleanup_incomplete: "Process-job cleanup could not be confirmed.",
133
+ process_job_store_error: "Process-job storage failed.",
134
+ process_job_wake_failed: "Process-job wake delivery failed.",
135
+ process_job_response_too_large: "The process-job response exceeded its size limit.",
136
+ process_job_invalid: "The process-job request is invalid.",
137
+ });
138
+
139
+ function publicBackgroundStartFailure(error) {
140
+ let code = "process_job_controller_unavailable";
141
+ try {
142
+ if (typeof error === "object" && error !== null && !nodeUtilTypes.isProxy(error)) {
143
+ const descriptor = Object.getOwnPropertyDescriptor(error, "code");
144
+ if (descriptor !== undefined
145
+ && Object.prototype.hasOwnProperty.call(descriptor, "value")
146
+ && typeof descriptor.value === "string"
147
+ && Object.prototype.hasOwnProperty.call(PUBLIC_BACKGROUND_START_FAILURES, descriptor.value)) {
148
+ code = descriptor.value;
149
+ }
150
+ }
151
+ } catch {
152
+ // Proxies and revoked proxies are hostile input at this boundary.
153
+ }
154
+ return { code, message: PUBLIC_BACKGROUND_START_FAILURES[code] };
155
+ }
156
+
157
+ function mergedProcessEnvironment(overrides = {}) {
158
+ const environment = { ...process.env };
159
+ for (const [name, value] of Object.entries(overrides)) {
160
+ if (value === undefined) delete environment[name];
161
+ else environment[name] = value;
162
+ }
163
+ return environment;
164
+ }
165
+
166
+ function withCleanupOnce(prepared) {
167
+ if (typeof prepared.cleanup !== "function") return prepared;
168
+ /** @type {Promise<void>|undefined} */
169
+ let cleanup;
170
+ const original = prepared.cleanup;
171
+ return {
172
+ ...prepared,
173
+ cleanup: async () => {
174
+ if (!cleanup) cleanup = Promise.resolve().then(() => original());
175
+ await cleanup;
176
+ },
177
+ };
178
+ }
179
+
180
+ function validProcessJobStartResult(value) {
181
+ if (!value || typeof value !== "object") return false;
182
+ if (typeof value.jobId !== "string" || value.jobId.trim().length === 0 || value.jobId.length > 256) return false;
183
+ if (value.state !== "queued" && value.state !== "starting" && value.state !== "running") return false;
184
+ if (value.startedAt === null) return true;
185
+ if (typeof value.startedAt !== "string") return false;
186
+ const timestamp = Date.parse(value.startedAt);
187
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value.startedAt;
188
+ }