@piagent/platform 1.1.9 → 1.2.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 (70) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +2 -2
  3. package/docs/command-reference-vietnamese.md +13 -4
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +108 -2
  6. package/docs/model-options.md +10 -8
  7. package/docs/operator-manual-vietnamese.md +12 -12
  8. package/docs/publishing-for-teams.md +1 -0
  9. package/docs/quickstart-vietnamese.md +2 -2
  10. package/docs/release-install-policy.md +6 -6
  11. package/docs/security-threat-model.md +1 -1
  12. package/docs/team-onboarding.md +4 -4
  13. package/docs/vercel-docs-site.md +34 -4
  14. package/docs-site/architecture.html +152 -0
  15. package/docs-site/assets/docs.css +1001 -0
  16. package/docs-site/assets/docs.js +84 -0
  17. package/docs-site/commands.html +203 -0
  18. package/docs-site/content/architecture.html +29 -0
  19. package/docs-site/content/commands.html +80 -0
  20. package/docs-site/content/index.html +80 -0
  21. package/docs-site/content/mcp-approval.html +81 -0
  22. package/docs-site/content/mcp-auth.html +76 -0
  23. package/docs-site/content/mcp-servers.html +111 -0
  24. package/docs-site/content/mcp.html +120 -0
  25. package/docs-site/content/permissions.html +54 -0
  26. package/docs-site/content/profiles.html +68 -0
  27. package/docs-site/content/quickstart.html +144 -0
  28. package/docs-site/content/runtime.html +45 -0
  29. package/docs-site/content/scope.html +71 -0
  30. package/docs-site/content/security.html +51 -0
  31. package/docs-site/content/team.html +23 -0
  32. package/docs-site/content/workflows.html +47 -0
  33. package/docs-site/index.html +131 -1534
  34. package/docs-site/mcp-approval.html +204 -0
  35. package/docs-site/mcp-auth.html +199 -0
  36. package/docs-site/mcp-servers.html +234 -0
  37. package/docs-site/mcp.html +243 -0
  38. package/docs-site/permissions.html +177 -0
  39. package/docs-site/profiles.html +191 -0
  40. package/docs-site/quickstart.html +267 -0
  41. package/docs-site/runtime.html +168 -0
  42. package/docs-site/scope.html +194 -0
  43. package/docs-site/security.html +174 -0
  44. package/docs-site/team.html +142 -0
  45. package/docs-site/workflows.html +170 -0
  46. package/package.json +4 -2
  47. package/packages/piagent-core/capabilities/capability-core.js +17 -1
  48. package/packages/piagent-core/extensions/document-intake.ts +5 -5
  49. package/packages/piagent-core/extensions/piagent-guard.ts +316 -0
  50. package/packages/piagent-core/mcp/mcp-approval-store.js +175 -0
  51. package/packages/piagent-core/mcp/mcp-auth-readiness.js +197 -0
  52. package/packages/piagent-core/mcp/mcp-command-actions.js +291 -0
  53. package/packages/piagent-core/mcp/mcp-config-layers.js +133 -0
  54. package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
  55. package/packages/piagent-core/mcp/mcp-server-entry.js +242 -0
  56. package/packages/piagent-core/mcp/mcp-session-view.js +185 -0
  57. package/packages/piagent-core/package.json +7 -3
  58. package/packages/piagent-core/prompts/model-options.md +8 -8
  59. package/scripts/build-docs-site.mjs +421 -0
  60. package/scripts/configure-model-scope.sh +2 -2
  61. package/scripts/configure-subagents.sh +1 -1
  62. package/scripts/install-global.sh +5 -5
  63. package/scripts/mcp-manage.mjs +567 -0
  64. package/scripts/piagent-cli.mjs +1 -1
  65. package/scripts/preview-docs-site.mjs +88 -0
  66. package/scripts/setup.sh +2 -2
  67. package/scripts/team-doctor.sh +18 -2
  68. package/scripts/verify-local.sh +46 -19
  69. package/scripts/verify-release-identity.mjs +8 -2
  70. package/scripts/configure-mcp.sh +0 -349
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import crypto from "node:crypto";
4
5
  import { execFileSync } from "node:child_process";
@@ -41,6 +42,10 @@ import {
41
42
  } from "./redaction-core.js";
42
43
  import { detectProfileName } from "./project-shape.js";
