@fungi.computer/truffle 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/private-files-patch.js +3 -17
- package/dist/private-invocation-computer.d.ts +224 -12
- package/dist/private-invocation-computer.js +162 -22
- package/dist/private-invocation-files.d.ts +3 -2
- package/dist/private-invocation-files.js +22 -3
- package/dist/private-local-agent-control.d.ts +2 -2
- package/dist/private-local-bwrap.d.ts +9 -7
- package/dist/private-local-bwrap.js +26 -21
- package/dist/private-local-computer-processes.d.ts +15 -0
- package/dist/private-local-computer-processes.js +13 -0
- package/dist/private-local-containment.d.ts +15 -13
- package/dist/private-local-containment.js +49 -65
- package/dist/private-local-delegation.d.ts +3 -3
- package/dist/private-local-delegation.js +32 -19
- package/dist/private-local-exec-review.d.ts +12 -0
- package/dist/private-local-exec-review.js +62 -0
- package/dist/private-local-exec-rules.d.ts +16 -0
- package/dist/private-local-exec-rules.js +441 -0
- package/dist/private-local-git-exclude.js +25 -15
- package/dist/private-local-linux-sandbox.d.ts +5 -5
- package/dist/private-local-linux-sandbox.js +63 -30
- package/dist/private-local-mail.d.ts +2 -2
- package/dist/private-local-mesh-links.d.ts +4 -1
- package/dist/private-local-mesh-links.js +50 -3
- package/dist/private-local-native-digests.js +1 -1
- package/dist/private-local-native-install-error.d.ts +2 -0
- package/dist/private-local-native-install-error.js +10 -0
- package/dist/private-local-native-staging.d.ts +7 -8
- package/dist/private-local-native-staging.js +132 -43
- package/dist/private-local-permission-mode.d.ts +10 -0
- package/dist/private-local-permission-mode.js +18 -0
- package/dist/private-local-runtime-host.d.ts +28 -5
- package/dist/private-local-runtime-host.js +404 -66
- package/dist/private-local-sandbox-policy.d.ts +30 -11
- package/dist/private-local-sandbox-policy.js +223 -373
- package/dist/private-local-sandbox.d.ts +30 -25
- package/dist/private-local-sandbox.js +190 -100
- package/dist/private-local-seatbelt.d.ts +7 -13
- package/dist/private-local-seatbelt.js +55 -32
- package/dist/private-local-service-contract.d.ts +5 -4
- package/dist/private-local-service-discovery.d.ts +19 -7
- package/dist/private-local-service-discovery.js +4 -3
- package/dist/private-local-service-listener.js +40 -2
- package/dist/private-local-service-log.d.ts +18 -0
- package/dist/private-local-service-log.js +6 -0
- package/dist/private-local-service-owner.d.ts +18 -10
- package/dist/private-local-service-owner.js +119 -25
- package/dist/private-local-service.js +30 -3
- package/dist/private-local-session-discovery.d.ts +2 -1
- package/dist/private-local-session-discovery.js +18 -6
- package/dist/private-mcp-form-contract.d.ts +10 -1
- package/dist/private-mcp-form-contract.js +10 -1
- package/dist/private-mcp-form-session.d.ts +39 -3
- package/dist/private-mcp-form-session.js +28 -5
- package/dist/private-oyster-mcp-auth.d.ts +1 -1
- package/dist/private-oyster-mcp-auth.js +139 -7
- package/dist/private-provider-rejection.js +1 -1
- package/dist/seatbelt/safe-base.sbpl +2 -2
- package/native/bin/bwrap-build-source/Dockerfile +2 -0
- package/native/bin/bwrap-build-source/README.md +6 -6
- package/native/bin/bwrap-build-source/pin.mjs +2 -2
- package/native/bin/bwrap-build-source/record.mjs +12 -1
- package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +10 -7
- package/native/bin/bwrap-build.json +3 -2
- package/native/bin/fungi-apply-patch +0 -0
- package/native/bin/fungi-seccomp-workspace.bpf +0 -0
- package/native/bin/fungi-seccomp-workspace.bpf.sha256 +1 -0
- package/package.json +23 -17
|
@@ -13,9 +13,12 @@ export { AgentArchiveRefused } from "@fungi.computer/shiitake/agent-control";
|
|
|
13
13
|
import { newWebSocketRpcSession } from "@fungi.computer/capnweb";
|
|
14
14
|
import { runPrivateLocalAuthRemote, } from "./private-local-auth-rpc.js";
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
16
18
|
import { fileURLToPath } from "node:url";
|
|
17
19
|
import { setTimeout as delay } from "node:timers/promises";
|
|
18
20
|
import { readInstalledLocalClientBuildId } from "./private-local-client-version.js";
|
|
21
|
+
import { nativePackageStartupError } from "./private-local-native-install-error.js";
|
|
19
22
|
export { readInstalledLocalClientBuildId } from "./private-local-client-version.js";
|
|
20
23
|
export { privateLocalServiceVersion } from "./private-local-service-discovery.js";
|
|
21
24
|
import { createShiitakeClient, parseSessionSnapshot, } from "@fungi.computer/shiitake/client";
|
|
@@ -220,6 +223,7 @@ export function createPrivateLocalServiceManager(options = {}) {
|
|
|
220
223
|
request.signal?.throwIfAborted();
|
|
221
224
|
const directory = await serviceDirectory(selectedDirectory);
|
|
222
225
|
const release = await acquireServiceLock(directory, "startup");
|
|
226
|
+
let nativeRefusalDirectory;
|
|
223
227
|
try {
|
|
224
228
|
const registration = await readServiceRegistration(directory);
|
|
225
229
|
const existing = registration === undefined
|
|
@@ -239,6 +243,8 @@ export function createPrivateLocalServiceManager(options = {}) {
|
|
|
239
243
|
: [];
|
|
240
244
|
const serviceEntry = bundledCli ??
|
|
241
245
|
fileURLToPath(new URL("./private-local-service-process.js", import.meta.url));
|
|
246
|
+
nativeRefusalDirectory = await mkdtemp(join(directory, ".native-startup-refusal-"));
|
|
247
|
+
const nativeRefusal = join(nativeRefusalDirectory, "native-package");
|
|
242
248
|
const child = spawn(options.executable ?? process.execPath, [
|
|
243
249
|
...bunFlags,
|
|
244
250
|
serviceEntry,
|
|
@@ -249,7 +255,11 @@ export function createPrivateLocalServiceManager(options = {}) {
|
|
|
249
255
|
stdio: "ignore",
|
|
250
256
|
// Service startup retains its existing inherited inputs. This is
|
|
251
257
|
// not a Computer exec child: only its selected host HOME is pinned.
|
|
252
|
-
env: {
|
|
258
|
+
env: {
|
|
259
|
+
...process.env,
|
|
260
|
+
HOME: root.home,
|
|
261
|
+
FUNGI_NATIVE_STARTUP_REFUSAL: nativeRefusal,
|
|
262
|
+
},
|
|
253
263
|
});
|
|
254
264
|
let failure;
|
|
255
265
|
let exited = false;
|
|
@@ -272,8 +282,18 @@ export function createPrivateLocalServiceManager(options = {}) {
|
|
|
272
282
|
throw new Error(`The started Local service reports version ${ready.version}, not this client's ${expectedVersion}`);
|
|
273
283
|
return accept(ready);
|
|
274
284
|
}
|
|
275
|
-
if (hasExited())
|
|
285
|
+
if (hasExited()) {
|
|
286
|
+
const refusal = await readFile(nativeRefusal, "utf8").catch((cause) => {
|
|
287
|
+
if (cause instanceof Error &&
|
|
288
|
+
"code" in cause &&
|
|
289
|
+
cause.code === "ENOENT")
|
|
290
|
+
return undefined;
|
|
291
|
+
throw cause;
|
|
292
|
+
});
|
|
293
|
+
if (refusal === "native-package")
|
|
294
|
+
throw nativePackageStartupError();
|
|
276
295
|
throw new Error("Local service exited before its handshake");
|
|
296
|
+
}
|
|
277
297
|
await delay(100, undefined, { signal: request.signal });
|
|
278
298
|
}
|
|
279
299
|
child.kill("SIGTERM");
|
|
@@ -284,7 +304,14 @@ export function createPrivateLocalServiceManager(options = {}) {
|
|
|
284
304
|
: `Local service did not become ready; child ${String(child.pid)} is still running`);
|
|
285
305
|
}
|
|
286
306
|
finally {
|
|
287
|
-
|
|
307
|
+
// The token is per spawn and cannot survive a failed or successful start.
|
|
308
|
+
try {
|
|
309
|
+
if (nativeRefusalDirectory !== undefined)
|
|
310
|
+
await rm(nativeRefusalDirectory, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await release();
|
|
314
|
+
}
|
|
288
315
|
}
|
|
289
316
|
}
|
|
290
317
|
/** POST one selection to `service`; every refusal is a typed failure. */
|
|
@@ -32,7 +32,8 @@ export declare function readPrivateLocalAgentProfile(path: string): {
|
|
|
32
32
|
name: string | undefined;
|
|
33
33
|
createdBy: string | null;
|
|
34
34
|
archived: boolean;
|
|
35
|
-
|
|
35
|
+
mode: "read-only" | "workspace" | "full-access";
|
|
36
|
+
writableFolders: string[];
|
|
36
37
|
folder: string;
|
|
37
38
|
model: {
|
|
38
39
|
readonly providerId: string;
|
|
@@ -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
|
-
|
|
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 (![
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,15 @@ 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
|
+
readonly alwaysAllowCoverage: Schema.optionalKey<Schema.String>;
|
|
189
|
+
readonly alwaysAllowUnavailableReason: Schema.optionalKey<Schema.String>;
|
|
190
|
+
}>>;
|
|
182
191
|
}>>;
|
|
183
192
|
}>;
|
|
184
193
|
};
|
|
@@ -32,6 +32,15 @@ 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
|
+
alwaysAllowCoverage: Schema.optionalKey(Schema.String),
|
|
42
|
+
alwaysAllowUnavailableReason: Schema.optionalKey(Schema.String),
|
|
43
|
+
})),
|
|
35
44
|
});
|
|
36
45
|
/** The existing Session extension slot carries only display-safe form data. */
|
|
37
46
|
export const mcpFormApprovalContract = {
|
|
@@ -41,7 +50,7 @@ export const mcpFormApprovalContract = {
|
|
|
41
50
|
scope: "session",
|
|
42
51
|
input: Schema.Struct({
|
|
43
52
|
id: Schema.String,
|
|
44
|
-
decision:
|
|
53
|
+
decision: Schema.Literals(["approve", "deny", "always-allow"]),
|
|
45
54
|
expectedRevision: ApprovalAnswerInputSchema.fields.expectedRevision,
|
|
46
55
|
}),
|
|
47
56
|
output: answerResult,
|
|
@@ -1,6 +1,23 @@
|
|
|
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
|
+
alwaysAllowCoverage?: string;
|
|
11
|
+
alwaysAllowUnavailableReason?: string;
|
|
12
|
+
};
|
|
13
|
+
type Pending = {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
approval: ApprovalView;
|
|
16
|
+
approvals: Approvals;
|
|
17
|
+
answering: boolean;
|
|
18
|
+
review: LocalApprovalReview | undefined;
|
|
19
|
+
alwaysAllow: ((selected: "prefix" | false) => void) | undefined;
|
|
20
|
+
};
|
|
4
21
|
/** Host-owned references to canonical approvals; no suspended tool or second ledger. */
|
|
5
22
|
export declare function createMcpFormSession(): {
|
|
6
23
|
extension: import("@fungi.computer/shiitake/extensions").DefinedExtensionV0<{
|
|
@@ -8,7 +25,7 @@ export declare function createMcpFormSession(): {
|
|
|
8
25
|
scope: "session";
|
|
9
26
|
input: import("effect/Schema").Struct<{
|
|
10
27
|
readonly id: import("effect/Schema").String;
|
|
11
|
-
readonly decision: import("effect/Schema").Literals<readonly ["approve", "deny"]>;
|
|
28
|
+
readonly decision: import("effect/Schema").Literals<readonly ["approve", "deny", "always-allow"]>;
|
|
12
29
|
readonly expectedRevision: import("effect/Schema").Int;
|
|
13
30
|
}>;
|
|
14
31
|
output: import("effect/Schema").Union<readonly [import("effect/Schema").Struct<{
|
|
@@ -108,6 +125,15 @@ export declare function createMcpFormSession(): {
|
|
|
108
125
|
readonly canAnswer: boolean;
|
|
109
126
|
readonly canSetAutoApprove: boolean;
|
|
110
127
|
};
|
|
128
|
+
readonly review?: {
|
|
129
|
+
readonly command: string;
|
|
130
|
+
readonly cwd: string;
|
|
131
|
+
readonly justification: string;
|
|
132
|
+
readonly prefix?: readonly string[];
|
|
133
|
+
readonly warning?: string;
|
|
134
|
+
readonly alwaysAllowCoverage?: string;
|
|
135
|
+
readonly alwaysAllowUnavailableReason?: string;
|
|
136
|
+
};
|
|
111
137
|
}[];
|
|
112
138
|
}, {
|
|
113
139
|
readonly pending: readonly {
|
|
@@ -153,9 +179,18 @@ export declare function createMcpFormSession(): {
|
|
|
153
179
|
readonly canAnswer: boolean;
|
|
154
180
|
readonly canSetAutoApprove: boolean;
|
|
155
181
|
};
|
|
182
|
+
readonly review?: {
|
|
183
|
+
readonly command: string;
|
|
184
|
+
readonly cwd: string;
|
|
185
|
+
readonly justification: string;
|
|
186
|
+
readonly prefix?: readonly string[];
|
|
187
|
+
readonly warning?: string;
|
|
188
|
+
readonly alwaysAllowCoverage?: string;
|
|
189
|
+
readonly alwaysAllowUnavailableReason?: string;
|
|
190
|
+
};
|
|
156
191
|
}[];
|
|
157
192
|
}>;
|
|
158
|
-
presentApproval: (sessionId: string, submitted: PendingApproval, approvals: Approvals) => Promise<{
|
|
193
|
+
presentApproval: (sessionId: string, submitted: PendingApproval, approvals: Approvals, review?: Pending["review"], alwaysAllow?: (selected: "prefix" | false) => void) => Promise<{
|
|
159
194
|
readonly kind: "refused";
|
|
160
195
|
readonly reason: "not-pending";
|
|
161
196
|
readonly id?: never;
|
|
@@ -170,3 +205,4 @@ export declare function createMcpFormSession(): {
|
|
|
170
205
|
}>;
|
|
171
206
|
bindSession: (sessionId: string, session: ShiitakeSession, attachment: LocalUiAttachment | undefined) => ShiitakeSession;
|
|
172
207
|
};
|
|
208
|
+
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
|
-
|
|
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]) =>
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
169
|
+
const path = options.credentialPath ?? capturePrivateLocalConfigRoot().credentialPath;
|
|
46
170
|
return {
|
|
47
171
|
async token() {
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
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
|
|
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}
|
|
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
|
|
7
|
-
;
|
|
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
|
|
@@ -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
|
|
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,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
/**
|
|
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
|
-
"
|
|
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: {
|
|
57
|
+
seccomp: {
|
|
58
|
+
generator: seccompPin,
|
|
59
|
+
readOnlySha256: seccompSha256,
|
|
60
|
+
workspaceSha256: workspaceSeccompSha256,
|
|
61
|
+
},
|
|
51
62
|
packages: [
|
|
52
63
|
{
|
|
53
64
|
name: "sandbox-runtime-seccomp-generator",
|