@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
@@ -3,6 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
3
3
  import * as Effect from "effect/Effect";
4
4
  import { parseModelPreferenceJson } from "@fungi.computer/shiitake/model-preference";
5
5
  import { z } from "zod";
6
+ import { localPermissionModeSchema } from "./private-local-permission-mode.js";
6
7
  import { readAgentDefaultModelJson } from "./private-local-agent-default-model.js";
7
8
  import { localAgentFolderSchema, resolveAgentFolder, } from "./private-local-sandbox-policy.js";
8
9
  import { logEvent } from "./log-event.js";
@@ -105,7 +106,8 @@ const storedProfileSchema = z.strictObject({
105
106
  name: z.string().nullable(),
106
107
  createdBy: z.string().nullable(),
107
108
  archived: z.union([z.literal(0), z.literal(1)]),
108
- requireSandbox: z.union([z.literal(0), z.literal(1)]),
109
+ mode: localPermissionModeSchema,
110
+ writableFolders: z.array(z.string()),
109
111
  folder: localAgentFolderSchema,
110
112
  model: z.string(),
111
113
  });
@@ -129,16 +131,25 @@ export function readPrivateLocalAgentProfile(path) {
129
131
  const columns = database
130
132
  .prepare("PRAGMA table_info(private_local_identity)")
131
133
  .all();
132
- if (!["name", "created_by", "archived", "require_sandbox", "folder"].every((required) => columns.some((column) => column.name === required)))
134
+ if (![
135
+ "name",
136
+ "created_by",
137
+ "archived",
138
+ "mode",
139
+ "writable_folders",
140
+ "folder",
141
+ ].every((required) => columns.some((column) => column.name === required)))
133
142
  throw new LocalStoreSchemaError(path);
134
143
  const identity = database
135
- .prepare("SELECT name, created_by, archived, require_sandbox, folder FROM private_local_identity WHERE singleton = 1")
144
+ .prepare("SELECT name, created_by, archived, mode, writable_folders, folder FROM private_local_identity WHERE singleton = 1")
136
145
  .get();
146
+ const writableFolders = storedProfileSchema.shape.writableFolders.parse(JSON.parse(z.string().parse(identity?.writable_folders)));
137
147
  const stored = storedProfileSchema.parse({
138
148
  name: identity?.name,
139
149
  createdBy: identity?.created_by,
140
150
  archived: identity?.archived,
141
- requireSandbox: identity?.require_sandbox,
151
+ mode: identity?.mode,
152
+ writableFolders,
142
153
  folder: identity?.folder,
143
154
  model: readAgentDefaultModelJson(database),
144
155
  });
@@ -146,7 +157,8 @@ export function readPrivateLocalAgentProfile(path) {
146
157
  name: stored.name ?? undefined,
147
158
  createdBy: stored.createdBy,
148
159
  archived: stored.archived === 1,
149
- requireSandbox: stored.requireSandbox === 1,
160
+ mode: stored.mode,
161
+ writableFolders: stored.writableFolders,
150
162
  folder: stored.folder,
151
163
  model: parseModelPreferenceJson(stored.model),
152
164
  };
@@ -267,7 +279,7 @@ export function projectPrivateLocalSessionDiscovery(discovered, connect) {
267
279
  databasePath,
268
280
  sessionId,
269
281
  fungiMcp: resumeRequest?.fungiMcp,
270
- requireSandbox: resumeRequest?.requireSandbox,
282
+ mode: resumeRequest?.mode,
271
283
  }, resumeRequest),
272
284
  }));
