@fungi.computer/truffle 0.2.2 → 0.3.1

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 (68) hide show
  1. package/dist/private-files-patch.js +3 -17
  2. package/dist/private-invocation-computer.d.ts +224 -12
  3. package/dist/private-invocation-computer.js +162 -22
  4. package/dist/private-invocation-files.d.ts +3 -2
  5. package/dist/private-invocation-files.js +22 -3
  6. package/dist/private-local-agent-control.d.ts +2 -2
  7. package/dist/private-local-bwrap.d.ts +9 -7
  8. package/dist/private-local-bwrap.js +26 -21
  9. package/dist/private-local-computer-processes.d.ts +15 -0
  10. package/dist/private-local-computer-processes.js +13 -0
  11. package/dist/private-local-containment.d.ts +15 -13
  12. package/dist/private-local-containment.js +49 -65
  13. package/dist/private-local-delegation.d.ts +3 -3
  14. package/dist/private-local-delegation.js +32 -19
  15. package/dist/private-local-exec-review.d.ts +12 -0
  16. package/dist/private-local-exec-review.js +62 -0
  17. package/dist/private-local-exec-rules.d.ts +16 -0
  18. package/dist/private-local-exec-rules.js +441 -0
  19. package/dist/private-local-git-exclude.js +25 -15
  20. package/dist/private-local-linux-sandbox.d.ts +5 -5
  21. package/dist/private-local-linux-sandbox.js +63 -30
  22. package/dist/private-local-mail.d.ts +2 -2
  23. package/dist/private-local-mesh-links.d.ts +4 -1
  24. package/dist/private-local-mesh-links.js +50 -3
  25. package/dist/private-local-native-digests.js +1 -1
  26. package/dist/private-local-native-install-error.d.ts +2 -0
  27. package/dist/private-local-native-install-error.js +10 -0
  28. package/dist/private-local-native-staging.d.ts +7 -8
  29. package/dist/private-local-native-staging.js +132 -43
  30. package/dist/private-local-permission-mode.d.ts +10 -0
  31. package/dist/private-local-permission-mode.js +18 -0
  32. package/dist/private-local-runtime-host.d.ts +28 -5
  33. package/dist/private-local-runtime-host.js +404 -66
  34. package/dist/private-local-sandbox-policy.d.ts +30 -11
  35. package/dist/private-local-sandbox-policy.js +223 -373
  36. package/dist/private-local-sandbox.d.ts +30 -25
  37. package/dist/private-local-sandbox.js +190 -100
  38. package/dist/private-local-seatbelt.d.ts +7 -13
  39. package/dist/private-local-seatbelt.js +55 -32
  40. package/dist/private-local-service-contract.d.ts +5 -4
  41. package/dist/private-local-service-discovery.d.ts +19 -7
  42. package/dist/private-local-service-discovery.js +4 -3
  43. package/dist/private-local-service-listener.js +40 -2
  44. package/dist/private-local-service-log.d.ts +18 -0
  45. package/dist/private-local-service-log.js +6 -0
  46. package/dist/private-local-service-owner.d.ts +18 -10
  47. package/dist/private-local-service-owner.js +119 -25
  48. package/dist/private-local-service.js +30 -3
  49. package/dist/private-local-session-discovery.d.ts +2 -1
  50. package/dist/private-local-session-discovery.js +18 -6
  51. package/dist/private-mcp-form-contract.d.ts +10 -1
  52. package/dist/private-mcp-form-contract.js +10 -1
  53. package/dist/private-mcp-form-session.d.ts +39 -3
  54. package/dist/private-mcp-form-session.js +28 -5
  55. package/dist/private-oyster-mcp-auth.d.ts +1 -1
  56. package/dist/private-oyster-mcp-auth.js +139 -7
  57. package/dist/private-provider-rejection.js +1 -1
  58. package/dist/seatbelt/safe-base.sbpl +2 -2
  59. package/native/bin/bwrap-build-source/Dockerfile +2 -0
  60. package/native/bin/bwrap-build-source/README.md +6 -6
  61. package/native/bin/bwrap-build-source/pin.mjs +2 -2
  62. package/native/bin/bwrap-build-source/record.mjs +12 -1
  63. package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +10 -7
  64. package/native/bin/bwrap-build.json +3 -2
  65. package/native/bin/fungi-apply-patch +0 -0
  66. package/native/bin/fungi-seccomp-workspace.bpf +0 -0
  67. package/native/bin/fungi-seccomp-workspace.bpf.sha256 +1 -0
  68. package/package.json +23 -17
@@ -1,24 +1,10 @@
1
1
  import { access, constants } from "node:fs/promises";
2
- import { fileURLToPath } from "node:url";
3
- import { createRequire } from "node:module";
4
2
  import process from "node:process";
5
3
  import * as ChildProcess from "effect/process/ChildProcess";
6
4
  import { parsePatch } from "diff";
7
5
  import * as Stream from "effect/Stream";
8
6
  import { runPrivateInvocationProcess, } from "./private-invocation-computer.js";
