@fungi.computer/truffle 0.3.0 → 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.
@@ -7,4 +7,6 @@ export declare function localExecEscapeReview(request: {
7
7
  escape?: {
8
8
  justification: string;
9
9
  } | undefined;
10
- }, cwd: string): LocalApprovalReview;
10
+ }, cwd: string, gitGrant?: {
11
+ configuredRemotes: ReadonlySet<string>;
12
+ }): LocalApprovalReview;
@@ -1,4 +1,4 @@
1
- import { rulePrefix } from "./private-local-exec-rules.js";
1
+ import { gitRuleCoverage, rulePrefix } from "./private-local-exec-rules.js";
2
2
  const gitHookGrantSubcommands = new Set([
3
3
  "add",
4
4
  "branch",
@@ -17,14 +17,15 @@ const gitHookGrantSubcommands = new Set([
17
17
  "tag",
18
18
  ]);
19
19
  /** Build the host's review before the approval record is staged or displayed. */
20
- export function localExecEscapeReview(request, cwd) {
20
+ export function localExecEscapeReview(request, cwd, gitGrant) {
21
21
  if (request.escape === undefined)
22
22
  throw new Error("An escape requires a justification");
23
23
  const command = request.command ??
24
24
  JSON.stringify([request.executable, ...(request.args ?? [])]);
25
- const prefix = request.executable === undefined
25
+ const prefix = request.executable === undefined ||
26
+ (request.executable === "git" && gitGrant === undefined)
26
27
  ? undefined
27
- : rulePrefix([request.executable, ...(request.args ?? [])]);
28
+ : rulePrefix([request.executable, ...(request.args ?? [])], gitGrant?.configuredRemotes);
28
29
  const review = {
29
30
  command,
30
31
  cwd,
@@ -32,19 +33,30 @@ export function localExecEscapeReview(request, cwd) {
32
33
  };
33
34
  if (prefix !== undefined) {
34
35
  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
- }
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.`;
48
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.";
49
61
  return review;
50
62
  }
@@ -1,9 +1,11 @@
1
- /** Offer only direct, narrow grants; extension installs require a fresh card. */
2
- export declare function rulePrefix(argv: readonly string[]): string[] | undefined;
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;
3
5
  /** One host-owned project file; every Agent in the project sees the same rules. */
4
6
  export declare function localExecRules(project: string): {
5
7
  list: () => string[][];
6
- add: (prefix: readonly string[]) => void;
8
+ add: (prefix: readonly string[]) => import("@fungi.computer/cairn").CairnId<"local-exec-rules", "rule", 1>;
7
9
  remove: (prefix: readonly string[]) => void;
8
10
  match: (argv: readonly string[], configuredRemotes?: ReadonlySet<string>) => {
9
11
  ruleId: import("@fungi.computer/cairn").CairnId<"local-exec-rules", "rule", 1>;
@@ -62,7 +62,7 @@ const gitOptions = {
62
62
  "-s",
63
63
  "--signoff",
64
64
  ],
65
- values: ["-m", "--message"],
65
+ values: ["-m", "--message", "-am"],
66
66
  },
67
67
  diff: {
68
68
  flags: [
@@ -250,8 +250,8 @@ function validPrefix(prefix) {
250
250
  prefix.length === 2 &&
251
251
  gitBuiltins.has(prefix[1] ?? ""))));
252
252
  }
253
- /** Offer only direct, narrow grants; extension installs require a fresh card. */
254
- export function rulePrefix(argv) {
253
+ /** Offer only direct commands that this same matcher can authorize. */
254
+ export function rulePrefix(argv, configuredRemotes) {
255
255
  if (argv.length === 0 ||
256
256
  argv[0]?.startsWith(".") ||
257
257
  argv[0]?.includes("/") ||
@@ -260,7 +260,9 @@ export function rulePrefix(argv) {
260
260
  return undefined;
261
261
  if (basename(argv[0] ?? "") === "git") {
262
262
  const prefix = argv.slice(0, 2);
263
- return validPrefix(prefix) ? prefix : undefined;
263
+ return validPrefix(prefix) && safeGitCommand(argv, configuredRemotes)
264
+ ? prefix
265
+ : undefined;
264
266
  }
265
267
  if (basename(argv[0] ?? "") === "fungi" &&
266
268
  argv[1] === "extension" &&
@@ -269,6 +271,21 @@ export function rulePrefix(argv) {
269
271
  const parsed = prefixSchema.safeParse(argv.slice(0, 2));
270
272
  return parsed.success && validPrefix(parsed.data) ? parsed.data : undefined;
271
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
+ }
272
289
  function safeRemote(remote, configuredRemotes) {
273
290
  if (remote === undefined)
274
291
  return true;
@@ -378,14 +395,18 @@ export function localExecRules(project) {
378
395
  const parsed = prefixSchema.parse(prefix);
379
396
  if (!validPrefix(parsed))
380
397
  throw new Error("Unsafe Always-allow prefix");
398
+ let ruleId;
381
399
  lockedUpdate(project, () => {
382
400
  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
- ]);
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
+ }
388
406
  });
407
+ if (ruleId === undefined)
408
+ throw new Error("Exec rule was not persisted");
409
+ return ruleId;
389
410
  };
390
411
  const remove = (prefix) => {
391
412
  const parsed = prefixSchema.parse(prefix);
@@ -48,6 +48,13 @@ export type PrivateLocalTools = Readonly<{
48
48
  /** Background Computer processes, scoped per Session; the host closes them. */
49
49
  processes: PrivateLocalComputerProcesses;
50
50
  }>;
51
+ export type ExecApprovalDecision = {
52
+ agentId: string;
53
+ recordId: string;
54
+ decision: "approve" | "deny";
55
+ scope?: "once" | "always";
56
+ ruleId?: string;
57
+ };
51
58
  /** Inputs for the one private local Agent opener. */
52
59
  export type PrivateLocalRuntimeHostOptions = Readonly<{
53
60
  /** Service-selected host configuration, independent of this Agent's Files root. */
@@ -63,6 +70,8 @@ export type PrivateLocalRuntimeHostOptions = Readonly<{
63
70
  executable: string;
64
71
  ruleId: string;
65
72
  }>) => void;
73
+ /** The human's committed approval choice, without command text or secrets. */
74
+ onExecApprovalDecision?: (event: Readonly<ExecApprovalDecision>) => void;
66
75
  /** Existing Agent database to reopen instead of creating a fresh local store. */
67
76
  databasePath?: string | undefined;
68
77
  /** Stable Local store identity for replaying one Agent create request. */
@@ -466,7 +466,7 @@ export function createPrivateLocalModelSource(models, providerId, modelId, provi
466
466
  signal,
467
467
  });
468
468
  if (startup === null)
469
- throw new ShiitakeError("model", "rejected", "No signed-in model provider is available. Run /login, then choose a model with /model.");
469
+ throw new ShiitakeError("model", "rejected", "No signed-in model provider is available. Use your own Claude or ChatGPT subscription: /login in a Session, or fungi login --provider anthropic --auth-type oauth (Claude) or fungi login --provider openai --auth-type oauth (ChatGPT) in the CLI. For hosted Fungi: fungi login --provider fungi.");
470
470
  const choice = startup.preference;
471
471
  const selectedProvider = choice.providerId;
472
472
  if (selectedProvider === "fungi") {
@@ -1125,14 +1125,29 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1125
1125
  const grantRepository = lstatSync(join(root, ".git"), { throwIfNoEntry: false }) === undefined
1126
1126
  ? undefined
1127
1127
  : gitDirectories(root, root);
1128
+ const gitGrantFor = (cwd, executionPath) => {
1129
+ // A nested/Agent-created repository has writable config and hooks.
1130
+ const protectedGit = grantRepository === undefined
1131
+ ? undefined
1132
+ : gitDirectories(root, root, executionPath);
1133
+ const selectedGit = gitDirectories(root, cwd, executionPath);
1134
+ if (protectedGit?.common === undefined ||
1135
+ protectedGit.common !== grantRepository?.common ||
1136
+ protectedGit.worktree !== grantRepository.worktree ||
1137
+ selectedGit?.common !== protectedGit.common ||
1138
+ selectedGit.worktree !== protectedGit.worktree)
1139
+ return undefined;
1140
+ const configuredRemotes = protectedGitRemoteNames(root, protectedGit.common, executionPath);
1141
+ return configuredRemotes === undefined
1142
+ ? undefined
1143
+ : { configuredRemotes };
1144
+ };
1128
1145
  // The attached human's answer, never form JSON or the model, selects
1129
1146
  // this short-lived intent. Commit the rule before dispatching the command.
1130
1147
  const ruleIntents = new Map();
1131
1148
  const selectRule = (recordId, prefix) => (selected) => {
1132
1149
  if (selected === "prefix")
1133
- ruleIntents.set(recordId, () => {
1134
- execRules.add(prefix);
1135
- });
1150
+ ruleIntents.set(recordId, () => execRules.add(prefix));
1136
1151
  else
1137
1152
  ruleIntents.delete(recordId);
1138
1153
  };
@@ -1192,12 +1207,32 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1192
1207
  decision: async (recordId) => {
1193
1208
  const answered = escapeLedger.get(recordId);
1194
1209
  if (answered?.state === "approved") {
1210
+ let ruleId;
1195
1211
  if (answered.module === "computer" &&
1196
1212
  answered.operation === "exec" &&
1197
1213
  answered.authorityFence === escapeFence())
1198
- ruleIntents.get(recordId)?.();
1214
+ ruleId = ruleIntents.get(recordId)?.();
1215
+ if (answered.module === "computer" && answered.operation === "exec") {
1216
+ const event = {
1217
+ agentId,
1218
+ recordId,
1219
+ decision: "approve",
1220
+ scope: ruleIntents.has(recordId) ? "always" : "once",
1221
+ };
1222
+ if (ruleId !== undefined)
1223
+ event.ruleId = ruleId;
1224
+ input.onExecApprovalDecision?.(event);
1225
+ }
1199
1226
  await escapeLedger.apply(recordId, escapeApply);
1200
1227
  }
1228
+ if (answered?.state === "denied" &&
1229
+ answered.module === "computer" &&
1230
+ answered.operation === "exec")
1231
+ input.onExecApprovalDecision?.({
1232
+ agentId,
1233
+ recordId,
1234
+ decision: "deny",
1235
+ });
1201
1236
  for (const pending of escapeLedger.pendingHandoffs())
1202
1237
  await deliverEscapeResult(pending);
1203
1238
  },
@@ -1246,7 +1281,9 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1246
1281
  recordId: staged.record.recordId,
1247
1282
  description: staged.record.description,
1248
1283
  };
1249
- const review = localExecEscapeReview(request, cwd);
1284
+ const review = localExecEscapeReview(request, cwd, "executable" in request && request.executable === "git"
1285
+ ? gitGrantFor(cwd, executionPath)
1286
+ : undefined);
1250
1287
  const presented = await localTools.presentApproval(sessionId, pending, escapeApprovals, review, review.prefix === undefined
1251
1288
  ? undefined
1252
1289
  : selectRule(pending.recordId, review.prefix));
@@ -1259,19 +1296,7 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
1259
1296
  return undefined;
1260
1297
  let configuredRemotes;
1261
1298
  if (request.executable === "git") {
1262
- // Only the project's own protected common Git directory may use a
1263
- // Git grant. A nested/Agent-created repository has writable config.
1264
- const protectedGit = grantRepository === undefined
1265
- ? undefined
1266
- : gitDirectories(root, root, executionPath);
1267
- const selectedGit = gitDirectories(root, cwd, executionPath);
1268
- if (protectedGit?.common === undefined ||
1269
- protectedGit.common !== grantRepository?.common ||
1270
- protectedGit.worktree !== grantRepository.worktree ||
1271
- selectedGit?.common !== protectedGit.common ||
1272
- selectedGit.worktree !== protectedGit.worktree)
1273
- return undefined;
1274
- configuredRemotes = protectedGitRemoteNames(root, protectedGit.common, executionPath);
1299
+ configuredRemotes = gitGrantFor(cwd, executionPath)?.configuredRemotes;
1275
1300
  if (configuredRemotes === undefined)
1276
1301
  return undefined;
1277
1302
  }
@@ -97,7 +97,7 @@ readRecoveryManifest, root = capturePrivateLocalConfigRoot()) {
97
97
  if (native === undefined)
98
98
  throw new Error("Local service native staging is not ready");
99
99
  return native;
100
- });
100
+ }, diagnostics.approvalDecision);
101
101
  const prefix = `/service/${serviceId}`;
102
102
  const releaseAccountOwner = registerFungiAccountOwner(root.credentialPath, () => owners.refreshMesh());
103
103
  const app = new Hono();
@@ -55,6 +55,13 @@ export type ServiceLogEvent = Readonly<{
55
55
  agentId: string;
56
56
  executable: string;
57
57
  ruleId: string;
58
+ }> | Readonly<{
59
+ event: "exec-approval-decision";
60
+ agentId: string;
61
+ recordId: string;
62
+ decision: "approve" | "deny";
63
+ scope?: "once" | "always";
64
+ ruleId?: string;
58
65
  }> | Readonly<{
59
66
  event: "memory";
60
67
  rss: number;
@@ -88,6 +95,9 @@ export type ServiceLogLifecycle = Readonly<{
88
95
  ruleMatch: (match: Omit<Extract<ServiceLogEvent, {
89
96
  event: "exec-rule-match";
90
97
  }>, "event">) => void;
98
+ approvalDecision: (decision: Omit<Extract<ServiceLogEvent, {
99
+ event: "exec-approval-decision";
100
+ }>, "event">) => void;
91
101
  recoveryDisabled: (code: RecoveryPersistenceFailureCode) => void;
92
102
  /** Synchronous, so it lands before the bounded drain exits the process. */
93
103
  drainTimeout: (drain: Readonly<{
@@ -290,6 +290,9 @@ export function createServiceLogLifecycle(input) {
290
290
  ruleMatch(match) {
291
291
  emit({ event: "exec-rule-match", ...match });
292
292
  },
293
+ approvalDecision(decision) {
294
+ emit({ event: "exec-approval-decision", ...decision });
295
+ },
293
296
  recoveryDisabled(code) {
294
297
  if (closed || recovery !== undefined)
295
298
  return;
@@ -38,7 +38,7 @@ readRecoveryManifest?: (path: string) => Promise<string>,
38
38
  /** Report only the classification, never the manifest or exception text. */
39
39
  onRecoveryDisabled?: ServiceLogLifecycle["recoveryDisabled"], configRoot?: PrivateLocalConfigRoot, meshLinksFactory?: typeof createLocalMeshLinks, onExecRuleMatch?: NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["onExecRuleMatch"]>,
40
40
  /** Supplied by the listener after startup; individual Agents never close it. */
41
- serviceNative?: () => NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["native"]>): {
41
+ serviceNative?: () => NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["native"]>, onExecApprovalDecision?: NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["onExecApprovalDecision"]>): {
42
42
  refreshMesh: () => Promise<void>;
43
43
  createSession: (creation: PrivateLocalSessionCreation) => Promise<{
44
44
  agentId: string;
@@ -71,7 +71,7 @@ readRecoveryManifest = (path) => readFile(path, "utf8"),
71
71
  /** Report only the classification, never the manifest or exception text. */
72
72
  onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot(), meshLinksFactory = createLocalMeshLinks, onExecRuleMatch = () => undefined,
73
73
  /** Supplied by the listener after startup; individual Agents never close it. */
74
- serviceNative) {
74
+ serviceNative, onExecApprovalDecision = () => undefined) {
75
75
  const extensions = suppliedExtensions ?? localExtensions({ home: configRoot.home });
76
76
  const recoveryPath = recoveryDirectory === undefined
77
77
  ? undefined
@@ -734,6 +734,7 @@ serviceNative) {
734
734
  mail: projectControl ? mailRouting(invocationRoot) : undefined,
735
735
  meshAdmission,
736
736
  onExecRuleMatch,
737
+ onExecApprovalDecision,
737
738
  };
738
739
  if (serviceNative !== undefined)
739
740
  options = { ...options, native: serviceNative() };
@@ -1049,6 +1050,7 @@ serviceNative) {
1049
1050
  agentControl: directory(invocationRoot),
1050
1051
  mail: mailRouting(invocationRoot),
1051
1052
  onExecRuleMatch,
1053
+ onExecApprovalDecision,
1052
1054
  };
1053
1055
  if (serviceNative !== undefined)
1054
1056
  options = { ...options, native: serviceNative() };
@@ -185,6 +185,8 @@ export declare const mcpFormApprovalContract: {
185
185
  readonly justification: Schema.String;
186
186
  readonly prefix: Schema.optionalKey<Schema.$Array<Schema.String>>;
187
187
  readonly warning: Schema.optionalKey<Schema.String>;
188
+ readonly alwaysAllowCoverage: Schema.optionalKey<Schema.String>;
189
+ readonly alwaysAllowUnavailableReason: Schema.optionalKey<Schema.String>;
188
190
  }>>;
189
191
  }>>;
190
192
  }>;
@@ -38,6 +38,8 @@ const McpFormPendingSchema = Schema.Struct({
38
38
  justification: Schema.String,
39
39
  prefix: Schema.optionalKey(Schema.Array(Schema.String)),
40
40
  warning: Schema.optionalKey(Schema.String),
41
+ alwaysAllowCoverage: Schema.optionalKey(Schema.String),
42
+ alwaysAllowUnavailableReason: Schema.optionalKey(Schema.String),
41
43
  })),
42
44
  });
43
45
  /** The existing Session extension slot carries only display-safe form data. */
@@ -7,6 +7,8 @@ export type LocalApprovalReview = {
7
7
  justification: string;
8
8
  prefix?: readonly string[];
9
9
  warning?: string;
10
+ alwaysAllowCoverage?: string;
11
+ alwaysAllowUnavailableReason?: string;
10
12
  };
11
13
  type Pending = {
12
14
  sessionId: string;
@@ -129,6 +131,8 @@ export declare function createMcpFormSession(): {
129
131
  readonly justification: string;
130
132
  readonly prefix?: readonly string[];
131
133
  readonly warning?: string;
134
+ readonly alwaysAllowCoverage?: string;
135
+ readonly alwaysAllowUnavailableReason?: string;
132
136
  };
133
137
  }[];
134
138
  }, {
@@ -181,6 +185,8 @@ export declare function createMcpFormSession(): {
181
185
  readonly justification: string;
182
186
  readonly prefix?: readonly string[];
183
187
  readonly warning?: string;
188
+ readonly alwaysAllowCoverage?: string;
189
+ readonly alwaysAllowUnavailableReason?: string;
184
190
  };
185
191
  }[];
186
192
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/truffle",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Truffle: Fungi's local Agent runtime, bundled into the fungi command",
6
6
  "type": "module",