@fungi.computer/truffle 0.2.1 → 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 +39 -36
  2. package/dist/private-invocation-computer.d.ts +224 -5
  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 +17 -7
  8. package/dist/private-local-bwrap.js +73 -33
  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 +18 -8
  12. package/dist/private-local-containment.js +70 -45
  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 -3
  21. package/dist/private-local-linux-sandbox.js +71 -25
  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 +2 -0
  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 +16 -0
  29. package/dist/private-local-native-staging.js +138 -0
  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 +391 -71
  34. package/dist/private-local-sandbox-policy.d.ts +46 -6
  35. package/dist/private-local-sandbox-policy.js +350 -71
  36. package/dist/private-local-sandbox.d.ts +34 -18
  37. package/dist/private-local-sandbox.js +225 -60
  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 +18 -6
  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 +43 -9
  49. package/dist/private-local-session-discovery.d.ts +3 -2
  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 +3 -1
  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 +25 -19
@@ -1,18 +1,22 @@
1
1
  import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
2
- import { observeProviderStatus } from "@fungi.computer/mule";
2
+ import { observeProviderStatus, normalizeModel, decodeProviderData, } from "@fungi.computer/mule";
3
+ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
3
4
  import { modelFailureMessage, modelFailureRetryable, } from "./private-provider-rejection.js";
4
5
  import { logEvent } from "./log-event.js";
5
6
  import { localArchiveHandoff } from "./local-archive-handoff.js";
6
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";
7
10
  import { localExtensions, } from "./local-extensions.js";
8
11
  import { lstat, mkdir, realpath, stat } from "node:fs/promises";
9
12
  import { lstatSync, mkdirSync, realpathSync } from "node:fs";
10
13
  import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
11
14
  import { createHash } from "node:crypto";
12
15
  import { z } from "zod";
16
+ import { defaultLocalPermissionMode, localPermissionModeSchema, modeWithinCeiling, } from "./private-local-permission-mode.js";
13
17
  import { cleanupSessionResources, } from "@earendil-works/pi-ai";
14
18
  import { parseModelPreference, } from "@fungi.computer/shiitake/model-preference";
15
- import { createAgentControlSource, } from "@fungi.computer/shiitake/agent-control";
19
+ import { AgentCreateRefused, createAgentControlSource, } from "@fungi.computer/shiitake/agent-control";
16
20
  import { defineIdFamily } from "@fungi.computer/cairn";
17
21
  import { words } from "@fungi.computer/cairn/words";
18
22
  import { botanicalExtensionsWithSkills } from "@fungi.computer/shiitake-extensions/roster";
@@ -27,12 +31,14 @@ import { ensureAgentDefaultModelTable, readAgentDefaultModel, seedAgentDefaultMo
27
31
  import { defaultLocalReasoning, resolveKnownLocalStartupChoice, resolveLocalStartupChoice, startupReasoning, validateLocalReasoning, } from "./private-local-startup-model.js";
28
32
  import { assertFungiModelsEnabled } from "./private-fungi-model-api.js";
29
33
  import { privateLocalEffectiveModelSchema, } from "./private-local-effective-model.js";
30
- 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";
31
36
  import { createPrivateLocalComputerProcesses, } from "./private-local-computer-processes.js";
32
37
  import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
33
38
  import { ensurePrivateLocalGitExclude } from "./private-local-git-exclude.js";
34
- import { createLocalSandboxControls, } from "./private-local-sandbox.js";
35
- 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";
36
42
  import { LocalStoreSchemaError } from "./private-local-session-discovery.js";
37
43
  import { acquireNodeStoreLease, nodeRuntimeHost, } from "@fungi.computer/shiitake/node";
38
44
  import { createPrivateInvocationFilesPort, } from "./private-invocation-files.js";
@@ -64,7 +70,8 @@ function isErrnoException(error) {
64
70
  function sidecarPathFor(databasePath) {
65
71
  return `${databasePath}.owner.sqlite`;
66
72
  }
67
- 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;
68
75
  database.exec("BEGIN IMMEDIATE");
69
76
  try {
70
77
  database.exec(`
@@ -75,16 +82,24 @@ function ensureLocalAgentId(database, databasePath, pathAgentId, name, createdBy
75
82
  created_by TEXT,
76
83
  archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)),
77
84
  folder TEXT NOT NULL DEFAULT '.',
78
- 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 '[]'
79
87
  )
80
88
  `);
81
89
  const columns = database
82
90
  .prepare("PRAGMA table_info(private_local_identity)")
83
91
  .all();
84
- 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)))
85
100
  throw new LocalStoreSchemaError(databasePath);