9
- import { createNativePayloadStaging } from "./private-local-native-staging.js";
10
- function patchExecutable() {
11
- if (process.env.FUNGI_BUNDLED_CLI) {
12
- const name = `@fungi.computer/spore-native-${process.platform}-${process.arch}`;
13
- try {
14
- return createRequire(process.env.FUNGI_BUNDLED_CLI).resolve(`${name}/bin/fungi-apply-patch`);
15
- }
16
- catch (cause) {
17
- throw new Error(`Missing ${name}; reinstall Fungi with npm optional dependencies enabled`, { cause });
18
- }
19
- }
20
- return fileURLToPath(new URL("../native/bin/fungi-apply-patch", import.meta.url));
21
- }
7
+ import { createNativePayloadStaging, packagedPatchPath, } from "./private-local-native-staging.js";
22
8
  const maximumPatchBytes = 128 * 1024;
23
9
  const mismatchExitCode = 23;
24
10
  const formatExitCode = 24;
@@ -50,7 +36,7 @@ async function unifiedDiffContext(patch, read, context) {
50
36
  }
51
37
  /** Advertise only a packaged native engine; never install tools during a model call. */
52
38
  export async function createPrivateFilesPatch(root, read, sandbox) {
53
- const executable = patchExecutable();
39
+ const executable = packagedPatchPath();
54
40
  try {
55
41
  // Advertise the installed helper if present; only the verified private
56
42
  // copy is executed, so its source does not need an execute bit.
@@ -78,7 +64,7 @@ export async function createPrivateFilesPatch(root, read, sandbox) {
78
64
  if (executableToRun === undefined)
79
65
  throw new Error("Native Files helper was not staged");
80
66
  const env = { ...process.env, ...launch?.environment };
81
- if (launch !== undefined && !("bareNative" in launch)) {
67
+ if (launch !== undefined) {
82
68
  delete env.XDG_RUNTIME_DIR;
83
69
  delete env.DBUS_SESSION_BUS_ADDRESS;
84
70
  }
@@ -1,17 +1,72 @@
1
1
  import * as ChildProcess from "effect/process/ChildProcess";
2
2
  import type { KnapsackComputer } from "@fungi.computer/knapsack";
3
+ import { type InvocationContext } from "@fungi.computer/mycelium";
3
4
  import { z } from "zod";
4
- import { type PrivateLocalProcessScope } from "./private-local-computer-processes.js";
5
+ import { backgroundExecInput, backgroundExecOutput, type PrivateLocalProcessScope } from "./private-local-computer-processes.js";
5
6
  import type { SandboxCommand } from "./private-local-containment.js";
6
- /** A bare fallback for the native Files engine still names only staged bytes. */
7
- export type BareNativeCommand = SandboxCommand & Readonly<{
8
- bareNative: true;
9
- }>;
10
- /** Only Files supplies a helper; generic exec can still fall back to its argv. */
11
- export type LocalSandboxLaunch = {
12
- (cwd: string, argv: readonly [string, ...string[]]): SandboxCommand | undefined;
13
- (cwd: string, argv: readonly [string, ...string[]], readOnlyHelperFile: string): SandboxCommand | BareNativeCommand;
14
- };
7
+ import type { LocalPermissionMode } from "./private-local-permission-mode.js";
8
+ import type { PendingApproval, ApprovalRefusal } from "@fungi.computer/approvals";
9
+ /** A Full-access launch is bare; sandboxed launches always supply a command or refuse. */
10
+ export type LocalSandboxLaunch = (cwd: string, argv: readonly [string, ...string[]], readOnlyHelperFile?: string) => SandboxCommand | undefined;
11
+ /**
12
+ * `command` (shell) and `executable`/`args` (direct) are mutually exclusive:
13
+ * each branch is a strict object, so supplying both or neither fails every
14
+ * branch and the union is refused.
15
+ */
16
+ declare const execInput: z.ZodUnion<readonly [z.ZodObject<{
17
+ command: z.ZodString;
18
+ stdin: z.ZodOptional<z.ZodString>;
19
+ operation: z.ZodDefault<z.ZodLiteral<"run">>;
20
+ escape: z.ZodOptional<z.ZodObject<{
21
+ justification: z.ZodString;
22
+ }, z.core.$strict>>;
23
+ cwd: z.ZodOptional<z.ZodString>;
24
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
25
+ }, z.core.$strict>, z.ZodObject<{
26
+ executable: z.ZodString;
27
+ args: z.ZodDefault<z.ZodArray<z.ZodString>>;
28
+ stdin: z.ZodOptional<z.ZodString>;
29
+ operation: z.ZodDefault<z.ZodLiteral<"run">>;
30
+ escape: z.ZodOptional<z.ZodObject<{
31
+ justification: z.ZodString;
32
+ }, z.core.$strict>>;
33
+ cwd: z.ZodOptional<z.ZodString>;
34
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
35
+ }, z.core.$strict>]>;
36
+ declare const completedOutput: z.ZodObject<{
37
+ exitCode: z.ZodNumber;
38
+ stdout: z.ZodString;
39
+ stderr: z.ZodString;
40
+ truncated: z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodArray<z.ZodObject<{
41
+ stream: z.ZodEnum<{
42
+ stdout: "stdout";
43
+ stderr: "stderr";
44
+ }>;
45
+ keptBytes: z.ZodNumber;
46
+ totalBytes: z.ZodNumber;
47
+ }, z.core.$strict>>]>;
48
+ status: z.ZodEnum<{
49
+ failed: "failed";
50
+ completed: "completed";
51
+ }>;
52
+ }, z.core.$strict>;
53
+ declare const deadlineOutput: z.ZodObject<{
54
+ exitCode: z.ZodNumber;
55
+ stdout: z.ZodString;
56
+ stderr: z.ZodString;
57
+ truncated: z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodArray<z.ZodObject<{
58
+ stream: z.ZodEnum<{
59
+ stdout: "stdout";
60
+ stderr: "stderr";
61
+ }>;
62
+ keptBytes: z.ZodNumber;
63
+ totalBytes: z.ZodNumber;
64
+ }, z.core.$strict>>]>;
65
+ status: z.ZodLiteral<"deadline_exceeded">;
66
+ timeoutMs: z.ZodNumber;
67
+ elapsedMs: z.ZodNumber;
68
+ message: z.ZodString;
69
+ }, z.core.$strict>;
15
70
  declare const execOutput: z.ZodDiscriminatedUnion<[z.ZodObject<{
16
71
  exitCode: z.ZodNumber;
17
72
  stdout: z.ZodString;
@@ -28,6 +83,15 @@ declare const execOutput: z.ZodDiscriminatedUnion<[z.ZodObject<{
28
83
  failed: "failed";
29
84
  completed: "completed";
30
85
  }>;
86
+ sandbox: z.ZodObject<{
87
+ mode: z.ZodEnum<{
88
+ "read-only": "read-only";
89
+ workspace: "workspace";
90
+ "full-access": "full-access";
91
+ }>;
92
+ likelyDenied: z.ZodBoolean;
93
+ ranOutsideSandbox: z.ZodDefault<z.ZodBoolean>;
94
+ }, z.core.$strict>;
31
95
  }, z.core.$strict>, z.ZodObject<{
32
96
  exitCode: z.ZodNumber;
33
97
  stdout: z.ZodString;
@@ -44,15 +108,161 @@ declare const execOutput: z.ZodDiscriminatedUnion<[z.ZodObject<{
44
108
  timeoutMs: z.ZodNumber;
45
109
  elapsedMs: z.ZodNumber;
46
110
  message: z.ZodString;
111
+ sandbox: z.ZodObject<{
112
+ mode: z.ZodEnum<{
113
+ "read-only": "read-only";
114
+ workspace: "workspace";
115
+ "full-access": "full-access";
116
+ }>;
117
+ likelyDenied: z.ZodBoolean;
118
+ ranOutsideSandbox: z.ZodDefault<z.ZodBoolean>;
119
+ }, z.core.$strict>;
47
120
  }, z.core.$strict>], "status">;
121
+ export declare const shellOutput: z.ZodUnion<readonly [z.ZodDiscriminatedUnion<[z.ZodObject<{
122
+ exitCode: z.ZodNumber;
123
+ stdout: z.ZodString;
124
+ stderr: z.ZodString;
125
+ truncated: z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodArray<z.ZodObject<{
126
+ stream: z.ZodEnum<{
127
+ stdout: "stdout";
128
+ stderr: "stderr";
129
+ }>;
130
+ keptBytes: z.ZodNumber;
131
+ totalBytes: z.ZodNumber;
132
+ }, z.core.$strict>>]>;
133
+ status: z.ZodEnum<{
134
+ failed: "failed";
135
+ completed: "completed";
136
+ }>;
137
+ sandbox: z.ZodObject<{
138
+ mode: z.ZodEnum<{
139
+ "read-only": "read-only";
140
+ workspace: "workspace";
141
+ "full-access": "full-access";
142
+ }>;
143
+ likelyDenied: z.ZodBoolean;
144
+ ranOutsideSandbox: z.ZodDefault<z.ZodBoolean>;
145
+ }, z.core.$strict>;
146
+ }, z.core.$strict>, z.ZodObject<{
147
+ exitCode: z.ZodNumber;
148
+ stdout: z.ZodString;
149
+ stderr: z.ZodString;
150
+ truncated: z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodArray<z.ZodObject<{
151
+ stream: z.ZodEnum<{
152
+ stdout: "stdout";
153
+ stderr: "stderr";
154
+ }>;
155
+ keptBytes: z.ZodNumber;
156
+ totalBytes: z.ZodNumber;
157
+ }, z.core.$strict>>]>;
158
+ status: z.ZodLiteral<"deadline_exceeded">;
159
+ timeoutMs: z.ZodNumber;
160
+ elapsedMs: z.ZodNumber;
161
+ message: z.ZodString;
162
+ sandbox: z.ZodObject<{
163
+ mode: z.ZodEnum<{
164
+ "read-only": "read-only";
165
+ workspace: "workspace";
166
+ "full-access": "full-access";
167
+ }>;
168
+ likelyDenied: z.ZodBoolean;
169
+ ranOutsideSandbox: z.ZodDefault<z.ZodBoolean>;
170
+ }, z.core.$strict>;
171
+ }, z.core.$strict>], "status">, z.ZodUnion<readonly [z.ZodObject<{
172
+ process: z.ZodObject<{
173
+ id: z.ZodString & z.ZodType<import("@fungi.computer/cairn").CairnId<"local-computer-process", "process", 1>, string, z.core.$ZodTypeInternals<import("@fungi.computer/cairn").CairnId<"local-computer-process", "process", 1>, string>>;
174
+ command: z.ZodString;
175
+ cwd: z.ZodString;
176
+ state: z.ZodEnum<{
177
+ running: "running";
178
+ exited: "exited";
179
+ }>;
180
+ exitCode: z.ZodNullable<z.ZodNumber>;
181
+ signal: z.ZodNullable<z.ZodString>;
182
+ startedAt: z.ZodNumber;
183
+ endedAt: z.ZodNullable<z.ZodNumber>;
184
+ logPath: z.ZodString;
185
+ logBytes: z.ZodNumber;
186
+ logStartOffset: z.ZodNumber;
187
+ logTruncated: z.ZodBoolean;
188
+ }, z.core.$strict>;
189
+ offset: z.ZodNumber;
190
+ nextOffset: z.ZodNumber;
191
+ text: z.ZodString;
192
+ complete: z.ZodBoolean;
193
+ timedOut: z.ZodBoolean;
194
+ signalled: z.ZodOptional<z.ZodBoolean>;
195
+ sandbox: z.ZodOptional<z.ZodObject<{
196
+ mode: z.ZodEnum<{
197
+ "read-only": "read-only";
198
+ workspace: "workspace";
199
+ "full-access": "full-access";
200
+ }>;
201
+ likelyDenied: z.ZodBoolean;
202
+ ranOutsideSandbox: z.ZodBoolean;
203
+ }, z.core.$strict>>;
204
+ }, z.core.$strict>, z.ZodObject<{
205
+ processes: z.ZodArray<z.ZodObject<{
206
+ id: z.ZodString & z.ZodType<import("@fungi.computer/cairn").CairnId<"local-computer-process", "process", 1>, string, z.core.$ZodTypeInternals<import("@fungi.computer/cairn").CairnId<"local-computer-process", "process", 1>, string>>;
207
+ command: z.ZodString;
208
+ cwd: z.ZodString;
209
+ state: z.ZodEnum<{
210
+ running: "running";
211
+ exited: "exited";
212
+ }>;
213
+ exitCode: z.ZodNullable<z.ZodNumber>;
214
+ signal: z.ZodNullable<z.ZodString>;
215
+ startedAt: z.ZodNumber;
216
+ endedAt: z.ZodNullable<z.ZodNumber>;
217
+ logPath: z.ZodString;
218
+ logBytes: z.ZodNumber;
219
+ logStartOffset: z.ZodNumber;
220
+ logTruncated: z.ZodBoolean;
221
+ }, z.core.$strict>>;
222
+ }, z.core.$strict>]>, z.ZodUnion<readonly [z.ZodObject<{
223
+ kind: z.ZodLiteral<"pending_approval">;
224
+ recordId: z.ZodString;
225
+ description: z.ZodObject<{
226
+ title: z.ZodString;
227
+ inputBytes: z.ZodNumber;
228
+ inputSha256: z.ZodString;
229
+ }, z.core.$strict>;
230
+ }, z.core.$strict>, z.ZodObject<{
231
+ kind: z.ZodLiteral<"refused">;
232
+ reason: z.ZodString;
233
+ }, z.core.$strict>]>]>;
234
+ /** A host-attested stage door; the Computer cannot mint approval identity itself. */
235
+ export type LocalExecEscapeStage = (input: Readonly<{
236
+ request: z.output<typeof execInput> | Extract<z.output<typeof backgroundExecInput>, {
237
+ operation: "start";
238
+ }>;
239
+ cwd: string;
240
+ mode: Exclude<LocalPermissionMode, "full-access">;
241
+ denialLine: string | null;
242
+ context: InvocationContext;
243
+ executionPath: string;
244
+ }>) => Promise<PendingApproval | ApprovalRefusal>;
245
+ /** Host-only prefix policy: the model has no rule-management door. */
246
+ export type LocalExecPolicyRun = (input: Parameters<LocalExecEscapeStage>[0]) => Promise<z.output<typeof shellOutput> | undefined>;
247
+ /** Run the recorded command through the normal bounded process door, without a sandbox.
248
+ * The host supplies the captured PATH and canonical cwd; the record supplies the
249
+ * exact original request. No model invocation can call this door directly. */
250
+ export declare function dispatchApprovedLocalExec(input: unknown, cwd: string, executionPath: string, home: string, processes: PrivateLocalProcessScope, mode: LocalPermissionMode): Promise<ExecOutput | z.output<typeof backgroundExecOutput>>;
48
251
  export type ExecOutput = z.output<typeof execOutput>;
252
+ export type ProcessOutput = z.output<typeof completedOutput> | z.output<typeof deadlineOutput>;
253
+ /**
254
+ * A hint to the model, not evidence that an operation was refused. Only the
255
+ * sandboxed launch can carry this hint; common shell usage errors and missing
256
+ * commands are not sandbox denials, even if their stderr mentions permissions.
257
+ */
258
+ export declare function likelySandboxDenied(mode: LocalPermissionMode, result: ProcessOutput): boolean;
49
259
  /** Shared bounded native process owner; callers supply argv/stdin, never shell interpolation. */
50
260
  export declare function runPrivateInvocationProcess(processCommand: ChildProcess.Command, signal: AbortSignal, timeoutMs: number | undefined, describe?: Readonly<{
51
261
  /** The timeout actually requested, before any clamp; defaults to `timeoutMs`. */
52
262
  requestedMs?: number;
53
263
  /** The real command text, for the timeout message; omitted when not meaningful (e.g. a native helper). */
54
264
  describeCommand?: () => string;
55
- }>): Promise<ExecOutput>;
265
+ }>): Promise<ProcessOutput>;
56
266
  /**
57
267
  * Construct one private invocation-root Computer: bounded shell exec, plus the
58
268
  * background-process operations of `processes` when the host supplies a scope.
@@ -61,5 +271,7 @@ export declare function createPrivateInvocationComputer(invocationRoot: string,
61
271
  /** Selected host home; standalone callers capture it before any await. */
62
272
  home?: string,
63
273
  /** Native Local always supplies its live per-Agent admission callback. */
64
- sandbox?: LocalSandboxLaunch): Promise<KnapsackComputer>;
274
+ sandbox?: LocalSandboxLaunch,
275
+ /** The live Agent mode, captured once at the command boundary. */
276
+ currentMode?: () => LocalPermissionMode, stageEscape?: LocalExecEscapeStage, runPolicy?: LocalExecPolicyRun): Promise<KnapsackComputer>;
65
277
  export {};
