@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,6 +1,5 @@
1
- import { deterministicReplayKey } from "@fungi.computer/cairn";
2
1
  import { AgentArchiveRefused } from "@fungi.computer/shiitake/agent-control";
3
- import { createFleetDelegation, delegationProvenance, delegationReplyMeta, delegationSendMeta, } from "@fungi.computer/shiitake/fleet";
2
+ import { agentDelegationRequestKey, createFleetDelegation, delegationProvenance, delegationReplyMeta, delegationSendMeta, } from "@fungi.computer/shiitake/fleet";
4
3
  /** How long one Local delegation grant stays valid while it is admitted. */
5
4
  const grantLifetimeMs = 60_000;
6
5
  /**
@@ -32,21 +31,17 @@ export function createPrivateLocalDelegation(input) {
32
31
  }
33
32
  return agents;
34
33
  }
35
- function requestKey(task, sourceSessionId) {
36
- return deterministicReplayKey("local-agent-delegation", [
37
- input.agentId,
38
- sourceSessionId,
39
- task.agentId,
40
- task.sessionId,
41
- task.requestId,
42
- ]);
43
- }
44
34
  /** Send with one caller-owned identity; follow-up replies return here. */
45
35
  async function send(task, sourceSessionId, signal, invocation) {
46
36
  if (invocation === undefined)
47
37
  throw new Error("Delegation requires a tool invocation");
48
38
  const agents = await authorizedMembers(task.agentId, signal);
49
- const requestId = await requestKey(task, sourceSessionId);
39
+ const requestId = await agentDelegationRequestKey({
40
+ owner: { kind: "local", agentId: input.agentId },
41
+ sourceSessionId,
42
+ targetAgentId: task.agentId,
43
+ requestId: task.requestId,
44
+ });
50
45
  signal.throwIfAborted();
51
46
  if (task.delivery === "steer") {
52
47
  const opened = await input.directory.open(task.agentId, signal);
@@ -83,12 +78,21 @@ export function createPrivateLocalDelegation(input) {
83
78
  /** Resolve only this sender's receipt, then let the target own the pending guard. */
84
79
  async function recall(task, sourceSessionId, signal) {
85
80
  await authorizedMembers(task.agentId, signal);
86
- const requestId = await requestKey(task, sourceSessionId);
81
+ const requestId = await agentDelegationRequestKey({
82
+ owner: { kind: "local", agentId: input.agentId },
83
+ sourceSessionId,
84
+ targetAgentId: task.agentId,
85
+ requestId: task.requestId,
86
+ });
87
87
  signal.throwIfAborted();
88
88
  const opened = await input.directory.open(task.agentId, signal);
89
89
  try {
90
- const session = opened.agent.session(task.sessionId);
91
- const admitted = fleet.receipt(requestId) ?? (await session.withdraw(requestId));
90
+ const receipt = fleet.receipt(requestId);
91
+ if (receipt?.sessionId !== undefined &&
92
+ receipt.sessionId !== task.sessionId)
93
+ return { status: "unknown" };
94
+ const session = opened.agent.session(receipt?.sessionId ?? task.sessionId);
95
+ const admitted = receipt ?? (await session.withdraw(requestId));
92
96
  return admitted.status === "accepted"
93
97
  ? await session.cancel({
94
98
  commandId: admitted.commandId,
@@ -110,7 +114,11 @@ export function createPrivateLocalDelegation(input) {
110
114
  if (mail.to.agentId !== input.agentId && mesh === undefined)
111
115
  throw new Error("Local addressed mail recipient is not authorized");
112
116
  const agents = mesh ?? (await members(new AbortController().signal));
113
- if (!agents.some(({ agentId }) => agentId === mail.from.agentId))
117
+ const delegation = mail.replyTo.length === 0 ? fleet.authenticateReply(mail) : undefined;
118
+ const frozenUndeliverable = delegation?.reply.outcome === "undeliverable" &&
119
+ delegation.reply.agentId === mail.from.agentId;
120
+ if (!agents.some(({ agentId }) => agentId === mail.from.agentId) &&
121
+ !frozenUndeliverable)
114
122
  throw new Error("Local mail sender is outside this project");
115
123
  if (mail.replyTo.length > 0) {
116
124
  const [destination] = mail.replyTo;
@@ -120,13 +128,18 @@ export function createPrivateLocalDelegation(input) {
120
128
  throw new Error("Local delegated work must reply to its sender");
121
129
  return input.accept(mail, mail.meta);
122
130
  }
123
- const delegation = fleet.authenticateReply(mail);
131
+ if (mail.reply?.outcome === "accepted") {
132
+ const receipt = fleet.recordAcceptedNotice(mail);
133
+ if (receipt === undefined)
134
+ throw new Error("Local acceptance notice does not match sent work");
135
+ return receipt;
136
+ }
124
137
  if (delegation === undefined)
125
138
  throw new Error("Local reply does not match work this Agent sent");
126
139
  const replier = agents.find(({ agentId }) => agentId === delegation.reply.agentId);
127
- if (replier === undefined)
140
+ if (replier === undefined && !frozenUndeliverable)
128
141
  throw new Error("Local reply sender is outside this project");
129
- const meta = delegationReplyMeta(delegation, replier.name);
142
+ const meta = delegationReplyMeta(delegation, replier?.name ?? delegation.reply.agentId);
130
143
  return input.accept({ ...mail, meta }, meta, delegation.reply);
131
144
  }
132
145
  return { send, recall, receive, outstanding: fleet.outstanding };
@@ -0,0 +1,10 @@
1
+ import type { LocalApprovalReview } from "./private-mcp-form-session.js";
2
+ /** Build the host's review before the approval record is staged or displayed. */
3
+ export declare function localExecEscapeReview(request: {
4
+ command?: string | undefined;
5
+ executable?: string | undefined;
6
+ args?: string[] | undefined;
7
+ escape?: {
8
+ justification: string;
9
+ } | undefined;
10
+ }, cwd: string): LocalApprovalReview;
@@ -0,0 +1,50 @@
1
+ import { rulePrefix } from "./private-local-exec-rules.js";
2
+ const gitHookGrantSubcommands = new Set([
3
+ "add",
4
+ "branch",
5
+ "checkout",
6
+ "commit",
7
+ "diff",
8
+ "fetch",
9
+ "merge",
10
+ "pull",
11
+ "push",
12
+ "reset",
13
+ "restore",
14
+ "stash",
15
+ "status",
16
+ "switch",
17
+ "tag",
18
+ ]);
19
+ /** Build the host's review before the approval record is staged or displayed. */
20
+ export function localExecEscapeReview(request, cwd) {
21
+ if (request.escape === undefined)
22
+ throw new Error("An escape requires a justification");
23
+ const command = request.command ??
24
+ JSON.stringify([request.executable, ...(request.args ?? [])]);
25
+ const prefix = request.executable === undefined
26
+ ? undefined
27
+ : rulePrefix([request.executable, ...(request.args ?? [])]);
28
+ const review = {
29
+ command,
30
+ cwd,
31
+ justification: request.escape.justification,
32
+ };
33
+ if (prefix !== undefined) {
34
+ review.prefix = prefix;
35
+ if (prefix[0] === "git") {
36
+ const hookCapable = gitHookGrantSubcommands.has(prefix[1] ?? "");
37
+ review.warning =
38
+ (hookCapable
39
+ ? "Project hooks can run code as you under this grant. Only the hooksPath setting must pre-exist; this Agent can create missing hook files there. A configured fsmonitor path in the worktree may also be Agent-writable."
40
+ : "This command runs as you outside the sandbox.") +
41
+ (prefix[1] === "push"
42
+ ? " This grant can push all branches, force-update them, or delete remote branches."
43
+ : "") +
44
+ (["push", "fetch", "pull"].includes(prefix[1] ?? "")
45
+ ? " Remote URLs can invoke ssh with your HOME and keys."
46
+ : "");
47
+ }
48
+ }
49
+ return review;
50
+ }
@@ -0,0 +1,14 @@
1
+ /** Offer only direct, narrow grants; extension installs require a fresh card. */
2
+ export declare function rulePrefix(argv: readonly string[]): string[] | undefined;
3
+ /** One host-owned project file; every Agent in the project sees the same rules. */
4
+ export declare function localExecRules(project: string): {
5
+ list: () => string[][];
6
+ add: (prefix: readonly string[]) => void;
7
+ remove: (prefix: readonly string[]) => void;
8
+ match: (argv: readonly string[], configuredRemotes?: ReadonlySet<string>) => {
9
+ ruleId: import("@fungi.computer/cairn").CairnId<"local-exec-rules", "rule", 1>;
10
+ } | undefined;
11
+ matches: (argv: readonly string[], configuredRemotes?: ReadonlySet<string>) => boolean;
12
+ };
13
+ /** Human CLI management only; no model-facing capability exposes this writer. */
14
+ export declare function manageLocalExecRules(project: string, action: "list" | "remove", prefix?: readonly string[]): readonly (readonly string[])[];
@@ -0,0 +1,420 @@
1
+ import { lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { correlationKey, defineIdFamily } from "@fungi.computer/cairn";
4
+ import { z } from "zod";
5
+ const prefixSchema = z.array(z.string().min(1).max(4096)).min(1).max(32);
6
+ const projectRuleIds = defineIdFamily({
7
+ family: "local-exec-rules",
8
+ version: 1,
9
+ kinds: { rule: { prefix: "local_exec_rule" } },
10
+ });
11
+ const projectRuleSchema = z.strictObject({
12
+ id: projectRuleIds.rule.schema,
13
+ prefix: prefixSchema,
14
+ });
15
+ const projectRulesSchema = z.strictObject({
16
+ version: z.literal(2),
17
+ rules: z.array(projectRuleSchema).max(256),
18
+ });
19
+ const gitOptions = {
20
+ add: {
21
+ flags: [
22
+ "--",
23
+ "-A",
24
+ "--all",
25
+ "-u",
26
+ "--update",
27
+ "-p",
28
+ "--patch",
29
+ "-n",
30
+ "--dry-run",
31
+ ],
32
+ values: [],
33
+ },
34
+ branch: {
35
+ flags: [
36
+ "--",
37
+ "-a",
38
+ "--all",
39
+ "-d",
40
+ "-D",
41
+ "--delete",
42
+ "-r",
43
+ "--remotes",
44
+ "--list",
45
+ "--show-current",
46
+ "-v",
47
+ ],
48
+ values: ["-m", "-M"],
49
+ },
50
+ checkout: {
51
+ flags: ["--", "-f", "--force", "--detach"],
52
+ values: ["-b", "-B"],
53
+ },
54
+ commit: {
55
+ flags: [
56
+ "--",
57
+ "-a",
58
+ "--all",
59
+ "--amend",
60
+ "--no-edit",
61
+ "--allow-empty",
62
+ "-s",
63
+ "--signoff",
64
+ ],
65
+ values: ["-m", "--message"],
66
+ },
67
+ diff: {
68
+ flags: [
69
+ "--",
70
+ "--stat",
71
+ "--cached",
72
+ "--staged",
73
+ "--name-only",
74
+ "--name-status",
75
+ "--quiet",
76
+ "--check",
77
+ ],
78
+ values: [],
79
+ },
80
+ fetch: {
81
+ flags: [
82
+ "--",
83
+ "--all",
84
+ "--prune",
85
+ "-p",
86
+ "--tags",
87
+ "-t",
88
+ "-q",
89
+ "--quiet",
90
+ "-v",
91
+ "--verbose",
92
+ "-n",
93
+ "--dry-run",
94
+ ],
95
+ values: [],
96
+ },
97
+ log: {
98
+ flags: [
99
+ "--",
100
+ "--oneline",
101
+ "--all",
102
+ "--decorate",
103
+ "--no-decorate",
104
+ "--stat",
105
+ "--name-only",
106
+ "--graph",
107
+ "-1",
108
+ "-2",
109
+ "-5",
110
+ "-10",
111
+ ],
112
+ values: [],
113
+ },
114
+ merge: {
115
+ flags: [
116
+ "--",
117
+ "--abort",
118
+ "--continue",
119
+ "--no-ff",
120
+ "--ff-only",
121
+ "--squash",
122
+ "--no-commit",
123
+ ],
124
+ values: [],
125
+ },
126
+ pull: {
127
+ flags: [
128
+ "--",
129
+ "--ff-only",
130
+ "--no-rebase",
131
+ "--no-commit",
132
+ "--no-edit",
133
+ "-q",
134
+ "--quiet",
135
+ "-v",
136
+ "--verbose",
137
+ ],
138
+ values: [],
139
+ },
140
+ push: {
141
+ flags: [
142
+ "--",
143
+ "-u",
144
+ "--set-upstream",
145
+ "--all",
146
+ "--tags",
147
+ "--delete",
148
+ "-d",
149
+ "--force-with-lease",
150
+ "--dry-run",
151
+ "-n",
152
+ "--follow-tags",
153
+ ],
154
+ values: [],
155
+ },
156
+ remote: { flags: ["-v", "--verbose"], values: [] },
157
+ reset: {
158
+ flags: ["--", "--soft", "--mixed", "--hard", "--keep", "-q", "--quiet"],
159
+ values: [],
160
+ },
161
+ restore: { flags: ["--", "--staged", "--worktree"], values: [] },
162
+ "rev-parse": {
163
+ flags: [
164
+ "--",
165
+ "--verify",
166
+ "--short",
167
+ "--abbrev-ref",
168
+ "--is-inside-work-tree",
169
+ "--show-toplevel",
170
+ "--git-dir",
171
+ "--absolute-git-dir",
172
+ "--show-prefix",
173
+ ],
174
+ values: [],
175
+ },
176
+ show: {
177
+ flags: ["--", "--stat", "--oneline", "--name-only", "-1"],
178
+ values: [],
179
+ },
180
+ stash: {
181
+ flags: [
182
+ "--",
183
+ "-u",
184
+ "--include-untracked",
185
+ "-a",
186
+ "--all",
187
+ "-k",
188
+ "--keep-index",
189
+ "-p",
190
+ "--patch",
191
+ ],
192
+ values: ["-m", "--message"],
193
+ },
194
+ status: {
195
+ flags: [
196
+ "--",
197
+ "-s",
198
+ "--short",
199
+ "-b",
200
+ "--branch",
201
+ "--porcelain",
202
+ "--ignored",
203
+ ],
204
+ values: [],
205
+ },
206
+ switch: { flags: ["--", "--detach"], values: ["-c", "-C"] },
207
+ tag: {
208
+ flags: ["--", "-l", "--list", "-d", "--delete", "-a", "--annotate"],
209
+ values: ["-m", "--message"],
210
+ },
211
+ };
212
+ // Git aliases may run shell commands; never grant a global option or alias.
213
+ const gitProfiles = new Map(Object.entries(gitOptions));
214
+ const gitBuiltins = new Set(gitProfiles.keys());
215
+ const shellExecutables = new Set(["sh", "bash", "zsh", "dash", "fish", "ksh"]);
216
+ const commandWrappers = new Set([
217
+ "env",
218
+ "nice",
219
+ "nohup",
220
+ "timeout",
221
+ "xargs",
222
+ "sudo",
223
+ "doas",
224
+ "command",
225
+ "exec",
226
+ "stdbuf",
227
+ "time",
228
+ "setsid",
229
+ "chroot",
230
+ "flock",
231
+ "busybox",
232
+ ]);
233
+ function unsafeExecutable(executable) {
234
+ const name = basename(executable).toLowerCase();
235
+ return (shellExecutables.has(name) ||
236
+ commandWrappers.has(name) ||
237
+ (name === "git" && executable !== "git") ||
238
+ (name === "fungi" && executable !== "fungi"));
239
+ }
240
+ function validPrefix(prefix) {
241
+ return (prefix[0] !== undefined &&
242
+ !prefix[0].startsWith(".") &&
243
+ !prefix[0].includes("/") &&
244
+ !prefix[0].includes("\\") &&
245
+ !unsafeExecutable(prefix[0]) &&
246
+ !(basename(prefix[0]) === "fungi" &&
247
+ (prefix.length === 1 || prefix[1] === "extension")) &&
248
+ (basename(prefix[0]) !== "git" ||
249
+ (prefix[0] === "git" &&
250
+ prefix.length === 2 &&
251
+ gitBuiltins.has(prefix[1] ?? ""))));
252
+ }
253
+ /** Offer only direct, narrow grants; extension installs require a fresh card. */
254
+ export function rulePrefix(argv) {
255
+ if (argv.length === 0 ||
256
+ argv[0]?.startsWith(".") ||
257
+ argv[0]?.includes("/") ||
258
+ argv[0]?.includes("\\") ||
259
+ unsafeExecutable(argv[0] ?? ""))
260
+ return undefined;
261
+ if (basename(argv[0] ?? "") === "git") {
262
+ const prefix = argv.slice(0, 2);
263
+ return validPrefix(prefix) ? prefix : undefined;
264
+ }
265
+ if (basename(argv[0] ?? "") === "fungi" &&
266
+ argv[1] === "extension" &&
267
+ argv[2] === "add")
268
+ return undefined;
269
+ const parsed = prefixSchema.safeParse(argv.slice(0, 2));
270
+ return parsed.success && validPrefix(parsed.data) ? parsed.data : undefined;
271
+ }
272
+ function safeRemote(remote, configuredRemotes) {
273
+ if (remote === undefined)
274
+ return true;
275
+ if (/^(https?|ssh|git):\/\//u.test(remote))
276
+ return true;
277
+ // A bare token is also a relative path. Only the protected repository's
278
+ // configured remote names can authorize it, regardless of filesystem state.
279
+ return (/^[A-Za-z][A-Za-z0-9_.-]*$/u.test(remote) &&
280
+ configuredRemotes?.has(remote) === true);
281
+ }
282
+ /** A Git prefix never authorizes options outside the reviewed subcommand profile. */
283
+ function safeGitCommand(argv, configuredRemotes) {
284
+ if (argv[0] !== "git")
285
+ return false;
286
+ const subcommand = argv[1];
287
+ const profile = gitProfiles.get(subcommand ?? "");
288
+ if (profile === undefined)
289
+ return false;
290
+ const operands = [];
291
+ for (let index = 2; index < argv.length; index++) {
292
+ const arg = argv[index];
293
+ if (arg === undefined)
294
+ return false;
295
+ if (arg === "--") {
296
+ operands.push(...argv.slice(index + 1));
297
+ break;
298
+ }
299
+ if (arg.startsWith("-") && arg !== "-") {
300
+ if (profile.flags.some((option) => option === arg))
301
+ continue;
302
+ if (!profile.values.some((option) => option === arg))
303
+ return false;
304
+ // A missing value must not turn a following option into an operand.
305
+ if (argv[index + 1] === undefined || argv[index + 1]?.startsWith("-"))
306
+ return false;
307
+ index++;
308
+ }
309
+ else {
310
+ operands.push(arg);
311
+ }
312
+ }
313
+ if (subcommand === "remote") {
314
+ if (operands.length === 0)
315
+ return true;
316
+ if (operands.length > 2 ||
317
+ (operands[0] !== "show" && operands[0] !== "get-url"))
318
+ return false;
319
+ return safeRemote(operands[1], configuredRemotes);
320
+ }
321
+ if (subcommand === "push" || subcommand === "fetch" || subcommand === "pull")
322
+ return safeRemote(operands[0], configuredRemotes);
323
+ return true;
324
+ }
325
+ function projectFile(project) {
326
+ const directory = join(project, ".fungi");
327
+ const marker = lstatSync(directory, { throwIfNoEntry: false });
328
+ if (marker !== undefined && !marker.isDirectory())
329
+ throw new Error("Project .fungi must be a real directory");
330
+ return { directory, file: join(directory, "exec-rules.json") };
331
+ }
332
+ function readProject(project) {
333
+ const file = join(project, ".fungi", "exec-rules.json");
334
+ try {
335
+ return readProjectFile(project);
336
+ }
337
+ catch (cause) {
338
+ throw new Error(`Invalid ${file}; delete it and reapprove Always-allow rules`, { cause });
339
+ }
340
+ }
341
+ function readProjectFile(project) {
342
+ const { file } = projectFile(project);
343
+ const entry = lstatSync(file, { throwIfNoEntry: false });
344
+ if (entry === undefined)
345
+ return { version: 2, rules: [] };
346
+ if (!entry.isFile() || entry.nlink !== 1)
347
+ throw new Error("Project exec rules must be an independent regular file");
348
+ const stored = projectRulesSchema.parse(JSON.parse(readFileSync(file, "utf8")));
349
+ if (stored.rules.some((rule) => !validPrefix(rule.prefix)))
350
+ throw new Error("Project exec rules contain an unsafe prefix");
351
+ return stored;
352
+ }
353
+ /** Fail closed on contention instead of overwriting another process's grant. */
354
+ function lockedUpdate(project, update) {
355
+ const { directory } = projectFile(project);
356
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
357
+ const lock = join(directory, "exec-rules.lock");
358
+ mkdirSync(lock, { mode: 0o700 });
359
+ try {
360
+ update();
361
+ }
362
+ finally {
363
+ rmdirSync(lock);
364
+ }
365
+ }
366
+ function writeProject(project, rules) {
367
+ const content = projectRulesSchema.parse({ version: 2, rules });
368
+ const { directory, file } = projectFile(project);
369
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
370
+ const temp = join(directory, "exec-rules-" + correlationKey() + ".tmp");
371
+ writeFileSync(temp, JSON.stringify(content), { flag: "wx", mode: 0o600 });
372
+ renameSync(temp, file);
373
+ }
374
+ /** One host-owned project file; every Agent in the project sees the same rules. */
375
+ export function localExecRules(project) {
376
+ const list = () => readProject(project).rules.map((rule) => rule.prefix);
377
+ const add = (prefix) => {
378
+ const parsed = prefixSchema.parse(prefix);
379
+ if (!validPrefix(parsed))
380
+ throw new Error("Unsafe Always-allow prefix");
381
+ lockedUpdate(project, () => {
382
+ const existing = readProject(project).rules;
383
+ if (!existing.some((rule) => JSON.stringify(rule.prefix) === JSON.stringify(parsed)))
384
+ writeProject(project, [
385
+ ...existing,
386
+ { id: projectRuleIds.rule.generate(), prefix: parsed },
387
+ ]);
388
+ });
389
+ };
390
+ const remove = (prefix) => {
391
+ const parsed = prefixSchema.parse(prefix);
392
+ lockedUpdate(project, () => {
393
+ writeProject(project, readProject(project).rules.filter((rule) => JSON.stringify(rule.prefix) !== JSON.stringify(parsed)));
394
+ });
395
+ };
396
+ const match = (argv, configuredRemotes) => {
397
+ if (unsafeExecutable(argv[0] ?? "") ||
398
+ (basename(argv[0] ?? "") === "fungi" &&
399
+ argv[1] === "extension" &&
400
+ argv[2] === "add"))
401
+ return undefined;
402
+ if (argv[0] === "git" && !safeGitCommand(argv, configuredRemotes))
403
+ return undefined;
404
+ const matched = readProject(project).rules.find((rule) => rule.prefix.every((part, index) => argv[index] === part));
405
+ // The stored Cairn id identifies the rule without exposing its prefix.
406
+ return matched === undefined ? undefined : { ruleId: matched.id };
407
+ };
408
+ const matches = (argv, configuredRemotes) => match(argv, configuredRemotes) !== undefined;
409
+ return { list, add, remove, match, matches };
410
+ }
411
+ /** Human CLI management only; no model-facing capability exposes this writer. */
412
+ export function manageLocalExecRules(project, action, prefix) {
413
+ const rules = localExecRules(project);
414
+ if (action === "remove") {
415
+ if (prefix === undefined)
416
+ throw new Error("A prefix is required");
417
+ rules.remove(prefix);
418
+ }
419
+ return rules.list();
420
+ }
@@ -1,25 +1,35 @@
1
- import { execFile as execFileCallback } from "node:child_process";
2
1
  import { appendFile, mkdir, readFile } from "node:fs/promises";
3
- import { resolve, join } from "node:path";
4
- import { promisify } from "node:util";
5
- const execFile = promisify(execFileCallback);
2
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { gitDirectories } from "./private-local-sandbox-policy.js";
6
5
  const excludeEntry = ".fungi/";
7
6
  /** Keep generated project state out of Git without changing tracked ignore rules. */
8
7
  export async function ensurePrivateLocalGitExclude(projectRoot) {
9
- let common;
10
- try {
11
- const result = await execFile("git", ["rev-parse", "--git-common-dir"], {
12
- cwd: projectRoot,
13
- });
14
- common = resolve(projectRoot, result.stdout.trim());
8
+ const root = realpathSync(projectRoot);
9
+ // Never append as the host to a common dir selected by a planted .git.
10
+ // Git's read-only answer alone cannot authorize a host write.
11
+ const git = gitDirectories(root, root);
12
+ if (git === undefined)
13
+ return;
14
+ const marker = join(root, ".git");
15
+ const entry = lstatSync(marker, { throwIfNoEntry: false });
16
+ if (entry?.isDirectory()) {
17
+ if (realpathSync(marker) !== git.common || git.worktree !== git.common)
18
+ return;
15
19
  }
16
- catch (cause) {
17
- // A non-Git project still keeps its local Fungi store.
18
- if (cause instanceof Error && "code" in cause && cause.code === 128)
20
+ else if (entry?.isFile()) {
21
+ if (dirname(git.worktree) !== join(git.common, "worktrees"))
22
+ return;
23
+ // A real linked worktree's Git-owned backlink must point to this marker.
24
+ const backlink = readFileSync(join(git.worktree, "gitdir"), "utf8");
25
+ if (!backlink.endsWith("\n") ||
26
+ backlink.split("\n").length !== 2 ||
27
+ resolve(git.worktree, backlink.trimEnd()) !== marker)
19
28
  return;
20
- throw cause;
21
29
  }
22
- const info = join(common, "info");
30
+ else
31
+ return;
32
+ const info = join(git.common, "info");
23
33
  await mkdir(info, { recursive: true });
24
34
  const exclude = join(info, "exclude");
25
35
  const current = await readFile(exclude, "utf8").catch((cause) => {
@@ -1,7 +1,9 @@
1
- import { type LocalSandboxPolicy } from "./private-local-sandbox-policy.js";
2
- import type { LinuxSandboxPayload } from "./private-local-bwrap.js";
1
+ import { type LinuxSandboxProfile } from "./private-local-sandbox-policy.js";
3
2
  /** Ordinary upstream bwrap argv. There is no approval or bare-command path. */
4
- export declare function linuxSandboxCommand(policy: LocalSandboxPolicy, payload: LinuxSandboxPayload, cwd: string, argv: readonly [string, ...string[]], host?: NodeJS.ProcessEnv): {
3
+ export declare function linuxSandboxCommand(policy: LinuxSandboxProfile, payload: Readonly<{
4
+ executable: string;
5
+ seccompFilter: Uint8Array;
6
+ }>, cwd: string, argv: readonly [string, ...string[]], host?: NodeJS.ProcessEnv, readOnlyHelperFile?: string): {
5
7
  args: string[];
6
8
  executable: string;
7
9
  seccompFilter: Uint8Array;