86
101
  const row = database
87
- .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")
88
103
  .get();
89
104
  if (row !== undefined) {
90
105
  const parsed = localHostIds.store.schema.safeParse(row.agent_id);
@@ -97,18 +112,37 @@ function ensureLocalAgentId(database, databasePath, pathAgentId, name, createdBy
97
112
  if (folder !== undefined &&
98
113
  localAgentFolderSchema.parse(folder) !== storedFolder)
99
114
  throw new Error("Local Agent folder conflicts with its store");
100
- 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
+ }
101
127
  database
102
- .prepare("UPDATE private_local_identity SET require_sandbox = ? WHERE singleton = 1")
103
- .run(requireSandbox ? 1 : 0);
128
+ .prepare("UPDATE private_local_identity SET mode = ? WHERE singleton = 1")
129
+ .run(mode);
130
+ }
104
131
  database.exec("COMMIT");
105
132
  return parsed.data;
106
133
  }
107
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);
108
142
  database
109
- .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, ?, ?, ?, ?, ?, '[]')")
110
144
  // An unnamed Agent gets a Cairn candidate word name so it never shows its id.
111
- .run(agentId, name ?? words(), createdBy ?? null, localAgentFolderSchema.parse(folder ?? "."), requireSandbox === true ? 1 : 0);
145
+ .run(agentId, name ?? words(), createdBy ?? null, selectedFolder, selectedMode);
112
146
  database.exec("COMMIT");
113
147
  return agentId;
114
148
  }