@@ -46,6 +46,9 @@ const capabilityNameSchema = z
46
46
  .regex(/^[A-Za-z][A-Za-z0-9_]*$/);
47
47
  const runCommonFields = {
48
48
  operation: z.literal("run").default("run"),
49
+ escape: z
50
+ .strictObject({ justification: z.string().min(1).max(2048) })
51
+ .optional(),
49
52
  cwd: launchCwdSchema.optional(),
50
53
  // Accepted up to the full invocation deadline and silently clamped to
51
54
  // maximumCommandTimeoutMs (see runCommandAtInvocationBoundary); rejecting
@@ -93,21 +96,68 @@ const commandOutputFields = {
93
96
  stderr: z.string(),
94
97
  truncated: z.union([z.literal(false), z.array(execTruncation).min(1)]),
95
98
  };
99
+ const sandboxResult = z.strictObject({
100
+ mode: z.enum(["full-access", "workspace", "read-only"]),
101
+ likelyDenied: z.boolean(),
102
+ ranOutsideSandbox: z.boolean().default(false),
103
+ });
104
+ const completedOutput = z.strictObject({
105
+ status: z.enum(["completed", "failed"]),
106
+ ...commandOutputFields,
107
+ });
108
+ const deadlineOutput = z.strictObject({
109
+ status: z.literal("deadline_exceeded"),
110
+ timeoutMs: z.number().int().positive(),
111
+ elapsedMs: z.number().nonnegative(),
112
+ message: z.string(),
113
+ ...commandOutputFields,
114
+ });
96
115
  const execOutput = z.discriminatedUnion("status", [
97
- z.strictObject({
98
- status: z.enum(["completed", "failed"]),
99
- ...commandOutputFields,
100
- }),
101
- z.strictObject({
102
- status: z.literal("deadline_exceeded"),
103
- timeoutMs: z.number().int().positive(),
104
- elapsedMs: z.number().nonnegative(),
105
- message: z.string(),
106
- ...commandOutputFields,
107
- }),
116
+ completedOutput.extend({ sandbox: sandboxResult }),
117
+ deadlineOutput.extend({ sandbox: sandboxResult }),
108
118
  ]);
109
119
  const shellInput = z.union([execInput, backgroundExecInput]);
110
- const shellOutput = z.union([execOutput, backgroundExecOutput]);
120
+ const pendingOutput = z.strictObject({
121
+ kind: z.literal("pending_approval"),
122
+ recordId: z.string(),
123
+ description: z.strictObject({
124
+ title: z.string(),
125
+ inputBytes: z.number(),
126
+ inputSha256: z.string(),
127
+ }),
128
+ });
129
+ const refusalOutput = z.strictObject({
130
+ kind: z.literal("refused"),
131
+ reason: z.string(),
132
+ });
133
+ const approvalOutput = z.union([pendingOutput, refusalOutput]);
134
+ export const shellOutput = z.union([
135
+ execOutput,
136
+ backgroundExecOutput,
137
+ approvalOutput,
138
+ ]);
139
+ /** Run the recorded command through the normal bounded process door, without a sandbox.
140
+ * The host supplies the captured PATH and canonical cwd; the record supplies the
141
+ * exact original request. No model invocation can call this door directly. */
142
+ export async function dispatchApprovedLocalExec(input, cwd, executionPath, home, processes, mode) {
143
+ const request = shellInput.parse(input);
144
+ if (request.operation === "run") {
145
+ const result = await runCommandAtInvocationBoundary(request, cwd, executionPath, home, new AbortController().signal);
146
+ return execOutput.parse({
147
+ ...result,
148
+ sandbox: { mode, likelyDenied: false, ranOutsideSandbox: true },
149
+ });
150
+ }
151
+ if (request.operation !== "start")
152
+ throw new Error("Only recorded command launches may be dispatched");
153
+ const output = await callBackgroundExec(processes, request, () => Promise.resolve(cwd), executionPath, new AbortController().signal);
154
+ return "text" in output
155
+ ? {
156
+ ...output,
157
+ sandbox: { mode, likelyDenied: false, ranOutsideSandbox: true },
158
+ }
159
+ : output;
160
+ }
111
161
  function computerCapabilities(root, processes) {
112
162
  return jsonValueSchema.parse([
113
163
  {
@@ -116,9 +166,11 @@ function computerCapabilities(root, processes) {
116
166
  kind: "runtime",
117
167
  // A filesystem path has no place in the bounded discovery summary.
118
168
  // Publish the exact root in the input schema, without truncating it.
119
- description: `Shell, terminal, git, builds. cwd uses the root in the input schema. ~/$HOME use home. Or {executable,args}; stdin once, closed. exec kills its group at call end; nohup/& die. timeoutMs default ${String(defaultCommandTimeoutMs)}, max ${String(maximumCommandTimeoutMs)} ms; invocation ${String(invocationDeadlineMs)} ms; deadline returns partial output. Streams keep 64 KiB, then a [stdout|stderr truncated] line; redirect to a file or head/tail.${processes === undefined ? "" : ' Jobs: {operation:"start",command} or executable+args returns process.id; poll/wait: state, exitCode, tail. wait <=60000 ms; timedOut: wait expired. Cancelled wait keeps jobs. maxBytes <=64 KiB, offset/nextOffset; keeps last 8 MiB (logStartOffset). kill stops group; list: Session jobs. Delete/close stop jobs. Use wait, not sleep.'}`,
169
+ description: `Shell, terminal, git, builds. cwd uses the root in the input schema. ~/$HOME use home. Or {executable,args}; stdin once, closed. exec kills its group at call end; nohup/& die. timeoutMs default ${String(defaultCommandTimeoutMs)}, max ${String(maximumCommandTimeoutMs)} ms; invocation ${String(invocationDeadlineMs)} ms; deadline returns partial output. Streams keep 64 KiB, then a [stdout|stderr truncated] line; redirect to a file or head/tail. Only after a sandbox denial, retry with escape: {justification} to request human approval; no command runs until approval.${processes === undefined ? "" : ' Jobs: {operation:"start",command} or executable+args returns process.id; poll/wait: state, exitCode, tail. wait <=60000 ms; timedOut: wait expired. Cancelled wait keeps jobs. maxBytes <=64 KiB, offset/nextOffset; keeps last 8 MiB (logStartOffset). kill stops group; list: Session jobs. Delete/close stop jobs. Use wait, not sleep.'}`,
120
170
  parameters: structuredClone(z.toJSONSchema((processes === undefined ? execInput : shellInput).describe(`Computer root: ${root}. cwd must be absolute and remain inside this root; omission starts at the root.`), { io: "input" })),
121
- output: structuredClone(z.toJSONSchema(processes === undefined ? execOutput : shellOutput)),
171
+ output: structuredClone(z.toJSONSchema(processes === undefined
172
+ ? z.union([execOutput, approvalOutput])
173
+ : shellOutput)),
122
174
  },
123
175
  ]);
124
176
  }
@@ -134,6 +186,24 @@ function commandTimeoutMessage(input) {
134
186
  : "";
135
187
  return `Command exceeded timeoutMs=${String(input.timeoutMs)} ms${clampNote}. Command: ${shortCommandLabel(input.command)}. Rerun with a larger timeoutMs, up to ${String(maximumCommandTimeoutMs)} ms; split longer work into separate tool calls.`;
136
188
  }
189
+ /** Process stderr has no structured errno; classify only this display hint. */
190
+ function classifySandboxDiagnostic(diagnostic) {
191
+ return /\b(?:EROFS|EPERM|EBUSY|read-only file system|operation not permitted|permission denied|device or resource busy|seccomp|sandbox|landlock)\b/iu.test(diagnostic)
192
+ ? "denial"
193
+ : "other";
194
+ }
195
+ /**
196
+ * A hint to the model, not evidence that an operation was refused. Only the
197
+ * sandboxed launch can carry this hint; common shell usage errors and missing
198
+ * commands are not sandbox denials, even if their stderr mentions permissions.
199
+ */
200
+ export function likelySandboxDenied(mode, result) {
201
+ if (mode === "full-access" ||
202
+ result.exitCode === 0 ||
203
+ [2, 126, 127].includes(result.exitCode))
204
+ return false;
205
+ return classifySandboxDiagnostic(result.stderr) === "denial";
206
+ }
137
207
  function isErrnoException(error) {
138
208
  return "code" in error;
139
209
  }
@@ -348,7 +418,7 @@ function buildRunCommand(request, cwd, executionPath, home, sandbox) {
348
418
  ? ["sh", "-c", request.command]
349
419
  : [request.executable, ...request.args];
350
420
  // Admission happens before spawn and is never retried after an executable
351
- // may have run. Folder validity and requireSandbox belong to the host.
421
+ // may have run. Folder validity and mode belong to the host.
352
422
  const launch = sandbox?.(cwd, argv);
353
423
  if (launch !== undefined) {
354
424
  delete env.XDG_RUNTIME_DIR;
@@ -445,11 +515,19 @@ export function runPrivateInvocationProcess(processCommand, signal, timeoutMs, d
445
515
  };
446
516
  })).pipe(Effect.provide(NodeServices.layer)), { signal });