43
44
  import { evaluateUpdateCheck, isInstalledPlatform, readUpdateCache, startUpdateProbe } from "./update-check.js";
45
+ import { REPOSITORY_SCOPES, collectServers } from "../mcp/mcp-config-layers.js";
46
+ import { approvalState } from "../mcp/mcp-approval-store.js";
47
+ import { evaluateServerReadiness, readinessNotice } from "../mcp/mcp-auth-readiness.js";
48
+ import * as mcpActions from "../mcp/mcp-command-actions.js";
44
49
  import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
45
50
  import {
46
51
  resolveCapabilityProfileDocument,
@@ -441,6 +446,22 @@ function updateAvailabilityNotice(): string | undefined {
441
446
  return decision.notice;
442
447
  }
443
448
 
449
+ // What the operator would otherwise find out when a call fails: a server that is
450
+ // configured but cannot answer. Computed from files and environment variables
451
+ // this process already has, with no network and no child process, because it runs
452
+ // on every session start.
453
+ function mcpReadinessNotice(cwd: string): string | undefined {
454
+ if (process.env.PIAGENT_NO_MCP_NOTICE?.trim()) return undefined;
455
+ try {
456
+ const servers = collectServers({ projectPath: cwd });
457
+ if (servers.length === 0) return undefined;
458
+ return readinessNotice(servers.map((server) => evaluateServerReadiness(server, { projectPath: cwd })));
459
+ } catch {
460
+ // Reporting on MCP is not worth failing a session start over.
461
+ return undefined;
462
+ }
463
+ }
464
+
444
465
  function loadPolicy(extensionDir: string): BasePolicy {
445
466
  const root = findPackageRoot(extensionDir);
446
467
  return readJsonFile<BasePolicy>(path.join(root, "policies", "base-policy.json")) ?? DEFAULT_POLICY;
@@ -1972,6 +1993,82 @@ function prepareToolInputForPolicy(
1972
1993
  };
1973
1994
  }
1974
1995
 
1996
+ // Which MCP server a tool call is addressed to. The adapter exposes one proxy
1997
+ // tool named `mcp` that carries the server in `input.server`, and, when a server
1998
+ // runs with directTools, individual tools named `mcp__<server>__<tool>`. Both
1999
+ // forms are read, because a gate that only covers the proxy is bypassed by
2000
+ // turning directTools on.
2001
+ function mcpServerFromToolCall(toolName: string, input: Record<string, unknown>): string | undefined {
2002
+ if (normalizeActionToken(toolName) === "mcp") {
2003
+ return typeof input.server === "string" && input.server.trim() ? input.server.trim() : undefined;
2004
+ }
2005
+ const direct = toolName.match(/^mcp(?:__|[-_:]+)([^_:.-]+)/i);
2006
+ return direct?.[1];
2007
+ }
2008
+
2009
+ // Servers a repository defines are not usable until somebody on this machine has
2010
+ // approved them. This is the enforcement point: the adapter owns the connection
2011
+ // and this extension cannot prevent one being opened, but no tool call reaches an
2012
+ // unapproved server. The residual exposure is the connection itself, which is
2013
+ // worth stating plainly rather than describing this as a full block.
2014
+ //
2015
+ // Recomputed only when one of the files behind the decision changes, because
2016
+ // this runs on every tool call.
2017
+ const mcpApprovalCache = new Map<string, { signature: string; blocked: Map<string, string> }>();
2018
+
2019
+ function unapprovedMcpServers(cwd: string): Map<string, string> {
2020
+ const servers = collectServers({ projectPath: cwd, scopes: [...REPOSITORY_SCOPES] });
2021
+ const signature = [
2022
+ ...servers.map((server) => `${server.scope}:${server.name}:${fileSignature(server.file)}`),
2023
+ `store:${fileSignature(path.join(os.homedir(), ".pi", "piagent-mcp-approvals.json"))}`
2024
+ ].join("|");
2025
+ const cached = mcpApprovalCache.get(cwd);
2026
+ if (cached?.signature === signature) return cached.blocked;
2027
+
2028
+ const blocked = new Map<string, string>();
2029
+ for (const server of servers) {
2030
+ const state = approvalState({ projectPath: cwd, name: server.name, entry: server.entry });
2031
+ if (state.state === "approved") continue;
2032
+ blocked.set(server.name, state.state);
2033
+ }
2034
+ mcpApprovalCache.set(cwd, { signature, blocked });
2035
+ return blocked;
2036
+ }
2037
+
2038
+ function fileSignature(file: string): string {
2039
+ try {
2040
+ const stat = fs.statSync(file);
2041
+ return `${stat.mtimeMs}:${stat.size}`;
2042
+ } catch {
2043
+ return "absent";
2044
+ }
2045
+ }
2046
+
2047
+ function evaluateMcpApproval(
2048
+ cwd: string,
2049
+ toolName: string,
2050
+ input: Record<string, unknown>
2051
+ ): { block: boolean; reason?: string } {
2052
+ const server = mcpServerFromToolCall(toolName, input);
2053
+ if (!server) return { block: false };
2054
+ const state = unapprovedMcpServers(cwd).get(server);
2055
+ if (!state) return { block: false };
2056
+ const explanation = state === "rejected"
2057
+ ? "it was rejected for this project"
2058
+ : state === "changed"
2059
+ ? "its definition changed since it was approved"
2060
+ : "this repository defines it and nobody on this machine has approved it";
2061
+ return {
2062
+ block: true,
2063
+ // Named as commands, because this is read inside a session: the reader can
2064
+ // run these where they already are, without shelling out.
2065
+ reason:
2066
+ `Blocked MCP server ${server}: ${explanation}. ` +
2067
+ `Review it with \`/piagent-mcp get ${server}\`, then \`/piagent-mcp approve ${server}\` to allow it ` +
2068
+ `or \`/piagent-mcp reject ${server}\` to refuse it.`
2069
+ };
2070
+ }
2071
+
1975
2072
  function evaluateMcpProxyShellProtectedAccess(
1976
2073
  cwd: string,
1977
2074
  prepared: PreparedToolInput,
@@ -3751,6 +3848,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
3751
3848
  if (permissionProfile.mode === "trusted-full-access") {
3752
3849
  ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
3753
3850
  }
3851
+ // A server this repository defines and nobody approved is a decision waiting
3852
+ // on the operator, so it is a warning and it comes before the release news.
3853
+ const mcpNotice = mcpReadinessNotice(ctx.cwd);
3854
+ if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
3754
3855
  // Last, so a release announcement never pushes a security warning out of
3755
3856
  // the first thing the operator reads.
3756
3857
  const updateNotice = updateAvailabilityNotice();
@@ -3910,6 +4011,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
3910
4011
  return { block: true, reason: `Blocked ${event.toolName}: ${preparedInput.reason}.` };
3911
4012
  }
3912
4013
 
4014
+ // Before anything is asked of an MCP server, whether this machine agreed to
4015
+ // that server at all.
4016
+ const approvalDecision = evaluateMcpApproval(ctx.cwd, event.toolName, toolInput);
4017
+ if (approvalDecision.block) {
4018
+ return { block: true, reason: approvalDecision.reason };
4019
+ }
4020
+
3913
4021
  if (SHELL_TOOL_NAMES.has(event.toolName)) {
3914
4022
  const shellInput = extractShellCommandInput(toolInput);
3915
4023
  if (!shellInput.command) {
@@ -5720,6 +5828,214 @@ export default function piagentGuard(pi: ExtensionAPI) {
5720
5828
  }
5721
5829
  });
5722
5830
 
5831
+ /**
5832
+ * MCP management as a command, not as a request to the model.
5833
+ *
5834
+ * Everything here is also reachable from the `piagent-mcp` terminal CLI. The
5835
+ * difference is where you are standing: inside a session, typing the shell
5836
+ * command means asking the model to run bash, read the output and tell you
5837
+ * what it said — three model turns to answer a question this process can
5838
+ * answer from files it has already read. So the same reports are bound to a
5839
+ * command, which pi dispatches without involving the model at all.
5840
+ *
5841
+ * Bare `/piagent-mcp` opens a menu built from what this project actually has,
5842
+ * because the surface only helps if you can find it without already knowing
5843
+ * the subcommand you want. Every entry in that menu is also typeable, so the
5844
+ * menu teaches the direct form rather than replacing it.
5845
+ */
5846
+ function registerMcpCommand(): void {
5847
+ const ACTIONS = new Set([...mcpActions.READ_ACTIONS, ...mcpActions.SERVER_ACTIONS]);
5848
+
5849
+ /** Report without a model turn: the text is shown, nothing is asked. */
5850
+ function emit(customType: string, report: { notify: { message: string; level: string }, lines: string[], details: Record<string, unknown> }, ctx: ExtensionContext): void {
5851
+ ctx.ui.notify(report.notify.message, report.notify.level as "info" | "warning" | "error");
5852
+ pi.sendMessage(
5853
+ { customType, content: report.lines.join("\n"), display: true, details: report.details },
5854
+ { triggerTurn: false }
5855
+ );
5856
+ }
5857
+
5858
+ function fail(ctx: ExtensionContext, message: string): void {
5859
+ ctx.ui.notify(`piagent-mcp: ${message}`, "error");
5860
+ pi.sendMessage(
5861
+ { customType: "piagent-mcp-error", content: message, display: true, details: { error: message } },
5862
+ { triggerTurn: false }
5863
+ );
5864
+ }
5865
+
5866
+ /**
5867
+ * Approval decisions live outside the repository, so writing one changes no
5868
+ * config file the gate's cache is keyed on. Drop the entry so the next tool
5869
+ * call re-reads the decision instead of the one it replaced.
5870
+ */
5871
+ function forgetApprovalCache(cwd: string): void {
5872
+ mcpApprovalCache.delete(cwd);
5873
+ }
5874
+
5875
+ /** Runs one action and shows it. Both the menu and a typed subcommand land here. */
5876
+ function runAction(ctx: ExtensionContext, action: string, name: string | undefined, scope: string | undefined): void {
5877
+ const project = ctx.cwd;
5878
+ switch (action) {
5879
+ case "status":
5880
+ emit("piagent-mcp-status", mcpActions.status({ projectPath: project, scope }), ctx);
5881
+ return;
5882
+ case "get":
5883
+ emit("piagent-mcp-detail", mcpActions.detail({ projectPath: project, name: name as string, scope }), ctx);
5884
+ return;
5885
+ case "doctor":
5886
+ emit("piagent-mcp-doctor", mcpActions.doctor({ projectPath: project }), ctx);
5887
+ return;
5888
+ case "approve":
5889
+ case "reject": {
5890
+ const report = mcpActions.decide({
5891
+ projectPath: project,
5892
+ name: name as string,
5893
+ decision: action === "approve" ? "approved" : "rejected"
5894
+ });
5895
+ forgetApprovalCache(project);
5896
+ emit("piagent-mcp-decision", report, ctx);
5897
+ return;
5898
+ }
5899
+ case "reset": {
5900
+ const report = mcpActions.reset({ projectPath: project, name });
5901
+ forgetApprovalCache(project);
5902
+ emit("piagent-mcp-decision", report, ctx);
5903
+ return;
5904
+ }
5905
+ case "enable":
5906
+ case "disable":
5907
+ emit("piagent-mcp-toggle", mcpActions.toggle({
5908
+ projectPath: project,
5909
+ name: name as string,
5910
+ scope,
5911
+ enabled: action === "enable"
5912
+ }), ctx);
5913
+ return;
5914
+ default:
5915
+ fail(ctx, `unknown subcommand: ${action}. Run /piagent-mcp help.`);
5916
+ }
5917
+ }
5918
+
5919
+ /**
5920
+ * The menu. Falls back to the plain status report when there is no select UI
5921
+ * — print mode and JSON mode have no way to answer a prompt, and blocking
5922
+ * there would hang a non-interactive run.
5923
+ */
5924
+ async function runMenu(ctx: ExtensionContext): Promise<void> {
5925
+ const menu = mcpActions.menuOptions({ projectPath: ctx.cwd });
5926
+ const chosen = ctx.hasUI === false
5927
+ ? undefined
5928
+ : await selectValueFromUi(ctx, "MCP", menu.entries, menu.entries.find((entry) => entry.recommended)?.value);
5929
+ if (!chosen) {
5930
+ emit("piagent-mcp-status", mcpActions.status({ projectPath: ctx.cwd }), ctx);
5931
+ pi.sendMessage(
5932
+ { customType: "piagent-mcp-help", content: mcpActions.HELP_LINES.join("\n"), display: true, details: {} },
5933
+ { triggerTurn: false }
5934
+ );
5935
+ return;
5936
+ }
5937
+ if (chosen === "help") {
5938
+ emit("piagent-mcp-help", mcpActions.help(), ctx);
5939
+ return;
5940
+ }
5941
+ const terminal = mcpActions.terminalOnly(chosen);
5942
+ if (terminal) {
5943
+ emit("piagent-mcp-terminal", terminal, ctx);
5944
+ return;
5945
+ }
5946
+ if (!mcpActions.SERVER_ACTIONS.has(chosen) || chosen === "reset") {
5947
+ // `reset` without a name forgets everything, which is a bigger answer
5948
+ // than the menu asked for, so it still picks a server here.
5949
+ if (chosen !== "reset") {
5950
+ runAction(ctx, chosen, undefined, undefined);
5951
+ return;
5952
+ }
5953
+ }
5954
+ const choices = mcpActions.serverChoices({ projectPath: ctx.cwd, action: chosen });
5955
+ if (choices.length === 0) {
5956
+ fail(ctx, `no server here can be ${chosen === "get" ? "inspected" : `${chosen}d`}.`);
5957
+ return;
5958
+ }
5959
+ const server = choices.length === 1
5960
+ ? choices[0].value
5961
+ : await selectValueFromUi(ctx, `Which server to ${chosen}?`, choices);
5962
+ if (!server) {
5963
+ fail(ctx, `no server chosen. Run \`/piagent-mcp ${chosen} <name>\` directly.`);
5964
+ return;
5965
+ }
5966
+ runAction(ctx, chosen, server, undefined);
5967
+ }
5968
+
5969
+ pi.registerCommand("piagent-mcp", {
5970
+ description: "Show and manage MCP servers, scopes and approvals without a model follow-up",
5971
+ getArgumentCompletions: (prefix: string) => {
5972
+ const typed = String(prefix ?? "");
5973
+ const parts = typed.split(/\s+/);
5974
+ if (parts.length <= 1) {
5975
+ const items = [...ACTIONS, "help"]
5976
+ .filter((name) => name.startsWith(parts[0] ?? ""))
5977
+ .map((name) => ({ value: name, label: name }));
5978
+ return items.length > 0 ? items : null;
5979
+ }
5980
+ if (!mcpActions.SERVER_ACTIONS.has(parts[0])) return null;
5981
+ let names: string[] = [];
5982
+ try {
5983
+ names = collectServers({ projectPath: process.cwd() }).map((server) => server.name);
5984
+ } catch {
5985
+ return null;
5986
+ }
5987
+ const seen = [...new Set(names)].filter((name) => name.startsWith(parts[parts.length - 1] ?? ""));
5988
+ return seen.length > 0 ? seen.map((name) => ({ value: name, label: name })) : null;
5989
+ },
5990
+ handler: async (args, ctx) => {
5991
+ const tokens = String(args ?? "").trim().split(/\s+/).filter(Boolean);
5992
+ const flags = new Map<string, string>();
5993
+ const positionals: string[] = [];
5994
+ for (let i = 0; i < tokens.length; i += 1) {
5995
+ if (tokens[i] === "--scope" && tokens[i + 1]) {
5996
+ flags.set("scope", tokens[i + 1]);
5997
+ i += 1;
5998
+ continue;
5999
+ }
6000
+ positionals.push(tokens[i]);
6001
+ }
6002
+ const requested = (positionals.shift() ?? "").toLowerCase();
6003
+ // `list` is what the terminal calls it, and muscle memory arrives here.
6004
+ const action = requested === "list" ? "status" : requested;
6005
+ const name = positionals[0];
6006
+ const scope = flags.get("scope");
6007
+
6008
+ try {
6009
+ if (action === "") {
6010
+ await runMenu(ctx);
6011
+ return;
6012
+ }
6013
+ if (action === "help" || action === "--help") {
6014
+ emit("piagent-mcp-help", mcpActions.help(), ctx);
6015
+ return;
6016
+ }
6017
+ const terminal = mcpActions.terminalOnly(action);
6018
+ if (terminal) {
6019
+ emit("piagent-mcp-terminal", terminal, ctx);
6020
+ return;
6021
+ }
6022
+ if (!ACTIONS.has(action)) {
6023
+ fail(ctx, `unknown subcommand: ${action}. Run /piagent-mcp help.`);
6024
+ return;
6025
+ }
6026
+ if (mcpActions.SERVER_ACTIONS.has(action) && action !== "reset" && !name) {
6027
+ fail(ctx, `${action} needs a server name.`);
6028
+ return;
6029
+ }
6030
+ runAction(ctx, action, name, scope);
6031
+ } catch (error) {
6032
+ fail(ctx, error instanceof Error ? error.message : String(error));
6033
+ }
6034
+ }
6035
+ });
6036
+ }
6037
+ registerMcpCommand();
6038
+
5723
6039
  pi.registerCommand("context-index", {
5724
6040
  description: "Show or search the compact project context index without a model follow-up",
5725
6041
  handler: async (args, ctx) => {
@@ -0,0 +1,175 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { stableJson } from "../capabilities/capability-core.js";
6
+
7
+ // Approval for MCP servers that arrive inside a repository.
8
+ //
9
+ // A repository can carry `.mcp.json` and `.pi/mcp.json`. Cloning one and opening
10
+ // a session should not hand its author a process on this machine with this
11
+ // operator's credentials, so a server found in those files is not usable until
12
+ // somebody here says so.
13
+ //
14
+ // The record lives in the operator's home, never in the repository. That is the
15
+ // whole point: a repository that could approve its own servers has not been
16
+ // gated. It also means a fork, a rename, or a second checkout starts unapproved
17
+ // rather than inheriting a decision made about different code.
18
+ //
19
+ // What is stored is the digest of the entry that was approved. Approval is
20
+ // consent to what the server runs, so a changed command, URL or argument list is
21
+ // a new question — the same split the capability lock draws between what a
22
+ // project consented to and which build produced it.
23
+
24
+ const STORE_FILE = "piagent-mcp-approvals.json";
25
+ const MAX_STORE_BYTES = 256 * 1024;
26
+ const SCHEMA_VERSION = 1;
27
+
28
+ /** @param {{home?: string}} [options] @returns {string|undefined} */
29
+ export function approvalStorePath(options = {}) {
30
+ const base = options.home ?? os.homedir();
31
+ if (typeof base !== "string" || base.length === 0) return undefined;
32
+ return path.join(base, ".pi", STORE_FILE);
33
+ }
34
+
35
+ /**
36
+ * The digest of a server entry, over the definition only. Two projects that
37
+ * declare the same server produce the same digest, which is fine: the store is
38
+ * keyed by project as well, so approving one does not approve the other.
39
+ *
40
+ * This is a content identifier, not a credential hash, and SHA-256 is the right
41
+ * primitive for it. The question it answers is "is this the same definition the
42
+ * operator approved", so it has to be fast, unsalted and stable across runs —
43
+ * every property a password hash is deliberately built to lack. Nothing is ever
44
+ * verified against it, and no secret reaches it: `add` refuses to write a
45
+ * credential value into MCP config and takes a `${VAR}` reference instead, so
46
+ * what gets hashed is a command line, a URL, variable names, and an OAuth client
47
+ * id and scope list, all of which are public identifiers.
48
+ *
49
+ * @param {unknown} entry
50
+ * @returns {string}
51
+ */
52
+ export function serverEntryDigest(entry) {
53
+ return `sha256:${crypto.createHash("sha256").update(stableJson(entry)).digest("hex")}`;
54
+ }
55
+
56
+ /**
57
+ * A project is identified by its resolved path. Resolved, because a checkout
58
+ * reached through a symlink is the same checkout, and because macOS reaches
59
+ * `/var` that way.
60
+ * @param {string} projectPath
61
+ * @returns {string}
62
+ */
63
+ export function projectKey(projectPath) {
64
+ try {
65
+ return fs.realpathSync(projectPath);
66
+ } catch {
67
+ return path.resolve(projectPath);
68
+ }
69
+ }
70
+
71
+ /** @param {{home?: string}} [options] @returns {Record<string, Record<string, {digest?: string, decision?: string, decidedAt?: string}>>} */
72
+ export function readApprovalStore(options = {}) {
73
+ const file = approvalStorePath(options);
74
+ if (!file) return {};
75
+ try {
76
+ // A store larger than this is not something this code wrote.
77
+ if (fs.statSync(file).size > MAX_STORE_BYTES) return {};
78
+ const document = JSON.parse(fs.readFileSync(file, "utf8"));
79
+ const projects = document?.projects;
80
+ if (!projects || typeof projects !== "object" || Array.isArray(projects)) return {};
81
+ return projects;
82
+ } catch {
83
+ // An unreadable store denies rather than permits: every server reads as
84
+ // undecided, which is the state that blocks.
85
+ return {};
86
+ }
87
+ }
88
+
89
+ /**
90
+ * @param {Record<string, unknown>} projects
91
+ * @param {{home?: string}} [options]
92
+ * @returns {boolean}
93
+ */
94
+ export function writeApprovalStore(projects, options = {}) {
95
+ const file = approvalStorePath(options);
96
+ if (!file) return false;
97
+ const document = { schemaVersion: SCHEMA_VERSION, projects };
98
+ try {
99
+ fs.mkdirSync(path.dirname(file), { recursive: true });
100
+ const temporary = `${file}.${process.pid}.tmp`;
101
+ fs.writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
102
+ fs.renameSync(temporary, file);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @typedef {"approved"|"rejected"|"pending"|"changed"} ApprovalState
111
+ */
112
+
113
+ /**
114
+ * What the operator has decided about this server as it is defined right now.
115
+ *
116
+ * `changed` is separated from `pending` on purpose. They both block, but they are
117
+ * different events: one is a server nobody has looked at, the other is a server
118
+ * that was approved and is no longer what was approved. Collapsing them would
119
+ * hide the second, which is the one worth reading.
120
+ *
121
+ * @param {{projectPath: string, name: string, entry: unknown, store?: Record<string, unknown>, home?: string}} options
122
+ * @returns {{state: ApprovalState, digest: string, approvedDigest?: string}}
123
+ */
124
+ export function approvalState(options) {
125
+ const store = options.store ?? readApprovalStore({ home: options.home });
126
+ const digest = serverEntryDigest(options.entry);
127
+ const record = /** @type {Record<string, {digest?: string, decision?: string}>|undefined} */ (
128
+ store[projectKey(options.projectPath)]
129
+ )?.[options.name];
130
+ if (!record || typeof record.decision !== "string") return { state: "pending", digest };
131
+ if (record.decision === "rejected") return { state: "rejected", digest, approvedDigest: record.digest };
132
+ if (record.decision !== "approved") return { state: "pending", digest };
133
+ if (record.digest !== digest) return { state: "changed", digest, approvedDigest: record.digest };
134
+ return { state: "approved", digest, approvedDigest: record.digest };
135
+ }
136
+
137
+ /**
138
+ * @param {{projectPath: string, name: string, entry: unknown, decision: "approved"|"rejected", now?: number, home?: string}} options
139
+ * @returns {boolean}
140
+ */
141
+ export function recordApproval(options) {
142
+ const store = readApprovalStore({ home: options.home });
143
+ const key = projectKey(options.projectPath);
144
+ const project = { ...(store[key] ?? {}) };
145
+ project[options.name] = {
146
+ decision: options.decision,
147
+ digest: serverEntryDigest(options.entry),
148
+ decidedAt: new Date(options.now ?? Date.now()).toISOString()
149
+ };
150
+ return writeApprovalStore({ ...store, [key]: project }, { home: options.home });
151
+ }
152
+
153
+ /**
154
+ * Drop a decision so the server returns to pending. Used by `piagent-mcp reset`
155
+ * and when a server is removed, so a later server reusing the name does not
156
+ * inherit the decision — the digest check would catch it, but leaving a record
157
+ * for a server that no longer exists is just debris.
158
+ * @param {{projectPath: string, name?: string, home?: string}} options
159
+ * @returns {boolean}
160
+ */
161
+ export function clearApproval(options) {
162
+ const store = readApprovalStore({ home: options.home });
163
+ const key = projectKey(options.projectPath);
164
+ if (!store[key]) return false;
165
+ if (!options.name) {
166
+ const { [key]: _removed, ...rest } = store;
167
+ return writeApprovalStore(rest, { home: options.home });
168
+ }
169
+ const { [options.name]: _dropped, ...project } = store[key];
170
+ const next = Object.keys(project).length > 0 ? { ...store, [key]: project } : (() => {
171
+ const { [key]: _empty, ...rest } = store;
172
+ return rest;
173
+ })();
174
+ return writeApprovalStore(next, { home: options.home });
175
+ }