@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.
Files changed (68) hide show
  1. package/dist/private-files-patch.js +3 -17
  2. package/dist/private-invocation-computer.d.ts +224 -12
  3. package/dist/private-invocation-computer.js +162 -22
  4. package/dist/private-invocation-files.d.ts +3 -2
  5. package/dist/private-invocation-files.js +22 -3
  6. package/dist/private-local-agent-control.d.ts +2 -2
  7. package/dist/private-local-bwrap.d.ts +9 -7
  8. package/dist/private-local-bwrap.js +26 -21
  9. package/dist/private-local-computer-processes.d.ts +15 -0
  10. package/dist/private-local-computer-processes.js +13 -0
  11. package/dist/private-local-containment.d.ts +15 -13
  12. package/dist/private-local-containment.js +49 -65
  13. package/dist/private-local-delegation.d.ts +3 -3
  14. package/dist/private-local-delegation.js +32 -19
  15. package/dist/private-local-exec-review.d.ts +12 -0
  16. package/dist/private-local-exec-review.js +62 -0
  17. package/dist/private-local-exec-rules.d.ts +16 -0
  18. package/dist/private-local-exec-rules.js +441 -0
  19. package/dist/private-local-git-exclude.js +25 -15
  20. package/dist/private-local-linux-sandbox.d.ts +5 -5
  21. package/dist/private-local-linux-sandbox.js +63 -30
  22. package/dist/private-local-mail.d.ts +2 -2
  23. package/dist/private-local-mesh-links.d.ts +4 -1
  24. package/dist/private-local-mesh-links.js +50 -3
  25. package/dist/private-local-native-digests.js +1 -1
  26. package/dist/private-local-native-install-error.d.ts +2 -0
  27. package/dist/private-local-native-install-error.js +10 -0
  28. package/dist/private-local-native-staging.d.ts +7 -8
  29. package/dist/private-local-native-staging.js +132 -43
  30. package/dist/private-local-permission-mode.d.ts +10 -0
  31. package/dist/private-local-permission-mode.js +18 -0
  32. package/dist/private-local-runtime-host.d.ts +28 -5
  33. package/dist/private-local-runtime-host.js +404 -66
  34. package/dist/private-local-sandbox-policy.d.ts +30 -11
  35. package/dist/private-local-sandbox-policy.js +223 -373
  36. package/dist/private-local-sandbox.d.ts +30 -25
  37. package/dist/private-local-sandbox.js +190 -100
  38. package/dist/private-local-seatbelt.d.ts +7 -13
  39. package/dist/private-local-seatbelt.js +55 -32
  40. package/dist/private-local-service-contract.d.ts +5 -4
  41. package/dist/private-local-service-discovery.d.ts +19 -7
  42. package/dist/private-local-service-discovery.js +4 -3
  43. package/dist/private-local-service-listener.js +40 -2
  44. package/dist/private-local-service-log.d.ts +18 -0
  45. package/dist/private-local-service-log.js +6 -0
  46. package/dist/private-local-service-owner.d.ts +18 -10
  47. package/dist/private-local-service-owner.js +119 -25
  48. package/dist/private-local-service.js +30 -3
  49. package/dist/private-local-session-discovery.d.ts +2 -1
  50. package/dist/private-local-session-discovery.js +18 -6
  51. package/dist/private-mcp-form-contract.d.ts +10 -1
  52. package/dist/private-mcp-form-contract.js +10 -1
  53. package/dist/private-mcp-form-session.d.ts +39 -3
  54. package/dist/private-mcp-form-session.js +28 -5
  55. package/dist/private-oyster-mcp-auth.d.ts +1 -1
  56. package/dist/private-oyster-mcp-auth.js +139 -7
  57. package/dist/private-provider-rejection.js +1 -1
  58. package/dist/seatbelt/safe-base.sbpl +2 -2
  59. package/native/bin/bwrap-build-source/Dockerfile +2 -0
  60. package/native/bin/bwrap-build-source/README.md +6 -6
  61. package/native/bin/bwrap-build-source/pin.mjs +2 -2
  62. package/native/bin/bwrap-build-source/record.mjs +12 -1
  63. package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +10 -7
  64. package/native/bin/bwrap-build.json +3 -2
  65. package/native/bin/fungi-apply-patch +0 -0
  66. package/native/bin/fungi-seccomp-workspace.bpf +0 -0
  67. package/native/bin/fungi-seccomp-workspace.bpf.sha256 +1 -0
  68. package/package.json +23 -17