447
517
  }
448
- async function execute(root, executionPath, home, request, context, sandbox) {
518
+ async function execute(root, executionPath, home, request, context, sandbox, mode = "full-access") {
449
519
  context.signal.throwIfAborted();
450
520
  const cwd = await resolveCwd(root, request.cwd);
451
521
  context.signal.throwIfAborted();
452
- return runCommandAtInvocationBoundary(request, cwd, executionPath, home, context.signal, sandbox);
522
+ const result = await runCommandAtInvocationBoundary(request, cwd, executionPath, home, context.signal, sandbox);
523
+ return execOutput.parse({
524
+ ...result,
525
+ sandbox: {
526
+ mode,
527
+ likelyDenied: likelySandboxDenied(mode, result),
528
+ ranOutsideSandbox: sandbox === undefined,
529
+ },
530
+ });
453
531
  }
454
532
  /**
455
533
  * Construct one private invocation-root Computer: bounded shell exec, plus the
@@ -459,9 +537,12 @@ export async function createPrivateInvocationComputer(invocationRoot, executionP
459
537
  /** Selected host home; standalone callers capture it before any await. */
460
538
  home = capturePrivateLocalConfigRoot().home,
461
539
  /** Native Local always supplies its live per-Agent admission callback. */
462
- sandbox) {
540
+ sandbox,
541
+ /** The live Agent mode, captured once at the command boundary. */
542
+ currentMode = () => "full-access", stageEscape, runPolicy) {
463
543
  const path = parsePrivateLocalExecPath(executionPath);
464
544
  const root = await realpath(resolve(invocationRoot));
545
+ let lastDenialLine = null;
465
546
  return Object.freeze({
466
547
  getCapabilities: () => Promise.resolve(computerCapabilities(root, processes)),
467
548
  call: async (capabilityId, input, context) => {
@@ -488,10 +569,69 @@ sandbox) {
488
569
  message: "Computer exec command or argument contains a NUL byte. Remove it or double the backslash in JavaScript escapes (for example, \\\\x00); no process was started.",
489
570
  },
490
571
  });
491
- if (request.operation === "run")
492
- return execute(root, path, home, request, context, sandbox);
493
- if (processes !== undefined)
494
- return jsonValueSchema.parse(await callBackgroundExec(processes, request, (requested) => resolveCwd(root, requested), path, context.signal, sandbox));
572
+ const mode = currentMode();
573
+ if (mode !== "full-access" &&
574
+ (request.operation === "run" || request.operation === "start") &&
575
+ "executable" in request &&
576
+ runPolicy !== undefined) {
577
+ const cwd = await resolveCwd(root, request.cwd);
578
+ context.signal.throwIfAborted();
579
+ const allowed = await runPolicy({
580
+ request,
581
+ cwd,
582
+ mode,
583
+ denialLine: null,
584
+ context,
585
+ executionPath: path,
586
+ });
587
+ if (allowed !== undefined)
588
+ return jsonValueSchema.parse(allowed);
589
+ }
590
+ if ((request.operation === "run" || request.operation === "start") &&
591
+ request.escape !== undefined &&
592
+ mode !== "full-access") {
593
+ if (stageEscape === undefined)
594
+ throw new Error("Local escape approval owner is unavailable; command was not run");
595
+ const cwd = await resolveCwd(root, request.cwd);
596
+ context.signal.throwIfAborted();
597
+ return approvalOutput.parse(await stageEscape({
598
+ request,
599
+ cwd,
600
+ mode,
601
+ denialLine: lastDenialLine,
602
+ context,
603
+ executionPath: path,
604
+ }));
605
+ }
606
+ if (request.operation === "run") {
607
+ const result = await execute(root, path, home, request, context, sandbox, mode);
608
+ lastDenialLine = result.sandbox.likelyDenied
609
+ ? (result.stderr
610
+ .split("\n")
611
+ .find((line) => classifySandboxDiagnostic(line) === "denial") ??
612
+ null)
613
+ : null;
614
+ return result;
615
+ }
616
+ if (processes !== undefined) {
617
+ const output = await callBackgroundExec(processes, request, (requested) => resolveCwd(root, requested), path, context.signal, sandbox);
618
+ return jsonValueSchema.parse("text" in output
619
+ ? {
620
+ ...output,
621
+ sandbox: {
622
+ mode,
623
+ likelyDenied: likelySandboxDenied(mode, {
624
+ status: "failed",
625
+ exitCode: output.process.exitCode ?? 0,
626
+ stderr: output.text,
627
+ stdout: "",
628
+ truncated: false,
629
+ }),
630
+ ranOutsideSandbox: sandbox === undefined,
631
+ },
632
+ }
633
+ : output);
634
+ }
495
635
  throw new Error(`Computer capability is unavailable: ${selected}`);
496
636
  },