@@ -556,82 +590,122 @@ export function createPrivateLocalModelSource(models, providerId, modelId, provi
556
590
  // prose that any SDK can supply.
557
591
  let modelFailure;
558
592
  return Promise.resolve(Object.freeze({
559
- model: selected,
593
+ model: normalizeModel(selected),
560
594
  retryable: (message) => !signal.aborted &&
561
595
  message.stopReason === "error" &&
562
596
  modelFailure?.message === message &&
563
597
  modelFailure.retryable,
564
598
  failureReason: (message) => modelFailure?.message === message ? modelFailure.reason : undefined,
599
+ providerError: (message) => modelFailure?.message === message
600
+ ? modelFailure.providerError
601
+ : undefined,
565
602
  stream: (selected, context, options) => {
603
+ const startedAt = Date.now();
566
604
  modelFailure = undefined;
605
+ let lastPartial;
567
606
  const streamOptions = { ...options };
568
607
  const reasoning = options?.reasoning ?? selectedReasoning;
569
608
  if (reasoning !== undefined)
570
609
  streamOptions.reasoning = reasoning;
571
610
  const provider = observeProviderStatus(streamOptions);
572
611
  const events = createAssistantMessageEventStream();
573
- 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);
574
616
  const evidence = {
575
617
  refusal: turn.refusal(),
576
- upstream,
618
+ upstream: upstream ?? partial,
577
619
  rejection: provider.rejection(),
578
620
  };
579
- 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.`;
580
624
  const result = {
581
625
  role: "assistant",
582
- content: [],
626
+ content: partial?.content ?? [],
583
627
  api: selected.api,
584
628
  provider: selected.provider,
585
629
  model: selected.id,
586
- 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
+ },
587
644
  stopReason: aborted ? "aborted" : "error",
588
645
  timestamp: Date.now(),
589
- errorMessage: aborted ? "Run cancelled" : message,
590
646
  };
591
- 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) {
592
651
  modelFailure = {
593
652
  message: result,
594
653
  reason: message,
595
654
  retryable: modelFailureRetryable(evidence),
596
655
  };
656
+ if (upstream?.errorMessage !== undefined)
657
+ modelFailure.providerError = upstream.errorMessage;
658
+ }
597
659
  return result;
598
660
  };
599
661
  void (async () => {
600
662
  try {
601
- const upstream = turn.models.streamSimple(selected, context, {
602
- ...streamOptions,
663
+ const { onPayload, onResponse, ...rest } = streamOptions;
664
+ const requestOptions = {
665
+ ...rest,
603
666
  fetch: provider.fetch,
604
- onProviderStreamEvent: provider.onProviderStreamEvent,
667
+ onProviderStreamEvent: (data, model) => provider.onProviderStreamEvent(decodeProviderData(data), normalizeModel(model)),
605
668
  sessionId: providerCacheKey,
606
669
  transport: options?.transport ?? "auto",
607
- });
670
+ };
671
+ if (onPayload !== undefined)
672
+ requestOptions.onPayload = (data, model) => onPayload(decodeProviderData(data), normalizeModel(model));
673
+ if (onResponse !== undefined)
674
+ requestOptions.onResponse = (response, model) => onResponse(response, normalizeModel(model));
675
+ const upstream = turn.models.streamSimple(selected, normalizeContext(context), requestOptions);
608
676
  for await (const event of upstream) {
677
+ if ("partial" in event)
678
+ lastPartial = event.partial;
609
679
  if (event.type === "error") {
610
- const error = failure(event.error.usage, event.reason === "aborted", event.error);
680
+ const error = failure(event.reason === "aborted", event.error);
611
681
  events.push({ ...event, error });
612
682
  events.end(error);
613
683
  return;
614
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
+ }
615
698
  events.push(event);
616
699
  }
617
- 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);
618
705
  }
619
- catch {
706
+ catch (cause) {
620
707
  const aborted = (options?.signal ?? signal).aborted;
621
- const error = failure({
622
- input: 0,
623
- output: 0,
624
- cacheRead: 0,
625
- cacheWrite: 0,
626
- totalTokens: 0,
627
- cost: {
628
- input: 0,
629
- output: 0,
630
- cacheRead: 0,
631
- cacheWrite: 0,
632
- total: 0,
633
- },
634
- }, aborted);
708
+ const error = failure(aborted, undefined, cause);
635
709
  events.push({
636
710
  type: "error",
637
711
  reason: aborted ? "aborted" : "error",
@@ -648,7 +722,7 @@ export function createPrivateLocalModelSource(models, providerId, modelId, provi
648
722
  });
649
723
  }
650
724
  /** One local Computer source, resolving its PATH and process scope per Session. */
651
- function createPrivateLocalComputerSource(root, port, sessionPath, processes, home, sandbox) {
725
+ function createPrivateLocalComputerSource(root, port, sessionPath, processes, home, sandbox, mode = () => "full-access", escapeForSession, policyForSession) {
652
726
  const id = "local-computer";
653
727
  return {
654
728
  id,
@@ -658,7 +732,7 @@ function createPrivateLocalComputerSource(root, port, sessionPath, processes, ho
658
732
  throw new Error("Local Computer requires a Session");
659
733
  const path = sessionPath(sessionId) ??
660
734
  capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd());
661
- 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));
662
736
  return Knapsack.computer({
663
737
  id,
664
738
  name: "local",
@@ -683,10 +757,10 @@ storeDirectory,
683
757
  * This Agent's sub-agent record: its Session-bound Agent-control tools and
684
758
  * the view's facts. Present when it can direct other Agents.
685
759
  */
686
- subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox) {
760
+ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox, currentMode = () => "full-access", escapeForSession, policyForSession) {
687
761
  const forms = createMcpFormSession();
688
762
  const root = await realpath(resolve(invocationRoot));
689
- const port = await createPrivateInvocationFilesPort(root, undefined, sandbox);
763
+ const port = await createPrivateInvocationFilesPort(root, undefined, sandbox, currentMode);
690
764
  const processes = createPrivateLocalComputerProcesses(root, configRoot.home);
691
765
  const computerSources = sessionPath === undefined
692
766
  ? [
@@ -698,11 +772,11 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox)
698
772
  port,
699
773
  pathInstructions: localPathInstructions(root),
700
774
  },
701
- 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")),
702
776
  }),
703
777
  ]
704
778
  : [
705
- createPrivateLocalComputerSource(root, port, sessionPath, processes, configRoot.home, sandbox?.launch),
779
+ createPrivateLocalComputerSource(root, port, sessionPath, processes, configRoot.home, sandbox?.launch, currentMode, escapeForSession, policyForSession),
706
780
  ];
707
781
  const installed = configuration?.installed ??
708
782
  (storeDirectory === undefined
@@ -726,7 +800,11 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox)
726
800
  homeExtension({
727
801
  computers: {
728
802
  read: () => [
729
- { name: "local", attachmentId: "local-computer", port },
803
+ {
804
+ name: "local",
805
+ attachmentId: "local-computer",
806
+ port,
807
+ },
730
808
  ],
731
809
  },
732
810
  }),
@@ -759,7 +837,7 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox)
759
837
  ...(clientPath === undefined
760
838
  ? computerSources
761
839
  : [
762
- createPrivateLocalComputerSource(root, port, () => clientPath, processes, configRoot.home, sandbox?.launch),
840
+ createPrivateLocalComputerSource(root, port, () => clientPath, processes, configRoot.home, sandbox?.launch, currentMode, escapeForSession, policyForSession),
763
841
  ]),
764
842
  ...(connection === undefined
765
843
  ? []
@@ -797,9 +875,13 @@ function localRuntimeOwner() {
797
875
  }),
798
876
  settled: async () => {
799
877
  if (pendingWork === undefined)
800
- return false;
878
+ return { idle: false, wakeAt: null };
801
879
  const work = await pendingWork();
802
- 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
+ };
803
885
  },
804
886
  });
805
887
  }
@@ -910,6 +992,16 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
910
992
  throw new ShiitakeError("model", "rejected", `Local model is not registered: ${selectedProviderId}/${selectedModelId ?? "select with --model"}`);
911
993
  validateLocalReasoning(model, input.reasoning);
912
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
+ }
913
1005
  const paths = await localDatabasePaths(root, input.databasePath, input.createRequestId);
914
1006
  const runtime = localRuntimeOwner();
915
1007
  let lease;
@@ -928,20 +1020,34 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
928
1020
  .digest("hex");
929
1021
  database = openPrivateWritableSqliteDatabase(paths.databasePath, 0);
930
1022
  database.exec("PRAGMA foreign_keys = ON");
931
- 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
+ });
932
1034
  const sandboxDatabase = database;
933
- const readSandboxRequirement = () => z
934
- .union([z.literal(0), z.literal(1)])
935
- .parse(sandboxDatabase
936
- .prepare("SELECT require_sandbox FROM private_local_identity WHERE singleton = 1")
937
- .get()?.require_sandbox) === 1;
1035
+ const readMode = () => localPermissionModeSchema.parse(sandboxDatabase
1036
+ .prepare("SELECT mode FROM private_local_identity WHERE singleton = 1")
1037
+ .get()?.mode);
938
1038
  const selectedFolder = resolveAgentFolder(root, localAgentFolderSchema.parse(database
939
1039
  .prepare("SELECT folder FROM private_local_identity WHERE singleton = 1")
940
1040
  .get()?.folder));
1041
+ const defaultReason = readMode() === "full-access" && input.mode === undefined
1042
+ ? probeLocalSandbox(root, selectedFolder.folder, input.native)
1043
+ : undefined;
941
1044
  const sandboxOwner = createLocalSandboxControls({
942
1045
  project: root,
943
1046
  folder: selectedFolder.folder,
944
- required: readSandboxRequirement,
1047
+ agentId,
1048
+ mode: readMode,
1049
+ defaultReason,
1050
+ native: input.native,
945
1051
  });
946
1052
  sandbox = sandboxOwner;
947
1053
  // Both constructors below can resume stored commands before returning.
@@ -1008,7 +1114,178 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1008
1114
  directory: projectDirectory,
1009
1115
  storage: store,
1010
1116
  });
1011
- 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);
1012
1289
  processes = localTools.processes;
1013
1290
  const sessionProcesses = processes;
1014
1291
  const modelSource = createPrivateLocalModelSource(models, selectedProviderId, selectedModelId, providerCacheKey, input.reasoning, { agent: readAgentModel });
@@ -1034,6 +1311,40 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1034
1311
  readSettlement = host.readSettlement;
1035
1312
  }
1036
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
+ }
1037
1348
  const setAgentModel = async (input, signal) => {
1038
1349
  const preference = parseModelPreference(input);
1039
1350
  if (preference === null)
@@ -1046,6 +1357,10 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1046
1357
  // Session override is cleared so the running Session follows the default.
1047
1358
  const getSession = (sessionId, attachment) => {
1048
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
+ });
1049
1364
  return {
1050
1365
  ...session,
1051
1366
  modelPreference: {
@@ -1069,11 +1384,11 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1069
1384
  getSession,
1070
1385
  presentApproval: localTools.presentApproval,
1071
1386
  setModel: setAgentModel,
1072
- setRequireSandbox: (required) => {
1387
+ setMode: (mode) => {
1388
+ sandboxOwner.assertModeReady(mode);
1073
1389
  sandboxDatabase
1074
- .prepare("UPDATE private_local_identity SET require_sandbox = ? WHERE singleton = 1")
1075
- .run(required ? 1 : 0);
1076
- sandboxOwner.assertReady();
1390
+ .prepare("UPDATE private_local_identity SET mode = ? WHERE singleton = 1")
1391
+ .run(mode);
1077
1392
  },
1078
1393
  sandboxStatus: sandboxOwner.status,
1079
1394
  setArchived: (archived) => {
@@ -1133,10 +1448,12 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1133
1448
  ? Promise.reject(new Error("Local settlement reads are unavailable"))
1134
1449
  : readSettlement(sessionId, commandId),
1135
1450
  idle: async () => {
1451
+ const busy = { idle: false, wakeAt: null };
1136
1452
  if (sessionProcesses.running())
1137
- return false;
1138
- if (!(await runtime.settled()))
1139
- return false;
1453
+ return busy;
1454
+ const settlement = await runtime.settled();
1455
+ if (!settlement.idle)
1456
+ return busy;
1140
1457
  // Delegated work still owed to this Agent is not read here: that read
1141
1458
  // reopens each child, and the child's reply mail reopens this Agent.
1142
1459
  let after;
@@ -1144,7 +1461,7 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1144
1461
  const page = await sessionOwner.listSessions(after);
1145
1462
  for (const row of page.sessions)
1146
1463
  if (row.state === "running" || row.state === "needs_you")
1147
- return false;
1464
+ return busy;
1148
1465
  after = page.next;
1149
1466
  } while (after !== undefined);
1150
1467
  // Reconciliation enqueues replies before advancing its settled cursor.
@@ -1152,8 +1469,11 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1152
1469
  // while we await settlement or Session reads, especially during stop
1153
1470
  // when delivery cannot reopen a parent. The owner rechecks admissions
1154
1471
  // and uses after this observation before forgetting the Agent.
1155
- return (input.mail === undefined ||
1156
- !(await hasPrivateLocalPendingMail(modelDatabase)));
1472
+ return {
1473
+ idle: input.mail === undefined ||
1474
+ !(await hasPrivateLocalPendingMail(modelDatabase)),
1475
+ wakeAt: settlement.wakeAt,
1476
+ };
1157
1477
  },
1158
1478
  close: () => {
1159
1479
  closing ??= closePrivateLocalResources({