@@ -0,0 +1,62 @@
1
+ import { gitRuleCoverage, 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, gitGrant) {
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
+ (request.executable === "git" && gitGrant === undefined)
27
+ ? undefined
28
+ : rulePrefix([request.executable, ...(request.args ?? [])], gitGrant?.configuredRemotes);
29
+ const review = {
30
+ command,
31
+ cwd,
32
+ justification: request.escape.justification,
33
+ };
34
+ if (prefix !== undefined) {
35
+ review.prefix = prefix;
36
+ review.alwaysAllowCoverage =
37
+ prefix[0] === "git"
38
+ ? gitRuleCoverage(prefix[1] ?? "")
39
+ : `Direct argv commands beginning ${JSON.stringify(prefix)} in this project; shell commands still ask.`;
40
+ }
41
+ if (request.executable === "git") {
42
+ const hookCapable = gitHookGrantSubcommands.has(request.args?.[0] ?? "");
43
+ review.warning =
44
+ (hookCapable
45
+ ? "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."
46
+ : "This command runs as you outside the sandbox.") +
47
+ (request.args?.[0] === "push"
48
+ ? " This grant can push all branches, force-update them, or delete remote branches."
49
+ : "") +
50
+ (["push", "fetch", "pull"].includes(request.args?.[0] ?? "")
51
+ ? " Remote URLs can invoke ssh with your HOME and keys."
52
+ : "");
53
+ }
54
+ if (prefix === undefined)
55
+ review.alwaysAllowUnavailableReason =
56
+ request.executable === undefined
57
+ ? "Shell command text cannot match a saved argv rule; Approve once or Deny."
58
+ : request.executable === "git" && gitGrant === undefined
59
+ ? "A protected project Git repository is unavailable; no reusable Git rule can match."
60
+ : "This command or option cannot match a safe reusable rule; Approve once or Deny.";
61
+ return review;
62
+ }
@@ -0,0 +1,16 @@
1
+ /** Offer only direct commands that this same matcher can authorize. */
2
+ export declare function rulePrefix(argv: readonly string[], configuredRemotes?: ReadonlySet<string>): string[] | undefined;
3
+ /** Text for the same Git option profile that gates matching, not a second list. */
4
+ export declare function gitRuleCoverage(subcommand: string): string;
5
+ /** One host-owned project file; every Agent in the project sees the same rules. */
6
+ export declare function localExecRules(project: string): {
7
+ list: () => string[][];
8
+ add: (prefix: readonly string[]) => import("@fungi.computer/cairn").CairnId<"local-exec-rules", "rule", 1>;
9
+ remove: (prefix: readonly string[]) => void;
10
+ match: (argv: readonly string[], configuredRemotes?: ReadonlySet<string>) => {
11
+ ruleId: import("@fungi.computer/cairn").CairnId<"local-exec-rules", "rule", 1>;
12
+ } | undefined;
13
+ matches: (argv: readonly string[], configuredRemotes?: ReadonlySet<string>) => boolean;
14
+ };
15
+ /** Human CLI management only; no model-facing capability exposes this writer. */
16
+ export declare function manageLocalExecRules(project: string, action: "list" | "remove", prefix?: readonly string[]): readonly (readonly string[])[];
@@ -0,0 +1,441 @@
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", "-am"],
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 commands that this same matcher can authorize. */
254
+ export function rulePrefix(argv, configuredRemotes) {
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) && safeGitCommand(argv, configuredRemotes)
264
+ ? prefix
265
+ : undefined;
266
+ }
267
+ if (basename(argv[0] ?? "") === "fungi" &&
268
+ argv[1] === "extension" &&
269
+ argv[2] === "add")
270
+ return undefined;
271
+ const parsed = prefixSchema.safeParse(argv.slice(0, 2));
272
+ return parsed.success && validPrefix(parsed.data) ? parsed.data : undefined;
273
+ }
274
+ /** Text for the same Git option profile that gates matching, not a second list. */
275
+ export function gitRuleCoverage(subcommand) {
276
+ const profile = gitProfiles.get(subcommand);
277
+ if (profile === undefined)
278
+ throw new Error("No reusable Git option profile");
279
+ const flags = profile.flags.filter((flag) => flag !== "--").join(", ");
280
+ const values = profile.values.map((option) => `${option} VALUE`).join(", ");
281
+ const remote = subcommand === "push" ||
282
+ subcommand === "fetch" ||
283
+ subcommand === "pull" ||
284
+ subcommand === "remote"
285
+ ? " Remote operands must be configured names or permitted explicit URLs."
286
+ : "";
287
+ return `Direct argv git ${subcommand} for Workspace Agents in this protected project repository. Only these options are reusable: ${flags || "none"}${values ? `; ${values}` : ""}; -- and permitted operands.${remote} Shell commands and unlisted Git options still ask.`;
288
+ }
289
+ function safeRemote(remote, configuredRemotes) {
290
+ if (remote === undefined)
291
+ return true;
292
+ if (/^(https?|ssh|git):\/\//u.test(remote))
293
+ return true;
294
+ // A bare token is also a relative path. Only the protected repository's
295
+ // configured remote names can authorize it, regardless of filesystem state.
296
+ return (/^[A-Za-z][A-Za-z0-9_.-]*$/u.test(remote) &&
297
+ configuredRemotes?.has(remote) === true);
298
+ }
299
+ /** A Git prefix never authorizes options outside the reviewed subcommand profile. */
300
+ function safeGitCommand(argv, configuredRemotes) {
301
+ if (argv[0] !== "git")
302
+ return false;
303
+ const subcommand = argv[1];
304
+ const profile = gitProfiles.get(subcommand ?? "");
305
+ if (profile === undefined)
306
+ return false;
307
+ const operands = [];
308
+ for (let index = 2; index < argv.length; index++) {
309
+ const arg = argv[index];
310
+ if (arg === undefined)
311
+ return false;
312
+ if (arg === "--") {
313
+ operands.push(...argv.slice(index + 1));
314
+ break;
315
+ }
316
+ if (arg.startsWith("-") && arg !== "-") {
317
+ if (profile.flags.some((option) => option === arg))
318
+ continue;
319
+ if (!profile.values.some((option) => option === arg))
320
+ return false;
321
+ // A missing value must not turn a following option into an operand.
322
+ if (argv[index + 1] === undefined || argv[index + 1]?.startsWith("-"))
323
+ return false;
324
+ index++;
325
+ }
326
+ else {
327
+ operands.push(arg);
328
+ }
329
+ }
330
+ if (subcommand === "remote") {
331
+ if (operands.length === 0)
332
+ return true;
333
+ if (operands.length > 2 ||
334
+ (operands[0] !== "show" && operands[0] !== "get-url"))
335
+ return false;
336
+ return safeRemote(operands[1], configuredRemotes);
337
+ }
338
+ if (subcommand === "push" || subcommand === "fetch" || subcommand === "pull")
339
+ return safeRemote(operands[0], configuredRemotes);
340
+ return true;
341
+ }
342
+ function projectFile(project) {
343
+ const directory = join(project, ".fungi");
344
+ const marker = lstatSync(directory, { throwIfNoEntry: false });
345
+ if (marker !== undefined && !marker.isDirectory())
346
+ throw new Error("Project .fungi must be a real directory");
347
+ return { directory, file: join(directory, "exec-rules.json") };
348
+ }
349
+ function readProject(project) {
350
+ const file = join(project, ".fungi", "exec-rules.json");
351
+ try {
352
+ return readProjectFile(project);
353
+ }
354
+ catch (cause) {
355
+ throw new Error(`Invalid ${file}; delete it and reapprove Always-allow rules`, { cause });
356
+ }
357
+ }
358
+ function readProjectFile(project) {
359
+ const { file } = projectFile(project);
360
+ const entry = lstatSync(file, { throwIfNoEntry: false });
361
+ if (entry === undefined)
362
+ return { version: 2, rules: [] };
363
+ if (!entry.isFile() || entry.nlink !== 1)
364
+ throw new Error("Project exec rules must be an independent regular file");
365
+ const stored = projectRulesSchema.parse(JSON.parse(readFileSync(file, "utf8")));
366
+ if (stored.rules.some((rule) => !validPrefix(rule.prefix)))
367
+ throw new Error("Project exec rules contain an unsafe prefix");
368
+ return stored;
369
+ }
370
+ /** Fail closed on contention instead of overwriting another process's grant. */
371
+ function lockedUpdate(project, update) {
372
+ const { directory } = projectFile(project);
373
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
374
+ const lock = join(directory, "exec-rules.lock");
375
+ mkdirSync(lock, { mode: 0o700 });
376
+ try {
377
+ update();
378
+ }
379
+ finally {
380
+ rmdirSync(lock);
381
+ }
382
+ }
383
+ function writeProject(project, rules) {
384
+ const content = projectRulesSchema.parse({ version: 2, rules });
385
+ const { directory, file } = projectFile(project);
386
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
387
+ const temp = join(directory, "exec-rules-" + correlationKey() + ".tmp");
388
+ writeFileSync(temp, JSON.stringify(content), { flag: "wx", mode: 0o600 });
389
+ renameSync(temp, file);
390
+ }
391
+ /** One host-owned project file; every Agent in the project sees the same rules. */
392
+ export function localExecRules(project) {
393
+ const list = () => readProject(project).rules.map((rule) => rule.prefix);
394
+ const add = (prefix) => {
395
+ const parsed = prefixSchema.parse(prefix);
396
+ if (!validPrefix(parsed))
397
+ throw new Error("Unsafe Always-allow prefix");
398
+ let ruleId;
399
+ lockedUpdate(project, () => {
400
+ const existing = readProject(project).rules;
401
+ ruleId = existing.find((rule) => JSON.stringify(rule.prefix) === JSON.stringify(parsed))?.id;
402
+ if (ruleId === undefined) {
403
+ ruleId = projectRuleIds.rule.generate();
404
+ writeProject(project, [...existing, { id: ruleId, prefix: parsed }]);
405
+ }
406
+ });
407
+ if (ruleId === undefined)
408
+ throw new Error("Exec rule was not persisted");
409
+ return ruleId;
410
+ };
411
+ const remove = (prefix) => {
412
+ const parsed = prefixSchema.parse(prefix);
413
+ lockedUpdate(project, () => {
414
+ writeProject(project, readProject(project).rules.filter((rule) => JSON.stringify(rule.prefix) !== JSON.stringify(parsed)));
415
+ });
416
+ };
417
+ const match = (argv, configuredRemotes) => {
418
+ if (unsafeExecutable(argv[0] ?? "") ||
419
+ (basename(argv[0] ?? "") === "fungi" &&
420
+ argv[1] === "extension" &&
421
+ argv[2] === "add"))
422
+ return undefined;
423
+ if (argv[0] === "git" && !safeGitCommand(argv, configuredRemotes))
424
+ return undefined;
425
+ const matched = readProject(project).rules.find((rule) => rule.prefix.every((part, index) => argv[index] === part));
426
+ // The stored Cairn id identifies the rule without exposing its prefix.
427
+ return matched === undefined ? undefined : { ruleId: matched.id };
428
+ };
429
+ const matches = (argv, configuredRemotes) => match(argv, configuredRemotes) !== undefined;
430
+ return { list, add, remove, match, matches };
431
+ }
432
+ /** Human CLI management only; no model-facing capability exposes this writer. */
433
+ export function manageLocalExecRules(project, action, prefix) {
434
+ const rules = localExecRules(project);
435
+ if (action === "remove") {
436
+ if (prefix === undefined)
437
+ throw new Error("A prefix is required");
438
+ rules.remove(prefix);
439
+ }
440
+ return rules.list();
441
+ }
@@ -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,10 +1,10 @@
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: Omit<LinuxSandboxPayload, "executableBytes"> & {
3
+ export declare function linuxSandboxCommand(policy: LinuxSandboxProfile, payload: Readonly<{
5
4
  executable: string;
6
- }, cwd: string, argv: readonly [string, ...string[]], host?: NodeJS.ProcessEnv, readOnlyHelperFile?: string): {
7
- args: string[];
8
5
  seccompFilter: Uint8Array;
6
+ }>, cwd: string, argv: readonly [string, ...string[]], host?: NodeJS.ProcessEnv, readOnlyHelperFile?: string): {
7
+ args: string[];
9
8
  executable: string;
9
+ seccompFilter: Uint8Array;
10
10
  };