497
637
  });
@@ -1,8 +1,9 @@
1
1
  import { type FilesPort } from "@fungi.computer/knapsack";
2
+ import type { LocalPermissionMode } from "./private-local-permission-mode.js";
2
3
  import type { LocalSandboxLaunch } from "./private-invocation-computer.js";
3
4
  /** Same live authority as exec; the policy module owns protected-path checks. */
4
5
  export type LocalSandboxWrites = Readonly<{
5
- assertWrite(path: string): void;
6
+ assertWrite(paths: readonly string[]): void;
6
7
  launch: LocalSandboxLaunch;
7
8
  }>;
8
9
  /**
@@ -27,4 +28,4 @@ export declare class FilesPathError extends Error {
27
28
  * in-root symlinks. This is trusted same-user containment, not protection from
28
29
  * a same-user process racing pathname resolution.
29
30
  */
30
- export declare function createPrivateInvocationFilesPort(path: string, scanBounds?: ScanBounds, sandbox?: LocalSandboxWrites): Promise<FilesPort>;
31
+ export declare function createPrivateInvocationFilesPort(path: string, scanBounds?: ScanBounds, sandbox?: LocalSandboxWrites, mode?: () => LocalPermissionMode): Promise<FilesPort>;
@@ -7,6 +7,15 @@ import { RE2JS } from "re2js";
7
7
  import { createPrivateFilesPatch } from "./private-files-patch.js";
