@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.
- package/CHANGELOG.md +43 -1
- package/README.md +2 -2
- package/docs/command-reference-vietnamese.md +13 -4
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +108 -2
- package/docs/model-options.md +10 -8
- package/docs/operator-manual-vietnamese.md +12 -12
- package/docs/publishing-for-teams.md +1 -0
- package/docs/quickstart-vietnamese.md +2 -2
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +1 -1
- package/docs/team-onboarding.md +4 -4
- package/docs/vercel-docs-site.md +34 -4
- package/docs-site/architecture.html +152 -0
- package/docs-site/assets/docs.css +1001 -0
- package/docs-site/assets/docs.js +84 -0
- package/docs-site/commands.html +203 -0
- package/docs-site/content/architecture.html +29 -0
- package/docs-site/content/commands.html +80 -0
- package/docs-site/content/index.html +80 -0
- package/docs-site/content/mcp-approval.html +81 -0
- package/docs-site/content/mcp-auth.html +76 -0
- package/docs-site/content/mcp-servers.html +111 -0
- package/docs-site/content/mcp.html +120 -0
- package/docs-site/content/permissions.html +54 -0
- package/docs-site/content/profiles.html +68 -0
- package/docs-site/content/quickstart.html +144 -0
- package/docs-site/content/runtime.html +45 -0
- package/docs-site/content/scope.html +71 -0
- package/docs-site/content/security.html +51 -0
- package/docs-site/content/team.html +23 -0
- package/docs-site/content/workflows.html +47 -0
- package/docs-site/index.html +131 -1534
- package/docs-site/mcp-approval.html +204 -0
- package/docs-site/mcp-auth.html +199 -0
- package/docs-site/mcp-servers.html +234 -0
- package/docs-site/mcp.html +243 -0
- package/docs-site/permissions.html +177 -0
- package/docs-site/profiles.html +191 -0
- package/docs-site/quickstart.html +267 -0
- package/docs-site/runtime.html +168 -0
- package/docs-site/scope.html +194 -0
- package/docs-site/security.html +174 -0
- package/docs-site/team.html +142 -0
- package/docs-site/workflows.html +170 -0
- package/package.json +4 -2
- package/packages/piagent-core/capabilities/capability-core.js +17 -1
- package/packages/piagent-core/extensions/document-intake.ts +5 -5
- package/packages/piagent-core/extensions/piagent-guard.ts +316 -0
- package/packages/piagent-core/mcp/mcp-approval-store.js +175 -0
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +197 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +291 -0
- package/packages/piagent-core/mcp/mcp-config-layers.js +133 -0
- package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +242 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +185 -0
- package/packages/piagent-core/package.json +7 -3
- package/packages/piagent-core/prompts/model-options.md +8 -8
- package/scripts/build-docs-site.mjs +421 -0
- package/scripts/configure-model-scope.sh +2 -2
- package/scripts/configure-subagents.sh +1 -1
- package/scripts/install-global.sh +5 -5
- package/scripts/mcp-manage.mjs +567 -0
- package/scripts/piagent-cli.mjs +1 -1
- package/scripts/preview-docs-site.mjs +88 -0
- package/scripts/setup.sh +2 -2
- package/scripts/team-doctor.sh +18 -2
- package/scripts/verify-local.sh +46 -19
- package/scripts/verify-release-identity.mjs +8 -2
- 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
|
+
}
|