@fungi.computer/truffle 0.2.2 → 0.3.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 (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 +10 -0
  16. package/dist/private-local-exec-review.js +50 -0
  17. package/dist/private-local-exec-rules.d.ts +14 -0
  18. package/dist/private-local-exec-rules.js +420 -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 +19 -5
  33. package/dist/private-local-runtime-host.js +378 -65
  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 +8 -0
  45. package/dist/private-local-service-log.js +3 -0
  46. package/dist/private-local-service-owner.d.ts +18 -10
  47. package/dist/private-local-service-owner.js +117 -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 +8 -1
  52. package/dist/private-mcp-form-contract.js +8 -1
  53. package/dist/private-mcp-form-session.d.ts +33 -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
@@ -5,15 +5,18 @@ import { modelFailureMessage, modelFailureRetryable, } from "./private-provider-
5
5
  import { logEvent } from "./log-event.js";
6
6
  import { localArchiveHandoff } from "./local-archive-handoff.js";
7
7
  import { createMcpFormSession } from "./private-mcp-form-session.js";
8
+ import { localExecEscapeReview } from "./private-local-exec-review.js";
9
+ import { ApprovalLedger, } from "@fungi.computer/approvals";
8
10
  import { localExtensions, } from "./local-extensions.js";
9
11
  import { lstat, mkdir, realpath, stat } from "node:fs/promises";
10
12
  import { lstatSync, mkdirSync, realpathSync } from "node:fs";
11
13
  import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
12
14
  import { createHash } from "node:crypto";
13
15
  import { z } from "zod";
16
+ import { defaultLocalPermissionMode, localPermissionModeSchema, modeWithinCeiling, } from "./private-local-permission-mode.js";
14
17
  import { cleanupSessionResources, } from "@earendil-works/pi-ai";
15
18
  import { parseModelPreference, } from "@fungi.computer/shiitake/model-preference";
16
- import { createAgentControlSource, } from "@fungi.computer/shiitake/agent-control";
19
+ import { AgentCreateRefused, createAgentControlSource, } from "@fungi.computer/shiitake/agent-control";
17
20
  import { defineIdFamily } from "@fungi.computer/cairn";
18
21
  import { words } from "@fungi.computer/cairn/words";
19
22
  import { botanicalExtensionsWithSkills } from "@fungi.computer/shiitake-extensions/roster";
@@ -28,12 +31,14 @@ import { ensureAgentDefaultModelTable, readAgentDefaultModel, seedAgentDefaultMo
28
31
  import { defaultLocalReasoning, resolveKnownLocalStartupChoice, resolveLocalStartupChoice, startupReasoning, validateLocalReasoning, } from "./private-local-startup-model.js";
29
32
  import { assertFungiModelsEnabled } from "./private-fungi-model-api.js";
30
33
  import { privateLocalEffectiveModelSchema, } from "./private-local-effective-model.js";
31
- import { createPrivateInvocationComputer, } from "./private-invocation-computer.js";
34
+ import { createPrivateInvocationComputer, dispatchApprovedLocalExec, shellOutput, } from "./private-invocation-computer.js";
35
+ import { localExecRules } from "./private-local-exec-rules.js";
32
36
  import { createPrivateLocalComputerProcesses, } from "./private-local-computer-processes.js";
33
37
  import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
34
38
  import { ensurePrivateLocalGitExclude } from "./private-local-git-exclude.js";
35
- import { createLocalSandboxControls, } from "./private-local-sandbox.js";
36
- import { localAgentFolderSchema, resolveAgentFolder, } from "./private-local-sandbox-policy.js";
39
+ import { createLocalSandboxControls, probeLocalSandbox, } from "./private-local-sandbox.js";
40
+ import { LocalSandboxUnavailable } from "./private-local-containment.js";
41
+ import { gitDirectories, protectedGitRemoteNames, localAgentFolderSchema, resolveAgentFolder, } from "./private-local-sandbox-policy.js";
37
42
  import { LocalStoreSchemaError } from "./private-local-session-discovery.js";
38
43
  import { acquireNodeStoreLease, nodeRuntimeHost, } from "@fungi.computer/shiitake/node";
39
44
  import { createPrivateInvocationFilesPort, } from "./private-invocation-files.js";
@@ -65,7 +70,8 @@ function isErrnoException(error) {
65
70
  function sidecarPathFor(databasePath) {
66
71
  return `${databasePath}.owner.sqlite`;
67
72
  }
68
- function ensureLocalAgentId(database, databasePath, pathAgentId, name, createdBy, folder, requireSandbox) {
73
+ function ensureLocalAgentId(options) {
74
+ const { database, databasePath, project, pathAgentId, name, createdBy, folder, mode, native, } = options;
69
75
  database.exec("BEGIN IMMEDIATE");
70
76
  try {
71
77
  database.exec(`
@@ -76,16 +82,24 @@ function ensureLocalAgentId(database, databasePath, pathAgentId, name, createdBy
76
82
  created_by TEXT,
77
83
  archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)),
78
84
  folder TEXT NOT NULL DEFAULT '.',
79
- require_sandbox INTEGER NOT NULL DEFAULT 0 CHECK (require_sandbox IN (0, 1))
85
+ mode TEXT NOT NULL CHECK (mode IN ('full-access', 'workspace', 'read-only')),
86
+ writable_folders TEXT NOT NULL DEFAULT '[]'
80
87
  )
81
88
  `);
82
89
  const columns = database
83
90
  .prepare("PRAGMA table_info(private_local_identity)")
84
91
  .all();
85
- if (!["name", "created_by", "archived", "folder", "require_sandbox"].every((required) => columns.some((column) => column.name === required)))
92
+ if (![
93
+ "name",
94
+ "created_by",
95
+ "archived",
96
+ "folder",
97
+ "mode",
98
+ "writable_folders",
99
+ ].every((required) => columns.some((column) => column.name === required)))
86
100
  throw new LocalStoreSchemaError(databasePath);
87
101
  const row = database
88
- .prepare("SELECT agent_id, created_by, folder FROM private_local_identity WHERE singleton = 1")
102
+ .prepare("SELECT agent_id, created_by, folder, mode FROM private_local_identity WHERE singleton = 1")
89
103
  .get();
90
104
  if (row !== undefined) {
91
105
  const parsed = localHostIds.store.schema.safeParse(row.agent_id);
@@ -98,18 +112,37 @@ function ensureLocalAgentId(database, databasePath, pathAgentId, name, createdBy
98
112
  if (folder !== undefined &&
99
113
  localAgentFolderSchema.parse(folder) !== storedFolder)
100
114
  throw new Error("Local Agent folder conflicts with its store");
101
- if (requireSandbox !== undefined)
115
+ if (mode !== undefined) {
116
+ if (createdBy !== undefined) {
117
+ if (!modeWithinCeiling(localPermissionModeSchema.parse(row.mode), mode))
118
+ throw new AgentCreateRefused("exceeds_creator", "Child Agent mode exceeds its creator's mode");
119
+ database.exec("COMMIT");
120
+ return parsed.data;
121
+ }
122
+ if (mode !== "full-access") {
123
+ const reason = probeLocalSandbox(project, storedFolder, native);
124
+ if (reason !== undefined)
125
+ throw new LocalSandboxUnavailable(reason);
126
+ }
102
127
  database
103
- .prepare("UPDATE private_local_identity SET require_sandbox = ? WHERE singleton = 1")
104
- .run(requireSandbox ? 1 : 0);
128
+ .prepare("UPDATE private_local_identity SET mode = ? WHERE singleton = 1")
129
+ .run(mode);
130
+ }
105
131
  database.exec("COMMIT");
106
132
  return parsed.data;
107
133
  }
108
134
  const agentId = pathAgentId ?? localHostIds.store.generate();
135
+ const selectedFolder = localAgentFolderSchema.parse(folder ?? ".");
136
+ const unavailable = mode === "full-access"
137
+ ? undefined
138
+ : probeLocalSandbox(project, selectedFolder, native);
139
+ if (mode !== undefined && unavailable !== undefined)
140
+ throw new LocalSandboxUnavailable(unavailable);
141
+ const selectedMode = mode ?? defaultLocalPermissionMode(unavailable === undefined);
109
142
  database
110
- .prepare("INSERT INTO private_local_identity (singleton, agent_id, name, created_by, folder, require_sandbox) VALUES (1, ?, ?, ?, ?, ?)")
143
+ .prepare("INSERT INTO private_local_identity (singleton, agent_id, name, created_by, folder, mode, writable_folders) VALUES (1, ?, ?, ?, ?, ?, '[]')")
111
144
  // An unnamed Agent gets a Cairn candidate word name so it never shows its id.
112
- .run(agentId, name ?? words(), createdBy ?? null, localAgentFolderSchema.parse(folder ?? "."), requireSandbox === true ? 1 : 0);
145
+ .run(agentId, name ?? words(), createdBy ?? null, selectedFolder, selectedMode);
113
146
  database.exec("COMMIT");
114
147
  return agentId;
115
148
  }
@@ -563,38 +596,66 @@ export function createPrivateLocalModelSource(models, providerId, modelId, provi
563
596
  modelFailure?.message === message &&
564
597
  modelFailure.retryable,
565
598
  failureReason: (message) => modelFailure?.message === message ? modelFailure.reason : undefined,
599
+ providerError: (message) => modelFailure?.message === message
600
+ ? modelFailure.providerError
601
+ : undefined,
566
602
  stream: (selected, context, options) => {
603
+ const startedAt = Date.now();
567
604
  modelFailure = undefined;
605
+ let lastPartial;
568
606
  const streamOptions = { ...options };
569
607
  const reasoning = options?.reasoning ?? selectedReasoning;
570
608
  if (reasoning !== undefined)
571
609
  streamOptions.reasoning = reasoning;
572
610
  const provider = observeProviderStatus(streamOptions);
573
611
  const events = createAssistantMessageEventStream();
574
- const failure = (usage, aborted, upstream) => {
612
+ const failure = (aborted, upstream, cause) => {
613
+ const partial = upstream?.content.some((part) => part.type !== "text" || part.text.length > 0)
614
+ ? upstream
615
+ : (lastPartial ?? upstream);
575
616
  const evidence = {
576
617
  refusal: turn.refusal(),
577
- upstream,
618
+ upstream: upstream ?? partial,
578
619
  rejection: provider.rejection(),
579
620
  };
580
- const message = modelFailureMessage(selected.provider, evidence);
621
+ const message = cause === undefined
622
+ ? modelFailureMessage(selected.provider, evidence)
623
+ : `${selected.provider} ${cause instanceof Error && cause.name === "TimeoutError" ? "request timed out" : cause instanceof Error && cause.name === "AbortError" ? "request was aborted" : "connection failed"} after ${String(Date.now() - startedAt)} ms. Retry the request.`;
581
624
  const result = {
582
625
  role: "assistant",
583
- content: [],
626
+ content: partial?.content ?? [],
584
627
  api: selected.api,
585
628
  provider: selected.provider,
586
629
  model: selected.id,
587
- usage,
630
+ usage: partial?.usage ?? {
631
+ input: 0,
632
+ output: 0,
633
+ cacheRead: 0,
634
+ cacheWrite: 0,
635
+ totalTokens: 0,
636
+ cost: {
637
+ input: 0,
638
+ output: 0,
639
+ cacheRead: 0,
640
+ cacheWrite: 0,
641
+ total: 0,
642
+ },
643
+ },
588
644
  stopReason: aborted ? "aborted" : "error",
589
645
  timestamp: Date.now(),
590
- errorMessage: aborted ? "Run cancelled" : message,
591
646
  };
592
- if (!aborted)
647
+ // The summary caller and retries receive only the safe reason.
648
+ // The turn recorder obtains provider prose by exact message identity.
649
+ result.errorMessage = aborted ? "Run cancelled" : message;
650
+ if (!aborted) {
593
651
  modelFailure = {
594
652
  message: result,
595
653
  reason: message,
596
654
  retryable: modelFailureRetryable(evidence),
597
655
  };
656
+ if (upstream?.errorMessage !== undefined)
657
+ modelFailure.providerError = upstream.errorMessage;
658
+ }
598
659
  return result;
599
660
  };
600
661
  void (async () => {
@@ -613,32 +674,38 @@ export function createPrivateLocalModelSource(models, providerId, modelId, provi
613
674
  requestOptions.onResponse = (response, model) => onResponse(response, normalizeModel(model));
614
675
  const upstream = turn.models.streamSimple(selected, normalizeContext(context), requestOptions);
615
676
  for await (const event of upstream) {
677
+ if ("partial" in event)
678
+ lastPartial = event.partial;
616
679
  if (event.type === "error") {
617
- const error = failure(event.error.usage, event.reason === "aborted", event.error);
680
+ const error = failure(event.reason === "aborted", event.error);
618
681
  events.push({ ...event, error });
619
682
  events.end(error);
620
683
  return;
621
684
  }
685
+ if (event.type === "done" &&
686
+ (event.message.stopReason === "error" ||
687
+ event.message.stopReason === "aborted")) {
688
+ const aborted = event.message.stopReason === "aborted";
689
+ const error = failure(aborted, event.message);
690
+ events.push({
691
+ type: "error",
692
+ reason: aborted ? "aborted" : "error",
693
+ error,
694
+ });
695
+ events.end(error);
696
+ return;
697
+ }
622
698
  events.push(event);
623
699
  }
624
- events.end(await upstream.result());
700
+ const result = await upstream.result();
701
+ events.end(result.stopReason === "error" ||
702
+ result.stopReason === "aborted"
703
+ ? failure(result.stopReason === "aborted", result)
704
+ : result);
625
705
  }
626
- catch {
706
+ catch (cause) {
627
707
  const aborted = (options?.signal ?? signal).aborted;
628
- const error = failure({
629
- input: 0,
630
- output: 0,
631
- cacheRead: 0,
632
- cacheWrite: 0,
633
- totalTokens: 0,
634
- cost: {
635
- input: 0,
636
- output: 0,
637
- cacheRead: 0,
638
- cacheWrite: 0,
639
- total: 0,
640
- },
641
- }, aborted);
708
+ const error = failure(aborted, undefined, cause);
642
709
  events.push({
643
710
  type: "error",
644
711
  reason: aborted ? "aborted" : "error",
@@ -655,7 +722,7 @@ export function createPrivateLocalModelSource(models, providerId, modelId, provi
655
722
  });
656
723
  }
657
724
  /** One local Computer source, resolving its PATH and process scope per Session. */
658
- function createPrivateLocalComputerSource(root, port, sessionPath, processes, home, sandbox) {
725
+ function createPrivateLocalComputerSource(root, port, sessionPath, processes, home, sandbox, mode = () => "full-access", escapeForSession, policyForSession) {
659
726
  const id = "local-computer";
660
727
  return {
661
728
  id,
@@ -665,7 +732,7 @@ function createPrivateLocalComputerSource(root, port, sessionPath, processes, ho
665
732
  throw new Error("Local Computer requires a Session");
666
733
  const path = sessionPath(sessionId) ??
667
734
  capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd());
668
- const selected = await createPrivateInvocationComputer(root, path, processes.scope(sessionId), home, sandbox);
735
+ const selected = await createPrivateInvocationComputer(root, path, processes.scope(sessionId), home, sandbox, mode, escapeForSession?.(sessionId), policyForSession?.(sessionId));
669
736
  return Knapsack.computer({
670
737
  id,
671
738
  name: "local",
@@ -690,10 +757,10 @@ storeDirectory,
690
757
  * This Agent's sub-agent record: its Session-bound Agent-control tools and
691
758
  * the view's facts. Present when it can direct other Agents.
692
759
  */
693
- subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox) {
760
+ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox, currentMode = () => "full-access", escapeForSession, policyForSession) {
694
761
  const forms = createMcpFormSession();
695
762
  const root = await realpath(resolve(invocationRoot));
696
- const port = await createPrivateInvocationFilesPort(root, undefined, sandbox);
763
+ const port = await createPrivateInvocationFilesPort(root, undefined, sandbox, currentMode);
697
764
  const processes = createPrivateLocalComputerProcesses(root, configRoot.home);
698
765
  const computerSources = sessionPath === undefined
699
766
  ? [
@@ -705,11 +772,11 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox)
705
772
  port,
706
773
  pathInstructions: localPathInstructions(root),
707
774
  },
708
- computer: await createPrivateInvocationComputer(root, undefined, processes.scope("invocation"), configRoot.home, sandbox?.launch),
775
+ computer: await createPrivateInvocationComputer(root, undefined, processes.scope("invocation"), configRoot.home, sandbox?.launch, currentMode, escapeForSession?.("invocation"), policyForSession?.("invocation")),
709
776
  }),
710
777
  ]
711
778
  : [
712
- createPrivateLocalComputerSource(root, port, sessionPath, processes, configRoot.home, sandbox?.launch),
779
+ createPrivateLocalComputerSource(root, port, sessionPath, processes, configRoot.home, sandbox?.launch, currentMode, escapeForSession, policyForSession),
713
780
  ];
714
781
  const installed = configuration?.installed ??
715
782
  (storeDirectory === undefined
@@ -733,7 +800,11 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox)
733
800
  homeExtension({
734
801
  computers: {
735
802
  read: () => [
736
- { name: "local", attachmentId: "local-computer", port },
803
+ {
804
+ name: "local",
805
+ attachmentId: "local-computer",
806
+ port,
807
+ },
737
808
  ],
738
809
  },
739
810
  }),
@@ -766,7 +837,7 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox)
766
837
  ...(clientPath === undefined
767
838
  ? computerSources
768
839
  : [
769
- createPrivateLocalComputerSource(root, port, () => clientPath, processes, configRoot.home, sandbox?.launch),
840
+ createPrivateLocalComputerSource(root, port, () => clientPath, processes, configRoot.home, sandbox?.launch, currentMode, escapeForSession, policyForSession),
770
841
  ]),
771
842
  ...(connection === undefined
772
843
  ? []
@@ -804,9 +875,13 @@ function localRuntimeOwner() {
804
875
  }),
805
876
  settled: async () => {
806
877
  if (pendingWork === undefined)
807
- return false;
878
+ return { idle: false, wakeAt: null };
808
879
  const work = await pendingWork();
809
- return !work.pending && work.nextWakeAt === null;
880
+ return {
881
+ idle: !work.pending &&
882
+ (work.nextWakeAt === null || work.nextWakeAt > Date.now()),
883
+ wakeAt: work.nextWakeAt,
884
+ };
810
885
  },
811
886
  });
812
887
  }
@@ -917,6 +992,16 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
917
992
  throw new ShiitakeError("model", "rejected", `Local model is not registered: ${selectedProviderId}/${selectedModelId ?? "select with --model"}`);
918
993
  validateLocalReasoning(model, input.reasoning);
919
994
  }
995
+ // A new explicit sandboxed Agent must be admissible before resolving its
996
+ // create receipt, directory, sidecar or SQLite file. Keep the check inside
997
+ // ensureLocalAgentId too: policy may change between this probe and commit.
998
+ if (input.databasePath === undefined &&
999
+ input.mode !== undefined &&
1000
+ input.mode !== "full-access") {
1001
+ const reason = probeLocalSandbox(root, localAgentFolderSchema.parse(input.folder ?? "."), input.native);
1002
+ if (reason !== undefined)
1003
+ throw new LocalSandboxUnavailable(reason);
1004
+ }
920
1005
  const paths = await localDatabasePaths(root, input.databasePath, input.createRequestId);
921
1006
  const runtime = localRuntimeOwner();
922
1007
  let lease;
@@ -935,20 +1020,34 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
935
1020
  .digest("hex");
936
1021
  database = openPrivateWritableSqliteDatabase(paths.databasePath, 0);
937
1022
  database.exec("PRAGMA foreign_keys = ON");
938
- const agentId = ensureLocalAgentId(database, paths.databasePath, paths.agentId, input.name, input.createdBy, input.folder, input.requireSandbox);
1023
+ const agentId = ensureLocalAgentId({
1024
+ database,
1025
+ databasePath: paths.databasePath,
1026
+ project: root,
1027
+ pathAgentId: paths.agentId,
1028
+ name: input.name,
1029
+ createdBy: input.createdBy,
1030
+ folder: input.folder,
1031
+ mode: input.mode,
1032
+ native: input.native,
1033
+ });
939
1034
  const sandboxDatabase = database;
940
- const readSandboxRequirement = () => z
941
- .union([z.literal(0), z.literal(1)])
942
- .parse(sandboxDatabase
943
- .prepare("SELECT require_sandbox FROM private_local_identity WHERE singleton = 1")
944
- .get()?.require_sandbox) === 1;
1035
+ const readMode = () => localPermissionModeSchema.parse(sandboxDatabase
1036
+ .prepare("SELECT mode FROM private_local_identity WHERE singleton = 1")
1037
+ .get()?.mode);
945
1038
  const selectedFolder = resolveAgentFolder(root, localAgentFolderSchema.parse(database
946
1039
  .prepare("SELECT folder FROM private_local_identity WHERE singleton = 1")
947
1040
  .get()?.folder));
1041
+ const defaultReason = readMode() === "full-access" && input.mode === undefined
1042
+ ? probeLocalSandbox(root, selectedFolder.folder, input.native)
1043
+ : undefined;
948
1044
  const sandboxOwner = createLocalSandboxControls({
949
1045
  project: root,
950
1046
  folder: selectedFolder.folder,
951
- required: readSandboxRequirement,
1047
+ agentId,
1048
+ mode: readMode,
1049
+ defaultReason,
1050
+ native: input.native,
952
1051
  });
953
1052
  sandbox = sandboxOwner;
954
1053
  // Both constructors below can resume stored commands before returning.
@@ -1015,7 +1114,178 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1015
1114
  directory: projectDirectory,
1016
1115
  storage: store,
1017
1116
  });
1018
- const localTools = await createPrivateLocalTools(selectedFolder.absolute, input.fungiMcp, subagents?.directory, (sessionId) => sessionPaths.get(sessionId), dirname(paths.databasePath), subagents, await input.configuration?.(dirname(paths.databasePath)), configRoot, sandboxOwner);
1117
+ const escapeLedger = new ApprovalLedger({
1118
+ owner: createPrivateSqliteOwner(database),
1119
+ home: { kind: "local", id: agentId },
1120
+ now: Date.now,
1121
+ });
1122
+ const execRules = localExecRules(root);
1123
+ // An Agent can create .git after opening the Session. That does not make
1124
+ // its newly authored config/hooks a protected repository for a grant.
1125
+ const grantRepository = lstatSync(join(root, ".git"), { throwIfNoEntry: false }) === undefined
1126
+ ? undefined
1127
+ : gitDirectories(root, root);
1128
+ // The attached human's answer, never form JSON or the model, selects
1129
+ // this short-lived intent. Commit the rule before dispatching the command.
1130
+ const ruleIntents = new Map();
1131
+ const selectRule = (recordId, prefix) => (selected) => {
1132
+ if (selected === "prefix")
1133
+ ruleIntents.set(recordId, () => {
1134
+ execRules.add(prefix);
1135
+ });
1136
+ else
1137
+ ruleIntents.delete(recordId);
1138
+ };
1139
+ const escapeFence = () => JSON.stringify([
1140
+ readMode(),
1141
+ resolveAgentFolder(root, localAgentFolderSchema.parse(sandboxDatabase
1142
+ .prepare("SELECT folder FROM private_local_identity WHERE singleton = 1")
1143
+ .get()?.folder)).absolute,
1144
+ ]);
1145
+ const escapeApply = {
1146
+ recheck: () => Promise.resolve({
1147
+ kind: "authorized",
1148
+ authorityFence: escapeFence(),
1149
+ }),
1150
+ dispatch: async ({ record, input: payload }) => {
1151
+ if (record.module !== "computer" || record.operation !== "exec")
1152
+ return { kind: "refused", reason: "not-granted" };
1153
+ const evidence = z
1154
+ .object({
1155
+ request: z.unknown(),
1156
+ cwd: z.string(),
1157
+ executionPath: z.string(),
1158
+ })
1159
+ .parse(payload);
1160
+ // Refuse a replaced or re-pointed working directory: the card showed
1161
+ // the canonical path selected at staging, not a later symlink target.
1162
+ if ((await realpath(evidence.cwd)) !== evidence.cwd)
1163
+ return { kind: "refused", reason: "not-granted" };
1164
+ const sessionId = record.requester.sessionId;
1165
+ if (sessionId === null)
1166
+ return { kind: "refused", reason: "not-granted" };
1167
+ const output = await dispatchApprovedLocalExec(evidence.request, evidence.cwd, evidence.executionPath, configRoot.home, localTools.processes.scope(sessionId), readMode());
1168
+ return { kind: "completed", output: z.json().parse(output) };
1169
+ },
1170
+ };
1171
+ const deliverEscapeResult = async (recordId) => {
1172
+ await escapeLedger.handoff(recordId, async (notice) => {
1173
+ const sessionId = notice.record.requester.sessionId;
1174
+ if (sessionId === null)
1175
+ throw new Error("Approval has no recipient Session");
1176
+ const accepted = await sessionOwner
1177
+ .session(sessionId)
1178
+ .send(`Approval ${notice.record.module}.${notice.record.operation}: ${JSON.stringify(notice.result)}`, { requestId: notice.deliveryId, delivery: "followUp" });
1179
+ return { commandId: accepted.commandId };
1180
+ });
1181
+ };
1182
+ const escapeApprovals = escapeLedger.capability({
1183
+ // Only the attached Local UI reaches the form's answer command. The
1184
+ // model has no member-bound approval capability.
1185
+ actor: () => Promise.resolve({
1186
+ kind: "human",
1187
+ memberId: agentId,
1188
+ role: "owner",
1189
+ actingForAgent: false,
1190
+ mayUseOperation: true,
1191
+ }),
1192
+ decision: async (recordId) => {
1193
+ const answered = escapeLedger.get(recordId);
1194
+ if (answered?.state === "approved") {
1195
+ if (answered.module === "computer" &&
1196
+ answered.operation === "exec" &&
1197
+ answered.authorityFence === escapeFence())
1198
+ ruleIntents.get(recordId)?.();
1199
+ await escapeLedger.apply(recordId, escapeApply);
1200
+ }
1201
+ for (const pending of escapeLedger.pendingHandoffs())
1202
+ await deliverEscapeResult(pending);
1203
+ },
1204
+ });
1205
+ const escapeForSession = (sessionId) => async ({ request, cwd, mode, denialLine, context, executionPath }) => {
1206
+ if (request.escape === undefined)
1207
+ throw new Error("An escape requires a justification");
1208
+ // The identity, current mode and root are host facts, not model input.
1209
+ const staged = await escapeLedger.request({
1210
+ requester: {
1211
+ principalId: agentId,
1212
+ agentId,
1213
+ sessionId,
1214
+ displayName: name,
1215
+ },
1216
+ module: "computer",
1217
+ operation: "exec",
1218
+ access: "external",
1219
+ reversibility: "irreversible-external",
1220
+ authorityFence: JSON.stringify([mode, selectedFolder.absolute]),
1221
+ idempotencyKey: JSON.stringify([
1222
+ context.executionId,
1223
+ context.toolCallId ?? null,
1224
+ context.operationOrdinal ?? null,
1225
+ ]),
1226
+ inputJson: JSON.stringify({
1227
+ request,
1228
+ cwd,
1229
+ mode,
1230
+ denialLine,
1231
+ executionPath,
1232
+ }),
1233
+ requireHumanApproval: true,
1234
+ });
1235
+ if (staged.kind === "refused")
1236
+ return staged;
1237
+ if (staged.record.state !== "pending")
1238
+ return {
1239
+ kind: "refused",
1240
+ reason: staged.record.state === "expired"
1241
+ ? "expired"
1242
+ : "already-answered",
1243
+ };
1244
+ const pending = {
1245
+ kind: "pending_approval",
1246
+ recordId: staged.record.recordId,
1247
+ description: staged.record.description,
1248
+ };
1249
+ const review = localExecEscapeReview(request, cwd);
1250
+ const presented = await localTools.presentApproval(sessionId, pending, escapeApprovals, review, review.prefix === undefined
1251
+ ? undefined
1252
+ : selectRule(pending.recordId, review.prefix));
1253
+ if (presented.kind !== "presented")
1254
+ throw new Error("Local escape approval could not be presented");
1255
+ return pending;
1256
+ };
1257
+ const policyForSession = (sessionId) => async ({ request, cwd, mode, executionPath }) => {
1258
+ if (mode !== "workspace" || !("executable" in request))
1259
+ return undefined;
1260
+ let configuredRemotes;
1261
+ if (request.executable === "git") {
1262
+ // Only the project's own protected common Git directory may use a
1263
+ // Git grant. A nested/Agent-created repository has writable config.
1264
+ const protectedGit = grantRepository === undefined
1265
+ ? undefined
1266
+ : gitDirectories(root, root, executionPath);
1267
+ const selectedGit = gitDirectories(root, cwd, executionPath);
1268
+ if (protectedGit?.common === undefined ||
1269
+ protectedGit.common !== grantRepository?.common ||
1270
+ protectedGit.worktree !== grantRepository.worktree ||
1271
+ selectedGit?.common !== protectedGit.common ||
1272
+ selectedGit.worktree !== protectedGit.worktree)
1273
+ return undefined;
1274
+ configuredRemotes = protectedGitRemoteNames(root, protectedGit.common, executionPath);
1275
+ if (configuredRemotes === undefined)
1276
+ return undefined;
1277
+ }
1278
+ const matched = execRules.match([request.executable, ...request.args], configuredRemotes);
1279
+ if (matched === undefined)
1280
+ return undefined;
1281
+ input.onExecRuleMatch?.({
1282
+ agentId,
1283
+ executable: request.executable,
1284
+ ruleId: matched.ruleId,
1285
+ });
1286
+ return shellOutput.parse(await dispatchApprovedLocalExec(request, cwd, executionPath, configRoot.home, localTools.processes.scope(sessionId), mode));
1287
+ };
1288
+ const localTools = await createPrivateLocalTools(selectedFolder.absolute, input.fungiMcp, subagents?.directory, (sessionId) => sessionPaths.get(sessionId), dirname(paths.databasePath), subagents, await input.configuration?.(dirname(paths.databasePath)), configRoot, sandboxOwner, () => sandboxOwner.status().mode, escapeForSession, policyForSession);
1019
1289
  processes = localTools.processes;
1020
1290
  const sessionProcesses = processes;
1021
1291
  const modelSource = createPrivateLocalModelSource(models, selectedProviderId, selectedModelId, providerCacheKey, input.reasoning, { agent: readAgentModel });
@@ -1041,6 +1311,40 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1041
1311
  readSettlement = host.readSettlement;
1042
1312
  }
1043
1313
  const sessionOwner = agent;
1314
+ async function recoverEscapes(sessionId) {
1315
+ for (const recordId of escapeLedger.pendingApplications())
1316
+ await escapeLedger.apply(recordId, escapeApply);
1317
+ for (const recordId of escapeLedger.pendingHandoffs())
1318
+ await deliverEscapeResult(recordId);
1319
+ const page = await escapeApprovals.list({ state: "pending", limit: 100 });
1320
+ for (const view of page.items) {
1321
+ if (view.record.requester.sessionId !== sessionId)
1322
+ continue;
1323
+ if (view.record.module !== "computer" ||
1324
+ view.record.operation !== "exec")
1325
+ continue;
1326
+ const input = escapeLedger.readInput(view.record.recordId);
1327
+ const evidence = z
1328
+ .object({
1329
+ request: z.object({
1330
+ command: z.string().optional(),
1331
+ executable: z.string().optional(),
1332
+ args: z.array(z.string()).optional(),
1333
+ escape: z.object({ justification: z.string() }),
1334
+ }),
1335
+ cwd: z.string(),
1336
+ })
1337
+ .parse(input);
1338
+ const review = localExecEscapeReview(evidence.request, evidence.cwd);
1339
+ await localTools.presentApproval(sessionId, {
1340
+ kind: "pending_approval",
1341
+ recordId: view.record.recordId,
1342
+ description: view.record.description,
1343
+ }, escapeApprovals, review, review.prefix === undefined
1344
+ ? undefined
1345
+ : selectRule(view.record.recordId, review.prefix));
1346
+ }
1347
+ }
1044
1348
  const setAgentModel = async (input, signal) => {
1045
1349
  const preference = parseModelPreference(input);
1046
1350
  if (preference === null)
@@ -1053,6 +1357,10 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1053
1357
  // Session override is cleared so the running Session follows the default.
1054
1358
  const getSession = (sessionId, attachment) => {
1055
1359
  const session = localTools.bindSession(sessionId, sessionOwner.session(sessionId), attachment);
1360
+ if (attachment !== undefined)
1361
+ void recoverEscapes(sessionId).catch((cause) => {
1362
+ logEvent("local-approval-recovery-failed", cause);
1363
+ });
1056
1364
  return {
1057
1365
  ...session,
1058
1366
  modelPreference: {
@@ -1076,11 +1384,11 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1076
1384
  getSession,
1077
1385
  presentApproval: localTools.presentApproval,
1078
1386
  setModel: setAgentModel,
1079
- setRequireSandbox: (required) => {
1387
+ setMode: (mode) => {
1388
+ sandboxOwner.assertModeReady(mode);
1080
1389
  sandboxDatabase
1081
- .prepare("UPDATE private_local_identity SET require_sandbox = ? WHERE singleton = 1")
1082
- .run(required ? 1 : 0);
1083
- sandboxOwner.assertReady();
1390
+ .prepare("UPDATE private_local_identity SET mode = ? WHERE singleton = 1")
1391
+ .run(mode);
1084
1392
  },
1085
1393
  sandboxStatus: sandboxOwner.status,
1086
1394
  setArchived: (archived) => {
@@ -1140,10 +1448,12 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1140
1448
  ? Promise.reject(new Error("Local settlement reads are unavailable"))
1141
1449
  : readSettlement(sessionId, commandId),
1142
1450
  idle: async () => {
1451
+ const busy = { idle: false, wakeAt: null };
1143
1452
  if (sessionProcesses.running())
1144
- return false;
1145
- if (!(await runtime.settled()))
1146
- return false;
1453
+ return busy;
1454
+ const settlement = await runtime.settled();
1455
+ if (!settlement.idle)
1456
+ return busy;
1147
1457
  // Delegated work still owed to this Agent is not read here: that read
1148
1458
  // reopens each child, and the child's reply mail reopens this Agent.
1149
1459
  let after;
@@ -1151,7 +1461,7 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1151
1461
  const page = await sessionOwner.listSessions(after);
1152
1462
  for (const row of page.sessions)
1153
1463
  if (row.state === "running" || row.state === "needs_you")
1154
- return false;
1464
+ return busy;
1155
1465
  after = page.next;
1156
1466
  } while (after !== undefined);
1157
1467
  // Reconciliation enqueues replies before advancing its settled cursor.
@@ -1159,8 +1469,11 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1159
1469
  // while we await settlement or Session reads, especially during stop
1160
1470
  // when delivery cannot reopen a parent. The owner rechecks admissions
1161
1471
  // and uses after this observation before forgetting the Agent.
1162
- return (input.mail === undefined ||
1163
- !(await hasPrivateLocalPendingMail(modelDatabase)));
1472
+ return {
1473
+ idle: input.mail === undefined ||
1474
+ !(await hasPrivateLocalPendingMail(modelDatabase)),
1475
+ wakeAt: settlement.wakeAt,
1476
+ };
1164
1477
  },
1165
1478
  close: () => {
1166
1479
  closing ??= closePrivateLocalResources({