273
285
  return Object.freeze({
@@ -77,7 +77,7 @@ export declare const mcpFormApprovalContract: {
77
77
  scope: "session";
78
78
  input: Schema.Struct<{
79
79
  readonly id: Schema.String;
80
- readonly decision: Schema.Literals<readonly ["approve", "deny"]>;
80
+ readonly decision: Schema.Literals<readonly ["approve", "deny", "always-allow"]>;
81
81
  readonly expectedRevision: Schema.Int;
82
82
  }>;
83
83
  output: Schema.Union<readonly [Schema.Struct<{
@@ -179,6 +179,13 @@ export declare const mcpFormApprovalContract: {
179
179
  readonly canAnswer: Schema.Boolean;
180
180
  readonly canSetAutoApprove: Schema.Boolean;
181
181
  }>;
182
+ readonly review: Schema.optionalKey<Schema.Struct<{
183
+ readonly command: Schema.String;
184
+ readonly cwd: Schema.String;
185
+ readonly justification: Schema.String;
186
+ readonly prefix: Schema.optionalKey<Schema.$Array<Schema.String>>;
187
+ readonly warning: Schema.optionalKey<Schema.String>;
188
+ }>>;
182
189
  }>>;
183
190
  }>;
184
191
  };
@@ -32,6 +32,13 @@ const answerResult = Schema.Union([
32
32
  const McpFormPendingSchema = Schema.Struct({
33
33
  id: Schema.String,
34
34
  approval: ApprovalViewSchema,
35
+ review: Schema.optionalKey(Schema.Struct({
36
+ command: Schema.String,
37
+ cwd: Schema.String,
38
+ justification: Schema.String,
39
+ prefix: Schema.optionalKey(Schema.Array(Schema.String)),
40
+ warning: Schema.optionalKey(Schema.String),
41
+ })),
35
42
  });
36
43
  /** The existing Session extension slot carries only display-safe form data. */
37
44
  export const mcpFormApprovalContract = {
@@ -41,7 +48,7 @@ export const mcpFormApprovalContract = {
41
48
  scope: "session",
42
49
  input: Schema.Struct({
43
50
  id: Schema.String,
44
- decision: ApprovalAnswerInputSchema.fields.decision,
51
+ decision: Schema.Literals(["approve", "deny", "always-allow"]),
45
52
  expectedRevision: ApprovalAnswerInputSchema.fields.expectedRevision,
46
53
  }),
47
54
  output: answerResult,
@@ -1,6 +1,21 @@
1
- import type { Approvals, PendingApproval } from "@fungi.computer/approvals";
1
+ import type { Approvals, ApprovalView, PendingApproval } from "@fungi.computer/approvals";
2
2
  import type { ShiitakeSession } from "@fungi.computer/shiitake";
3
3
  import { type LocalUiAttachment } from "./private-local-ui-attachment.js";
4
+ export type LocalApprovalReview = {
5
+ command: string;
6
+ cwd: string;
7
+ justification: string;
8
+ prefix?: readonly string[];
9
+ warning?: string;
10
+ };
11
+ type Pending = {
12
+ sessionId: string;
13
+ approval: ApprovalView;
14
+ approvals: Approvals;
15
+ answering: boolean;
16
+ review: LocalApprovalReview | undefined;
17
+ alwaysAllow: ((selected: "prefix" | false) => void) | undefined;
18
+ };
4
19
  /** Host-owned references to canonical approvals; no suspended tool or second ledger. */
5
20
  export declare function createMcpFormSession(): {
6
21
  extension: import("@fungi.computer/shiitake/extensions").DefinedExtensionV0<{
@@ -8,7 +23,7 @@ export declare function createMcpFormSession(): {
8
23
  scope: "session";
9
24
  input: import("effect/Schema").Struct<{
10
25
  readonly id: import("effect/Schema").String;
11
- readonly decision: import("effect/Schema").Literals<readonly ["approve", "deny"]>;
26
+ readonly decision: import("effect/Schema").Literals<readonly ["approve", "deny", "always-allow"]>;
12
27
  readonly expectedRevision: import("effect/Schema").Int;
13
28
  }>;
14
29
  output: import("effect/Schema").Union<readonly [import("effect/Schema").Struct<{
@@ -108,6 +123,13 @@ export declare function createMcpFormSession(): {
108
123
  readonly canAnswer: boolean;
109
124
  readonly canSetAutoApprove: boolean;
110
125
  };
126
+ readonly review?: {
127
+ readonly command: string;
128
+ readonly cwd: string;
129
+ readonly justification: string;
130
+ readonly prefix?: readonly string[];
131
+ readonly warning?: string;
132
+ };
111
133
  }[];
112
134
  }, {
113
135
  readonly pending: readonly {
@@ -153,9 +175,16 @@ export declare function createMcpFormSession(): {
153
175
  readonly canAnswer: boolean;
154
176
  readonly canSetAutoApprove: boolean;
155
177
  };
178
+ readonly review?: {
179
+ readonly command: string;
180
+ readonly cwd: string;
181
+ readonly justification: string;
182
+ readonly prefix?: readonly string[];
183
+ readonly warning?: string;
184
+ };
156
185
  }[];
157
186
  }>;
158
- presentApproval: (sessionId: string, submitted: PendingApproval, approvals: Approvals) => Promise<{
187
+ presentApproval: (sessionId: string, submitted: PendingApproval, approvals: Approvals, review?: Pending["review"], alwaysAllow?: (selected: "prefix" | false) => void) => Promise<{
159
188
  readonly kind: "refused";
160
189
  readonly reason: "not-pending";
161
190
  readonly id?: never;
@@ -170,3 +199,4 @@ export declare function createMcpFormSession(): {
170
199
  }>;
171
200
  bindSession: (sessionId: string, session: ShiitakeSession, attachment: LocalUiAttachment | undefined) => ShiitakeSession;
172
201
  };
202
+ export {};
@@ -46,13 +46,22 @@ export function createMcpFormSession() {
46
46
  return { kind: "refused", reason: "not-attached" };
47
47
  // Session commit and live UI recheck precede the canonical
48
48
  // decision. Its bound host, never MCP accept, owns apply.
49
- return await entry.approvals.answer({
49
+ if (input.decision === "always-allow" &&
50
+ entry.alwaysAllow === undefined)
51
+ return { kind: "refused", reason: "not-pending" };
52
+ if (input.decision === "always-allow")
53
+ entry.alwaysAllow?.("prefix");
54
+ const result = await entry.approvals.answer({
50
55
  recordId: entry.approval.record.recordId,
51
- decision: input.decision,
56
+ decision: input.decision === "always-allow"
57
+ ? "approve"
58
+ : input.decision,
52
59
  expectedRevision: input.expectedRevision,
53
60
  });
61
+ return result;
54
62
  }
55
63
  finally {
64
+ entry.alwaysAllow?.(false);
56
65
  entry.answering = false;
57
66
  const view = await entry.approvals.get({
58
67
  recordId: entry.approval.record.recordId,
@@ -80,7 +89,12 @@ export function createMcpFormSession() {
80
89
  read: (_context, sessionId) => ({
81
90
  pending: [...pending.entries()]
82
91
  .filter(([, entry]) => entry.sessionId === sessionId)
83
- .map(([id, entry]) => ({ id, approval: entry.approval })),
92
+ .map(([id, entry]) => {
93
+ const item = { id, approval: entry.approval };
94
+ return entry.review === undefined
95
+ ? item
96
+ : { ...item, review: entry.review };
97
+ }),
84
98
  }),
85
99
  },
86
100
  });
@@ -88,7 +102,7 @@ export function createMcpFormSession() {
88
102
  * Native host port, never a model or UI command. The caller supplies the
89
103
  * actual submit result and a member-bound capability, not form JSON.
90
104
  */
91
- async function presentApproval(sessionId, submitted, approvals) {
105
+ async function presentApproval(sessionId, submitted, approvals, review, alwaysAllow) {
92
106
  const approval = await approvals.get({ recordId: submitted.recordId });
93
107
  if (approval?.record.state !== "pending" ||
94
108
  approval.record.requester.sessionId !== sessionId)
@@ -99,13 +113,22 @@ export function createMcpFormSession() {
99
113
  const [id, entry] = existing;
100
114
  entry.approval = approval;
101
115
  entry.approvals = approvals;
116
+ entry.review = review;
117
+ entry.alwaysAllow = alwaysAllow;
102
118
  await publish?.(sessionId);
103
119
  return { kind: "presented", id };
104
120
  }
105
121
  if (pending.size >= maximumPendingForms)
106
122
  return { kind: "refused", reason: "capacity" };
107
123
  const id = correlationKey();
108
- pending.set(id, { sessionId, approval, approvals, answering: false });
124
+ pending.set(id, {
125
+ sessionId,
126
+ approval,
127
+ approvals,
128
+ answering: false,
129
+ review,
130
+ alwaysAllow,
131
+ });
109
132
  await publish?.(sessionId);
110
133
  return { kind: "presented", id };
111
134
  }
@@ -3,7 +3,7 @@ import { UnauthorizedError, type AuthProvider } from "@modelcontextprotocol/clie
3
3
  export declare class OysterMcpKeyRejectedError extends UnauthorizedError {
4
4
  constructor();
5
5
  }
6
- /** Read bounded UTF-8 stdin, then place the key in Local's existing private store. */
6
+ /** Read bounded UTF-8 stdin, then place the key in OS custody when available. */
7
7
  export declare function saveOysterMcpKey(endpoint: URL, input: AsyncIterable<Uint8Array>, options?: {
8
8
  credentialPath?: string;
9
9
  }): Promise<void>;
@@ -1,4 +1,9 @@
1
1
  import { UnauthorizedError, } from "@modelcontextprotocol/client";
2
+ import { once } from "node:events";
3
+ import { access } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { Message, sessionBus } from "@jellybrick/dbus-next";
6
+ import { AsyncEntry } from "@napi-rs/keyring";
2
7
  import { z } from "zod";
3
8
  import { createPrivateCredentialStore, maximumCredentialStringLength, } from "./private-file-credential-store.js";
4
9
  import { parseFungiMcpEndpoint } from "./private-fungi-mcp-endpoint.js";
@@ -20,10 +25,120 @@ const oysterKey = z
20
25
  .min(5)
21
26
  .max(maximumCredentialStringLength)
22
27
  .regex(/^oyk_[A-Za-z0-9_-]+$/);
23
- /** Read bounded UTF-8 stdin, then place the key in Local's existing private store. */
28
+ const keychainService = "fungi-oyster-mcp";
29
+ const warnedFallbackPaths = new Set();
30
+ function fileStore(path) {
31
+ return createPrivateCredentialStore(path);
32
+ }
33
+ function fallbackWarning(path) {
34
+ if (warnedFallbackPaths.has(path))
35
+ return;
36
+ warnedFallbackPaths.add(path);
37
+ process.stderr.write(`fungi: OS keychain unavailable (Linux Secret Service); Oyster key stored/read in 0600 file ${JSON.stringify(path)}.\n`);
38
+ }
39
+ /**
40
+ * The keyring binding reports both absent and broken services as GenericFailure.
41
+ * Ask the D-Bus owner for typed names, including activatable services, rather
42
+ * than interpreting its human-readable error text or downgrading every error.
43
+ */
44
+ async function secretServiceAbsent() {
45
+ const missingBus = z.object({ code: z.enum(["ENOENT", "ECONNREFUSED"]) });
46
+ let bus;
47
+ try {
48
+ bus = sessionBus();
49
+ const connectedBus = bus;
50
+ async function names(member) {
51
+ const reply = await Promise.race([
52
+ connectedBus.call(new Message({
53
+ destination: "org.freedesktop.DBus",
54
+ path: "/org/freedesktop/DBus",
55
+ interface: "org.freedesktop.DBus",
56
+ member,
57
+ })),
58
+ once(connectedBus, "error", { signal: AbortSignal.timeout(3_000) }),
59
+ ]);
60
+ if (Array.isArray(reply)) {
61
+ const error = reply[0];
62
+ if (error instanceof Error)
63
+ throw error;
64
+ throw new Error("Cannot connect to the Secret Service session bus");
65
+ }
66
+ if (reply === null)
67
+ throw new Error("Cannot inspect the Secret Service session bus");
68
+ return z.array(z.string()).parse(reply.body[0]);
69
+ }
70
+ const [active, activatable] = await Promise.all([
71
+ names("ListNames"),
72
+ names("ListActivatableNames"),
73
+ ]);
74
+ return (!active.includes("org.freedesktop.secrets") &&
75
+ !activatable.includes("org.freedesktop.secrets"));
76
+ }
77
+ catch (cause) {
78
+ if (missingBus.safeParse(cause).success)
79
+ return true;
80
+ // dbus-next emits a plain Error when it cannot discover any bus address.
81
+ // Without an explicit address, an XDG bus socket or an X11 display, no
82
+ // session bus can be reached; other errors from a reachable bus stay fatal.
83
+ if (!process.env.DBUS_SESSION_BUS_ADDRESS && !process.env.DISPLAY) {
84
+ const runtime = process.env.XDG_RUNTIME_DIR;
85
+ if (!runtime)
86
+ return true;
87
+ try {
88
+ await access(join(runtime, "bus"));
89
+ }
90
+ catch (missingSocket) {
91
+ if (missingBus.safeParse(missingSocket).success)
92
+ return true;
93
+ throw missingSocket;
94
+ }
95
+ }
96
+ throw cause;
97
+ }
98
+ finally {
99
+ bus?.disconnect();
100
+ }
101
+ }
102
+ async function keychain(id) {
103
+ try {
104
+ // Do not accept the library's default, nonpersistent Linux keyutils fallback.
105
+ return new AsyncEntry(keychainService, id, {
106
+ linux: { store: "secret-service" },
107
+ });
108
+ }
109
+ catch (cause) {
110
+ if (process.platform === "linux" && (await secretServiceAbsent()))
111
+ return undefined;
112
+ throw cause;
113
+ }
114
+ }
115
+ async function movePlaintextKey(id, path, entry) {
116
+ const store = fileStore(path);
117
+ const legacy = await store.read(id);
118
+ const saved = await entry.getPassword();
119
+ if (legacy === undefined)
120
+ return saved;
121
+ if (legacy.type !== "api_key" || legacy.key === undefined)
122
+ throw new Error("The saved Oyster MCP credential is invalid");
123
+ const key = oysterKey.parse(legacy.key);
124
+ if (saved !== null && saved !== key)
125
+ throw new Error("The Oyster MCP keychain and plaintext file disagree; refusing to discard either key");
126
+ if (saved === null)
127
+ await entry.setPassword(key);
128
+ if ((await entry.getPassword()) !== key)
129
+ throw new Error("The Oyster MCP keychain did not retain the migrated key");
130
+ await store.remove(id, async (current) => {
131
+ if (current.type !== "api_key" || current.key !== key)
132
+ throw new Error("The plaintext Oyster MCP key changed during migration");
133
+ if ((await entry.getPassword()) !== key)
134
+ throw new Error("The Oyster MCP keychain changed during migration");
135
+ });
136
+ return key;
137
+ }
138
+ /** Read bounded UTF-8 stdin, then place the key in OS custody when available. */
24
139
  export async function saveOysterMcpKey(endpoint, input, options = {}) {
25
140
  const id = credentialId(endpoint);
26
- const store = createPrivateCredentialStore(options.credentialPath ?? capturePrivateLocalConfigRoot().credentialPath);
141
+ const path = options.credentialPath ?? capturePrivateLocalConfigRoot().credentialPath;
27
142
  const decoder = new TextDecoder("utf-8", { fatal: true });
28
143
  let bytes = 0;
29
144
  let text = "";
@@ -37,18 +152,35 @@ export async function saveOysterMcpKey(endpoint, input, options = {}) {
37
152
  const parsed = oysterKey.safeParse(text);
38
153
  if (!parsed.success)
39
154
  throw new Error("Expected one Oyster oyk_ key on stdin");
40
- await store.modify(id, () => Promise.resolve({ type: "api_key", key: parsed.data }));
155
+ const entry = await keychain(id);
156
+ if (entry === undefined) {
157
+ await fileStore(path).modify(id, () => Promise.resolve({ type: "api_key", key: parsed.data }));
158
+ fallbackWarning(path);
159
+ return;
160
+ }
161
+ await movePlaintextKey(id, path, entry);
162
+ await entry.setPassword(parsed.data);
163
+ if ((await entry.getPassword()) !== parsed.data)
164
+ throw new Error("The Oyster MCP keychain did not retain the saved key");
41
165
  }
42
166
  /** Static bearer auth reads only the selected origin's credential, without discovery. */
43
167
  export function oysterMcpKeyAuth(endpoint, options = {}) {
44
168
  const id = credentialId(endpoint);
45
- const store = createPrivateCredentialStore(options.credentialPath ?? capturePrivateLocalConfigRoot().credentialPath);
169
+ const path = options.credentialPath ?? capturePrivateLocalConfigRoot().credentialPath;
46
170
  return {
47
171
  async token() {
48
- const credential = await store.read(id);
49
- if (credential?.type !== "api_key" || credential.key === undefined)
172
+ const entry = await keychain(id);
173
+ if (entry === undefined) {
174
+ const credential = await fileStore(path).read(id);
175
+ fallbackWarning(path);
176
+ if (credential?.type !== "api_key" || credential.key === undefined)
177
+ throw new OysterMcpKeyRejectedError();
178
+ return oysterKey.parse(credential.key);
179
+ }
180
+ const key = await movePlaintextKey(id, path, entry);
181
+ if (key === null)
50
182
  throw new OysterMcpKeyRejectedError();
51
- return credential.key;
183
+ return oysterKey.parse(key);
52
184
  },
53
185
  onUnauthorized() {
54
186
  return Promise.reject(new OysterMcpKeyRejectedError());
@@ -20,5 +20,5 @@ export function modelFailureMessage(provider, failure) {
20
20
  }
21
21
  if (failure.rejection?.code === "provider_rejected")
22
22
  return `${provider} rejected the model request (${failure.rejection.code}, HTTP ${String(failure.rejection.status)}). Check /login or choose another model with /model.`;
23
- return `${provider} rejected the model request. Check /login or choose another model with /model.`;
23
+ return `${provider} model request failed without a provider rejection. Retry the request.`;
24
24
  }
@@ -3,8 +3,8 @@
3
3
  ; rust-v0.162.1 (7a078f6bb4).
4
4
  ; Changed by Botanical: no network fragments, PTY/device write grants, shared
5
5
  ; memory or power-management grants. Reads and parameterized folder/temp writes
6
- ; are composed by private-local-seatbelt.ts. Protect .fungi and Git hooks/config,
7
- ; not all of .git. Denials carry fungi-safe for diagnostic correlation.
6
+ ; are composed by private-local-seatbelt.ts. Protect .fungi and the whole
7
+ ; existing .git administration. Denials carry fungi-safe for diagnostic correlation.
8
8
  ;
9
9
  ; inspired by Chrome's sandbox policy:
10
10
  ; https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
@@ -12,7 +12,7 @@ RUN if [ "$TARGET" = linux-arm64 ]; then \
12
12
  fi && apt-get update && apt-get install -y --no-install-recommends \
13
13
  build-essential ca-certificates curl xz-utils python3-pip ninja-build \
14
14
  pkg-config patchelf libcap-dev libseccomp-dev \
15
- $(if [ "$TARGET" = linux-arm64 ]; then printf 'gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu libcap-dev:arm64'; fi) \
15
+ $(if [ "$TARGET" = linux-arm64 ]; then printf 'gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu libc6-dev-arm64-cross libcap-dev:arm64'; fi) \
16
16
  && rm -rf /var/lib/apt/lists/*
17
17
  # prefer_static is a maintained Meson option newer than Jammy's Meson.
18
18
  RUN pip3 install --no-cache-dir meson==1.10.2
@@ -37,6 +37,8 @@ RUN test "$(getconf GNU_LIBC_VERSION)" = "glibc 2.35" \
37
37
  && cc -O2 -Wall -Wextra -Werror /source/vendor/seccomp-unix-block.c -lseccomp -o /compiled/seccomp-generator \
38
38
  && /compiled/seccomp-generator /out/fungi-seccomp.bpf \
39
39
  $(if [ "$TARGET" = linux-arm64 ]; then printf aarch64; else printf x86_64; fi) unix \
40
+ && /compiled/seccomp-generator /out/fungi-seccomp-workspace.bpf \
41
+ $(if [ "$TARGET" = linux-arm64 ]; then printf aarch64; else printf x86_64; fi) workspace \
40
42
  && chmod 0755 /out/fungi-bwrap \
41
43
  && patchelf --print-needed /out/fungi-bwrap > /needed \
42
44
  && while read -r lib; do \
@@ -73,17 +73,17 @@ directory. Only controlled TCP/Unix listeners are created, and they close in
73
73
  mutated.
74
74
 
75
75
  The prepared probe checks confined writes, outside-write refusal, `.fungi` and
76
- Git config/hooks protection, a real allowed Git add/commit, private `/run` and
76
+ Git administration write refusal (including add/commit), private `/run` and
77
77
  external XDG runtime directory, absent session locators, and refusal to reach
78
78
  the controlled host TCP/Unix listeners. Namespace denial must fail the proof,
79
79
  not trigger bare execution or a skipped success.
80
80
 
81
81
  This is a **direct backend proof fixture**, not foreground/background lifecycle
82
- or Files acceptance. Linked-worktree Git protection, immovable Git directories,
83
- shell/direct command owners, process-group cleanup, hidden-run detachment,
84
- installed-package resolution and all affected gates still require qualification.
85
- The argv/digest unit tests do not establish any of these kernel properties. Even
86
- a passing backend probe alone does not authorize changing readiness.
82
+ or Files acceptance. Linked-worktree Git protection, shell/direct command
83
+ owners, process-group cleanup, hidden-run detachment, installed-package
84
+ resolution and all affected gates still require qualification. The argv/digest
85
+ unit tests do not establish any of these kernel properties. Even a passing
86
+ backend probe alone does not authorize changing readiness.
87
87
 
88
88
  ## Exact remaining gap
89
89
 
@@ -9,13 +9,13 @@ export const bwrapPin = Object.freeze({
9
9
  "0e85c8702248b8dd64144f594147553650cdd0e4c951690345faace79c217c4d",
10
10
  });
11
11
 
12
- /** Unmodified maintained libseccomp generator; never locally assembled BPF. */
12
+ /** Retained libseccomp generator with a Workspace rule-set selector. */
13
13
  export const seccompPin = Object.freeze({
14
14
  repository: "https://github.com/anthropic-experimental/sandbox-runtime",
15
15
  revision: "38d1aee29c72035f1dba1337ac89b03a2f655d26",
16
16
  source: "vendor/seccomp-src/seccomp-unix-block.c",
17
17
  sourceSha256:
18
- "9dcde4aedc076fb44efc5cc8d843c7ba898f2a056b71931812d55ef99f1ed6b1",
18
+ "1599d24097d5e0bac5a22c6d38de98cae990eb18ee28d6be5faccf6db80dcf24",
19
19
  licenseSha256:
20
20
  "1210bc93eb85dd786c33192d5bcb7153a93922fa99fbc1512af6a7199cb41080",
21
21
  });
@@ -19,6 +19,13 @@ await writeFile(
19
19
  resolve(output, "fungi-seccomp.bpf.sha256"),
20
20
  `${seccompSha256}\n`,
21
21
  );
22
+ const workspaceSeccompSha256 = createHash("sha256")
23
+ .update(await readFile(resolve(output, "fungi-seccomp-workspace.bpf")))
24
+ .digest("hex");
25
+ await writeFile(
26
+ resolve(output, "fungi-seccomp-workspace.bpf.sha256"),
27
+ `${workspaceSeccompSha256}\n`,
28
+ );
22
29
  await copyFile(
23
30
  "/source/vendor/LICENSE.sandbox-runtime",
24
31
  resolve(output, "LICENSE.sandbox-runtime"),
@@ -47,7 +54,11 @@ await writeFile(
47
54
  platform,
48
55
  upstream: bwrapPin,
49
56
  sha256,
50
- seccomp: { generator: seccompPin, sha256: seccompSha256 },
57
+ seccomp: {
58
+ generator: seccompPin,
59
+ readOnlySha256: seccompSha256,
60
+ workspaceSha256: workspaceSeccompSha256,
61
+ },
51
62
  packages: [
52
63
  {
53
64
  name: "sandbox-runtime-seccomp-generator",
@@ -157,7 +157,8 @@ int main(int argc, char *argv[]) {
157
157
  const char *output_file = argv[1];
158
158
  const char *arch_name = (argc >= 3) ? argv[2] : NULL;
159
159
  const char *rule_set = (argc == 4) ? argv[3] : "unix";
160
- if (strcmp(rule_set, "unix") != 0 && strcmp(rule_set, "namespaces") != 0) {
160
+ if (strcmp(rule_set, "unix") != 0 && strcmp(rule_set, "workspace") != 0 &&
161
+ strcmp(rule_set, "namespaces") != 0) {
161
162
  fprintf(stderr, "Error: Unknown rule set '%s'\n", rule_set);
162
163
  return 1;
163
164
  }
@@ -209,12 +210,14 @@ int main(int argc, char *argv[]) {
209
210
  * int, so the kernel ignores the upper 32 bits of the register. A plain
210
211
  * SCMP_CMP_EQ would compare all 64 bits and miss calls where the upper bits
211
212
  * are set. */
212
- rc = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(socket), 1,
213
- SCMP_A0(SCMP_CMP_MASKED_EQ, 0xffffffff, AF_UNIX));
214
- if (rc < 0) {
215
- fprintf(stderr, "Error: Failed to add seccomp rule: %s\n", strerror(-rc));
216
- seccomp_release(ctx);
217
- return 1;
213
+ if (strcmp(rule_set, "unix") == 0) {
214
+ rc = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(socket), 1,
215
+ SCMP_A0(SCMP_CMP_MASKED_EQ, 0xffffffff, AF_UNIX));
216
+ if (rc < 0) {
217
+ fprintf(stderr, "Error: Failed to add seccomp rule: %s\n", strerror(-rc));
218
+ seccomp_release(ctx);
219
+ return 1;
220
+ }
218
221
  }
219
222
 
220
223
  /* Add rule to block socket(AF_VSOCK, ...) */
@@ -13,10 +13,11 @@
13
13
  "repository": "https://github.com/anthropic-experimental/sandbox-runtime",
14
14
  "revision": "38d1aee29c72035f1dba1337ac89b03a2f655d26",
15
15
  "source": "vendor/seccomp-src/seccomp-unix-block.c",
16
- "sourceSha256": "9dcde4aedc076fb44efc5cc8d843c7ba898f2a056b71931812d55ef99f1ed6b1",
16
+ "sourceSha256": "1599d24097d5e0bac5a22c6d38de98cae990eb18ee28d6be5faccf6db80dcf24",
17
17
  "licenseSha256": "1210bc93eb85dd786c33192d5bcb7153a93922fa99fbc1512af6a7199cb41080"
18
18
  },
19
- "sha256": "38112f9cf994bed2ed962fae63e803c41874732ab032088b2c1ea3d713e51c11"
19
+ "readOnlySha256": "38112f9cf994bed2ed962fae63e803c41874732ab032088b2c1ea3d713e51c11",
20
+ "workspaceSha256": "e46f1045f85fcf14f91fae8654bba6a0c87f57e3b92f60bfc16008dbf7dc87c8"
20
21
  },
21
22
  "packages": [
22
23
  {
Binary file
@@ -0,0 +1 @@
1
+ e46f1045f85fcf14f91fae8654bba6a0c87f57e3b92f60bfc16008dbf7dc87c8