8
8
  import { fitLocalImage, maximumLocalImageInputBytes, } from "./local-image-fit.js";
9
9
  import writeFileAtomic from "write-file-atomic";
10
+ const readOnlyFilesMessage = "Read-only mode: switch to Workspace to edit";
11
+ /** An expected Files refusal, not a proposal for an approval. */
12
+ class ReadOnlyFilesRefusal extends FilesEditError {
13
+ constructor(path) {
14
+ super("read-only", path, readOnlyFilesMessage);
15
+ this.name = "ReadOnlyFilesRefusal";
16
+ this.message = readOnlyFilesMessage;
17
+ }
18
+ }
10
19
  const maximumFileBytes = 128 * 1_024;
11
20
  const defaultScanBounds = {
12
21
  candidates: 50_000,
@@ -472,10 +481,14 @@ function createWriteQueue() {
472
481
  * in-root symlinks. This is trusted same-user containment, not protection from
473
482
  * a same-user process racing pathname resolution.
474
483
  */
475
- export async function createPrivateInvocationFilesPort(path, scanBounds = defaultScanBounds, sandbox) {
484
+ export async function createPrivateInvocationFilesPort(path, scanBounds = defaultScanBounds, sandbox, mode = () => "full-access") {
476
485
  const root = await InvocationRoot.capture(path);
477
486
  const write = createWriteQueue();
478
487
  const port = {
488
+ assertEditable(requested) {
489
+ if (mode() === "read-only")
490
+ throw new ReadOnlyFilesRefusal(requested);
491
+ },
479
492
  async read(input, context) {
480
493
  return await readPage(await root.existing(input.path, context), input.offset, input.limit, context);
481
494
  },
@@ -491,6 +504,7 @@ export async function createPrivateInvocationFilesPort(path, scanBounds = defaul
491
504
  async replace(input, context) {
492
505
  return await write(async () => {
493
506
  checkCancellation(context);
507
+ port.assertEditable?.(input.path);
494
508
  const path = await root.existing(input.path, context);
495
509
  const current = await readBoundedText(path, context);
496
510
  if (current !== input.expectedContent)
@@ -500,7 +514,7 @@ export async function createPrivateInvocationFilesPort(path, scanBounds = defaul
500
514
  throw new FilesEditError("file-too-large", input.path, `Result exceeds ${String(maximumFileBytes)} bytes; use files.patch for a small change`);
501
515
  }
502
516
  checkCancellation(context);
503
- sandbox?.assertWrite(path.resolved);
517
+ sandbox?.assertWrite([path.resolved]);
504
518
  await writeFileAtomic(path.resolved, input.content, {
505
519
  encoding: "utf8",
506
520
  });
@@ -515,6 +529,11 @@ export async function createPrivateInvocationFilesPort(path, scanBounds = defaul
515
529
  return Object.freeze(port);
516
530
  return Object.freeze({
517
531
  ...port,
518
- patch: (input, context) => write(() => patch(input, context)),
532
+ patch: (input, context) => write(async () => {
533
+ checkCancellation(context);
534
+ if (mode() === "read-only")
535
+ throw new ReadOnlyFilesRefusal(root.canonicalPath);
536
+ return patch(input, context);
537
+ }),
519
538
  });
520
539
  }
@@ -70,7 +70,7 @@ export declare function createPrivateLocalAgentControl(input: Readonly<{
70
70
  list: (signal: AbortSignal, options?: Readonly<{
71
71
  archived?: boolean;
72
72
  }>) => Promise<readonly AgentControlAgent[]>;
73
- send?: (input: import("@fungi.computer/shiitake/agent-control").AgentDelegationInput, sourceSessionId: string, signal: AbortSignal, invocation?: import("@fungi.computer/shiitake/agent-control").AgentDelegationInvocation) => Promise<import("@fungi.computer/shiitake").Accepted>;
73
+ send?: (input: import("@fungi.computer/shiitake/agent-control").AgentDelegationInput, sourceSessionId: string, signal: AbortSignal, invocation?: import("@fungi.computer/shiitake/agent-control").AgentDelegationInvocation) => Promise<import("@fungi.computer/shiitake").Accepted | import("@fungi.computer/shiitake").Held>;
74
74
  recall?: (input: import("@fungi.computer/shiitake/agent-control").AgentRecallInput, sourceSessionId: string, signal: AbortSignal) => Promise<import("@fungi.computer/shiitake").CancelResult>;
75
75
  create?: (input: Readonly<{
76
76
  requestId: string;
@@ -91,7 +91,7 @@ export declare function createPrivateLocalAgentControl(input: Readonly<{
91
91
  models?: (signal: AbortSignal) => Promise<readonly import("@fungi.computer/shiitake/agent-control").AgentControlModel[]>;
92
92
  checkModel?: (input: Readonly<{
93
93
  agentId: string;
94
- sessionId: string;
94
+ sessionId?: string;
95
95
  }>, signal: AbortSignal) => Promise<void>;
96
96
  open: (agentId: string, signal: AbortSignal) => Promise<Readonly<{
97
97
  agent: import("@fungi.computer/shiitake").ShiitakeAgent;