@piagent/platform 1.1.9 → 1.2.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.
- package/CHANGELOG.md +175 -1
- package/README.md +2 -2
- package/docs/command-reference-vietnamese.md +19 -5
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +154 -2
- package/docs/model-options.md +10 -8
- package/docs/operator-manual-vietnamese.md +25 -13
- package/docs/publishing-for-teams.md +2 -0
- package/docs/quickstart-vietnamese.md +2 -2
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +4 -3
- package/docs/team-onboarding.md +4 -4
- package/docs/usage-observability.md +29 -0
- 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 +133 -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 +256 -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 +58 -5
- package/packages/piagent-core/capabilities/capability-sources.js +73 -12
- package/packages/piagent-core/extensions/document-intake.ts +5 -5
- package/packages/piagent-core/extensions/piagent-guard.ts +432 -2
- package/packages/piagent-core/extensions/policy-core.js +1586 -98
- package/packages/piagent-core/mcp/mcp-approval-store.js +216 -0
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +225 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +400 -0
- package/packages/piagent-core/mcp/mcp-config-layers.js +466 -0
- package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
- package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +293 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +236 -0
- package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -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 +21 -5
- package/scripts/configure-subagents.sh +18 -5
- package/scripts/init-project.sh +15 -8
- package/scripts/install-global.sh +5 -5
- package/scripts/mcp-manage.mjs +656 -0
- package/scripts/pi-model-catalog.sh +11 -1
- package/scripts/pi-session-stats.sh +27 -1
- package/scripts/pi-usage-history.mjs +567 -0
- package/scripts/piagent-cli.mjs +1 -1
- package/scripts/preview-docs-site.mjs +88 -0
- package/scripts/quality-benchmark.sh +37 -9
- package/scripts/setup.sh +23 -15
- package/scripts/team-doctor.sh +18 -2
- package/scripts/uninstall-global.sh +10 -4
- package/scripts/verify-local.sh +65 -19
- package/scripts/verify-release-identity.mjs +37 -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";
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
extractShellGlobCandidates,
|
|
12
13
|
extractShellPathCandidates,
|
|
13
14
|
findProtectedPathInCommand,
|
|
15
|
+
unresolvedPathExpansions,
|
|
14
16
|
globMatchesPath,
|
|
15
17
|
matchesProtectedPath,
|
|
16
18
|
normalizePathCandidate,
|
|
@@ -41,6 +43,17 @@ import {
|
|
|
41
43
|
} from "./redaction-core.js";
|
|
42
44
|
import { detectProfileName } from "./project-shape.js";
|
|
43
45
|
import { evaluateUpdateCheck, isInstalledPlatform, readUpdateCache, startUpdateProbe } from "./update-check.js";
|
|
46
|
+
import {
|
|
47
|
+
REPOSITORY_SCOPES,
|
|
48
|
+
collectServers,
|
|
49
|
+
configPathForScope,
|
|
50
|
+
mcpDecisionInputs,
|
|
51
|
+
unverifiableMcpConfig
|
|
52
|
+
} from "../mcp/mcp-config-layers.js";
|
|
53
|
+
import { attributeDirectTool } from "../mcp/mcp-tool-naming.js";
|
|
54
|
+
import { approvalState } from "../mcp/mcp-approval-store.js";
|
|
55
|
+
import { evaluateServerReadiness, readinessNotice } from "../mcp/mcp-auth-readiness.js";
|
|
56
|
+
import * as mcpActions from "../mcp/mcp-command-actions.js";
|
|
44
57
|
import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
|
|
45
58
|
import {
|
|
46
59
|
resolveCapabilityProfileDocument,
|
|
@@ -441,6 +454,22 @@ function updateAvailabilityNotice(): string | undefined {
|
|
|
441
454
|
return decision.notice;
|
|
442
455
|
}
|
|
443
456
|
|
|
457
|
+
// What the operator would otherwise find out when a call fails: a server that is
|
|
458
|
+
// configured but cannot answer. Computed from files and environment variables
|
|
459
|
+
// this process already has, with no network and no child process, because it runs
|
|
460
|
+
// on every session start.
|
|
461
|
+
function mcpReadinessNotice(cwd: string): string | undefined {
|
|
462
|
+
if (process.env.PIAGENT_NO_MCP_NOTICE?.trim()) return undefined;
|
|
463
|
+
try {
|
|
464
|
+
const servers = collectServers({ projectPath: cwd });
|
|
465
|
+
if (servers.length === 0) return undefined;
|
|
466
|
+
return readinessNotice(servers.map((server) => evaluateServerReadiness(server, { projectPath: cwd })));
|
|
467
|
+
} catch {
|
|
468
|
+
// Reporting on MCP is not worth failing a session start over.
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
444
473
|
function loadPolicy(extensionDir: string): BasePolicy {
|
|
445
474
|
const root = findPackageRoot(extensionDir);
|
|
446
475
|
return readJsonFile<BasePolicy>(path.join(root, "policies", "base-policy.json")) ?? DEFAULT_POLICY;
|
|
@@ -1972,6 +2001,155 @@ function prepareToolInputForPolicy(
|
|
|
1972
2001
|
};
|
|
1973
2002
|
}
|
|
1974
2003
|
|
|
2004
|
+
// Which MCP server a tool call is addressed to. The adapter exposes one proxy
|
|
2005
|
+
// tool named `mcp` that carries the server in `input.server`, and, under
|
|
2006
|
+
// directTools, one tool per server tool named `<prefix>_<tool>` where the prefix
|
|
2007
|
+
// is derived from the server name. Direct names are matched against the servers
|
|
2008
|
+
// this gate is holding rather than against a fixed pattern, because the prefix
|
|
2009
|
+
// is the server's own name and there is nothing constant to anchor on.
|
|
2010
|
+
function mcpServerFromToolCall(
|
|
2011
|
+
toolName: string,
|
|
2012
|
+
input: Record<string, unknown>,
|
|
2013
|
+
candidates: Iterable<string>
|
|
2014
|
+
): string | undefined {
|
|
2015
|
+
if (normalizeActionToken(toolName) === "mcp") {
|
|
2016
|
+
return typeof input.server === "string" && input.server.trim() ? input.server.trim() : undefined;
|
|
2017
|
+
}
|
|
2018
|
+
return attributeDirectTool(toolName, candidates);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Servers a repository defines are not usable until somebody on this machine has
|
|
2022
|
+
// approved them. This is the enforcement point: the adapter owns the connection
|
|
2023
|
+
// and this extension cannot prevent one being opened, but no tool call reaches an
|
|
2024
|
+
// unapproved server. The residual exposure is the connection itself, which is
|
|
2025
|
+
// worth stating plainly rather than describing this as a full block.
|
|
2026
|
+
//
|
|
2027
|
+
// Recomputed only when one of the files behind the decision changes, because
|
|
2028
|
+
// this runs on every tool call.
|
|
2029
|
+
interface RepositoryMcpGate {
|
|
2030
|
+
blocked: Map<string, { state: string; origin: string }>;
|
|
2031
|
+
// Conditions under which no tool call can be checked at all, each with the
|
|
2032
|
+
// sentence to show. These are not "a server is unapproved" — they are "this
|
|
2033
|
+
// repository's config has put the gate in a state where it cannot tell which
|
|
2034
|
+
// server a call belongs to", and the only sound answer to that is to stop.
|
|
2035
|
+
unverifiable: string[];
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const mcpApprovalCache = new Map<string, { signature: string; gate: RepositoryMcpGate }>();
|
|
2039
|
+
|
|
2040
|
+
function repositoryMcpGate(cwd: string): RepositoryMcpGate {
|
|
2041
|
+
// Repository scopes plus anything an `imports` key drags in from a file the
|
|
2042
|
+
// clone carries. Collecting those here is the whole point: a server the gate
|
|
2043
|
+
// never enumerates is a server it silently permits.
|
|
2044
|
+
const servers = collectServers({ projectPath: cwd, scopes: [...REPOSITORY_SCOPES] });
|
|
2045
|
+
const repositoryFiles = [...REPOSITORY_SCOPES].map((scope) => ({
|
|
2046
|
+
scope,
|
|
2047
|
+
file: configPathForScope(scope, { projectPath: cwd })
|
|
2048
|
+
}));
|
|
2049
|
+
// Every file the decision reads, listed by the module that reads them rather
|
|
2050
|
+
// than restated here. Building this by hand is what went wrong: the scan grew
|
|
2051
|
+
// to read merged settings from all four scopes and to stat import targets
|
|
2052
|
+
// outside the repository, the hand-written list did not, and an already-loaded
|
|
2053
|
+
// guard went on permitting calls after a personal global config had put the
|
|
2054
|
+
// session in the state this gate exists to refuse.
|
|
2055
|
+
const signature = [
|
|
2056
|
+
...servers.map((server) => `${server.origin}:${server.name}:${fileSignature(server.file)}`),
|
|
2057
|
+
...repositoryFiles.map((layer) => `layer:${layer.scope}:${fileSignature(layer.file)}`),
|
|
2058
|
+
...mcpDecisionInputs({ projectPath: cwd }).map((file) => `input:${file}:${fileSignature(file)}`),
|
|
2059
|
+
`store:${fileSignature(path.join(os.homedir(), ".pi", "piagent-mcp-approvals.json"))}`
|
|
2060
|
+
].join("|");
|
|
2061
|
+
const cached = mcpApprovalCache.get(cwd);
|
|
2062
|
+
if (cached?.signature === signature) return cached.gate;
|
|
2063
|
+
|
|
2064
|
+
const blocked = new Map<string, { state: string; origin: string }>();
|
|
2065
|
+
for (const server of servers) {
|
|
2066
|
+
const state = approvalState({ projectPath: cwd, name: server.name, entry: server.entry });
|
|
2067
|
+
if (state.state === "approved") continue;
|
|
2068
|
+
// A name defined more than once keeps its blocking entry: the approved copy
|
|
2069
|
+
// does not vouch for the one nobody has looked at.
|
|
2070
|
+
blocked.set(server.name, { state: state.state, origin: server.origin });
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// Two things a repository-carried config can do that leave nothing to check.
|
|
2074
|
+
//
|
|
2075
|
+
// It can ask for tools with no prefix, so a direct tool arrives under a bare
|
|
2076
|
+
// name carrying no evidence of its server. Refusing only the proxy there was
|
|
2077
|
+
// not a fix: the proxy is the one form that names its server, so blocking it
|
|
2078
|
+
// and allowing the bare names left the hole exactly where it was.
|
|
2079
|
+
//
|
|
2080
|
+
// It can also import a kind whose config this platform cannot enumerate, and
|
|
2081
|
+
// then the set of servers reaching the session is simply unknown. Blocking the
|
|
2082
|
+
// servers that could be listed says nothing about the ones that could not.
|
|
2083
|
+
//
|
|
2084
|
+
// Both stop every tool call. A repository can make a session refuse to run;
|
|
2085
|
+
// it must not be able to make one run a server nobody approved.
|
|
2086
|
+
// Read from the shared module rather than decided here, so `piagent-mcp
|
|
2087
|
+
// doctor` reports exactly what this refuses. The two disagreeing is how an
|
|
2088
|
+
// operator ends up reading "PASS" while every tool call is being stopped.
|
|
2089
|
+
const unverifiable = unverifiableMcpConfig({ projectPath: cwd }).map((problem) => problem.detail);
|
|
2090
|
+
|
|
2091
|
+
const gate = { blocked, unverifiable };
|
|
2092
|
+
mcpApprovalCache.set(cwd, { signature, gate });
|
|
2093
|
+
return gate;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function fileSignature(file: string): string {
|
|
2097
|
+
try {
|
|
2098
|
+
const stat = fs.statSync(file);
|
|
2099
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
2100
|
+
} catch {
|
|
2101
|
+
return "absent";
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function evaluateMcpApproval(
|
|
2106
|
+
cwd: string,
|
|
2107
|
+
toolName: string,
|
|
2108
|
+
input: Record<string, unknown>
|
|
2109
|
+
): { block: boolean; reason?: string } {
|
|
2110
|
+
const gate = repositoryMcpGate(cwd);
|
|
2111
|
+
|
|
2112
|
+
// Every tool call, not only the ones that look like MCP. Under either of these
|
|
2113
|
+
// conditions a call cannot be traced to a server, so there is no such thing as
|
|
2114
|
+
// a call this gate can clear.
|
|
2115
|
+
if (gate.unverifiable.length > 0) {
|
|
2116
|
+
return { block: true, reason: `Blocked every tool call: ${gate.unverifiable.join(" ")}` };
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
const server = mcpServerFromToolCall(toolName, input, gate.blocked.keys());
|
|
2120
|
+
if (!server) return { block: false };
|
|
2121
|
+
const record = gate.blocked.get(server);
|
|
2122
|
+
if (!record) return { block: false };
|
|
2123
|
+
const explanation = record.state === "rejected"
|
|
2124
|
+
? "it was rejected for this project"
|
|
2125
|
+
: record.state === "changed"
|
|
2126
|
+
? "its definition changed since it was approved"
|
|
2127
|
+
: record.origin.startsWith("import:")
|
|
2128
|
+
? `this repository imports it from ${record.origin.slice("import:".length)} config and nobody on this machine has approved it here`
|
|
2129
|
+
: "this repository defines it and nobody on this machine has approved it";
|
|
2130
|
+
return {
|
|
2131
|
+
block: true,
|
|
2132
|
+
// Named as commands, because this is read inside a session: the reader can
|
|
2133
|
+
// run these where they already are, without shelling out.
|
|
2134
|
+
reason:
|
|
2135
|
+
`Blocked MCP server ${server}: ${explanation}. ` +
|
|
2136
|
+
`Review it with \`/piagent-mcp get ${server}\`, then \`/piagent-mcp approve ${server}\` to allow it ` +
|
|
2137
|
+
`or \`/piagent-mcp reject ${server}\` to refuse it.`
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// A word whose final path segment glues literal text onto an expansion --
|
|
2142
|
+
// `.en$(echo v)`, `${D}.env` -- names a file this process cannot know without
|
|
2143
|
+
// running the substitution. Matching the literal half against the protected
|
|
2144
|
+
// patterns answers a different question than the one being asked, so refuse
|
|
2145
|
+
// rather than let an unknown filename through unchecked.
|
|
2146
|
+
function unresolvedExpansionReason(subject: string, words: string[]): string {
|
|
2147
|
+
const listed = words.map((word) => `\`${word}\``).join(", ");
|
|
2148
|
+
return `${subject} builds a filename this guard cannot resolve: ${listed}. `
|
|
2149
|
+
+ "The literal text around the expansion makes it a path, but its value is only known at run time, "
|
|
2150
|
+
+ "so it cannot be checked against the protected paths. Write the path out, or put the expansion in its own argument.";
|
|
2151
|
+
}
|
|
2152
|
+
|
|
1975
2153
|
function evaluateMcpProxyShellProtectedAccess(
|
|
1976
2154
|
cwd: string,
|
|
1977
2155
|
prepared: PreparedToolInput,
|
|
@@ -2015,6 +2193,10 @@ function evaluateMcpProxyShellProtectedAccess(
|
|
|
2015
2193
|
reason: `MCP command resolves to protected path: ${resolvedProtectedHit.candidate} resolves to ${resolvedProtectedHit.resolved} matching ${resolvedProtectedHit.pattern}`
|
|
2016
2194
|
};
|
|
2017
2195
|
}
|
|
2196
|
+
const unresolved = protectedPaths.length > 0 ? unresolvedPathExpansions(command) : [];
|
|
2197
|
+
if (unresolved.length > 0) {
|
|
2198
|
+
return { block: true, reason: unresolvedExpansionReason("MCP command", unresolved) };
|
|
2199
|
+
}
|
|
2018
2200
|
return { block: false };
|
|
2019
2201
|
}
|
|
2020
2202
|
|
|
@@ -3187,6 +3369,19 @@ function modelLabel(ctx: ExtensionContext): string {
|
|
|
3187
3369
|
return model.name ?? model.id ?? "unknown";
|
|
3188
3370
|
}
|
|
3189
3371
|
|
|
3372
|
+
function currentSessionName(ctx: ExtensionContext): string {
|
|
3373
|
+
try {
|
|
3374
|
+
return String(ctx.sessionManager.getSessionName() ?? "").trim();
|
|
3375
|
+
} catch {
|
|
3376
|
+
return "";
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
function hasOperatorSessionName(name: string): boolean {
|
|
3381
|
+
const normalized = name.trim().toLowerCase();
|
|
3382
|
+
return Boolean(normalized && normalized !== "session");
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3190
3385
|
function buildUsageSnapshot(ctx: ExtensionContext, thinkingLevel?: string): UsageSnapshot {
|
|
3191
3386
|
const contextUsage = ctx.getContextUsage();
|
|
3192
3387
|
const contextWithThinking = ctx as ExtensionContext & { getThinkingLevel?: () => string };
|
|
@@ -3213,7 +3408,8 @@ function buildUsageSnapshot(ctx: ExtensionContext, thinkingLevel?: string): Usag
|
|
|
3213
3408
|
availableInCommand: false,
|
|
3214
3409
|
howToRead: [
|
|
3215
3410
|
"Inside Pi TUI: run /session for exact tokens and cost.",
|
|
3216
|
-
"Outside Pi: run piagent-usage /path/to/project or scripts/pi-session-stats.sh /path/to/project."
|
|
3411
|
+
"Outside Pi: run piagent-usage /path/to/project or scripts/pi-session-stats.sh /path/to/project.",
|
|
3412
|
+
"Historical totals: run piagent-usage --history /path/to/project --days 7."
|
|
3217
3413
|
]
|
|
3218
3414
|
}
|
|
3219
3415
|
};
|
|
@@ -3241,6 +3437,12 @@ function formatUsageSnapshot(snapshot: UsageSnapshot): string {
|
|
|
3241
3437
|
"",
|
|
3242
3438
|
"```bash",
|
|
3243
3439
|
"piagent-usage /path/to/project",
|
|
3440
|
+
"```",
|
|
3441
|
+
"",
|
|
3442
|
+
"Historical totals / weekly report:",
|
|
3443
|
+
"",
|
|
3444
|
+
"```bash",
|
|
3445
|
+
"piagent-usage --history /path/to/project --days 7",
|
|
3244
3446
|
"```"
|
|
3245
3447
|
].join("\n");
|
|
3246
3448
|
}
|
|
@@ -3726,7 +3928,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3726
3928
|
const explicitProfile = Boolean(process.env.PIAGENT_PROFILE?.trim());
|
|
3727
3929
|
const profile = loadProfile(ctx.cwd, projectTrusted);
|
|
3728
3930
|
const name = profile.displayName || profile.projectId || path.basename(ctx.cwd);
|
|
3729
|
-
|
|
3931
|
+
const operatorSessionName = currentSessionName(ctx);
|
|
3932
|
+
if (!hasOperatorSessionName(operatorSessionName)) {
|
|
3933
|
+
pi.setSessionName(`pi:${name}`);
|
|
3934
|
+
}
|
|
3730
3935
|
const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
|
|
3731
3936
|
? ""
|
|
3732
3937
|
: " (run /onboard-project to select a profile)";
|
|
@@ -3751,6 +3956,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3751
3956
|
if (permissionProfile.mode === "trusted-full-access") {
|
|
3752
3957
|
ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
|
|
3753
3958
|
}
|
|
3959
|
+
// A server this repository defines and nobody approved is a decision waiting
|
|
3960
|
+
// on the operator, so it is a warning and it comes before the release news.
|
|
3961
|
+
const mcpNotice = mcpReadinessNotice(ctx.cwd);
|
|
3962
|
+
if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
|
|
3754
3963
|
// Last, so a release announcement never pushes a security warning out of
|
|
3755
3964
|
// the first thing the operator reads.
|
|
3756
3965
|
const updateNotice = updateAvailabilityNotice();
|
|
@@ -3910,6 +4119,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3910
4119
|
return { block: true, reason: `Blocked ${event.toolName}: ${preparedInput.reason}.` };
|
|
3911
4120
|
}
|
|
3912
4121
|
|
|
4122
|
+
// Before anything is asked of an MCP server, whether this machine agreed to
|
|
4123
|
+
// that server at all.
|
|
4124
|
+
const approvalDecision = evaluateMcpApproval(ctx.cwd, event.toolName, toolInput);
|
|
4125
|
+
if (approvalDecision.block) {
|
|
4126
|
+
return { block: true, reason: approvalDecision.reason };
|
|
4127
|
+
}
|
|
4128
|
+
|
|
3913
4129
|
if (SHELL_TOOL_NAMES.has(event.toolName)) {
|
|
3914
4130
|
const shellInput = extractShellCommandInput(toolInput);
|
|
3915
4131
|
if (!shellInput.command) {
|
|
@@ -3939,6 +4155,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3939
4155
|
reason: `Command resolves to protected path: ${resolvedProtectedHit.candidate} resolves to ${resolvedProtectedHit.resolved} matching ${resolvedProtectedHit.pattern}`
|
|
3940
4156
|
};
|
|
3941
4157
|
}
|
|
4158
|
+
const unresolvedExpansions = pathPolicy.shellProtectedPaths.length > 0
|
|
4159
|
+
? unresolvedPathExpansions(command)
|
|
4160
|
+
: [];
|
|
4161
|
+
if (unresolvedExpansions.length > 0) {
|
|
4162
|
+
return { block: true, reason: unresolvedExpansionReason("Command", unresolvedExpansions) };
|
|
4163
|
+
}
|
|
3942
4164
|
|
|
3943
4165
|
const confirmationReasons = execDecision.mode !== "off" && execDecision.decision === "prompt"
|
|
3944
4166
|
? [...execDecision.reasons]
|
|
@@ -5720,6 +5942,214 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5720
5942
|
}
|
|
5721
5943
|
});
|
|
5722
5944
|
|
|
5945
|
+
/**
|
|
5946
|
+
* MCP management as a command, not as a request to the model.
|
|
5947
|
+
*
|
|
5948
|
+
* Everything here is also reachable from the `piagent-mcp` terminal CLI. The
|
|
5949
|
+
* difference is where you are standing: inside a session, typing the shell
|
|
5950
|
+
* command means asking the model to run bash, read the output and tell you
|
|
5951
|
+
* what it said — three model turns to answer a question this process can
|
|
5952
|
+
* answer from files it has already read. So the same reports are bound to a
|
|
5953
|
+
* command, which pi dispatches without involving the model at all.
|
|
5954
|
+
*
|
|
5955
|
+
* Bare `/piagent-mcp` opens a menu built from what this project actually has,
|
|
5956
|
+
* because the surface only helps if you can find it without already knowing
|
|
5957
|
+
* the subcommand you want. Every entry in that menu is also typeable, so the
|
|
5958
|
+
* menu teaches the direct form rather than replacing it.
|
|
5959
|
+
*/
|
|
5960
|
+
function registerMcpCommand(): void {
|
|
5961
|
+
const ACTIONS = new Set([...mcpActions.READ_ACTIONS, ...mcpActions.SERVER_ACTIONS]);
|
|
5962
|
+
|
|
5963
|
+
/** Report without a model turn: the text is shown, nothing is asked. */
|
|
5964
|
+
function emit(customType: string, report: { notify: { message: string; level: string }, lines: string[], details: Record<string, unknown> }, ctx: ExtensionContext): void {
|
|
5965
|
+
ctx.ui.notify(report.notify.message, report.notify.level as "info" | "warning" | "error");
|
|
5966
|
+
pi.sendMessage(
|
|
5967
|
+
{ customType, content: report.lines.join("\n"), display: true, details: report.details },
|
|
5968
|
+
{ triggerTurn: false }
|
|
5969
|
+
);
|
|
5970
|
+
}
|
|
5971
|
+
|
|
5972
|
+
function fail(ctx: ExtensionContext, message: string): void {
|
|
5973
|
+
ctx.ui.notify(`piagent-mcp: ${message}`, "error");
|
|
5974
|
+
pi.sendMessage(
|
|
5975
|
+
{ customType: "piagent-mcp-error", content: message, display: true, details: { error: message } },
|
|
5976
|
+
{ triggerTurn: false }
|
|
5977
|
+
);
|
|
5978
|
+
}
|
|
5979
|
+
|
|
5980
|
+
/**
|
|
5981
|
+
* Approval decisions live outside the repository, so writing one changes no
|
|
5982
|
+
* config file the gate's cache is keyed on. Drop the entry so the next tool
|
|
5983
|
+
* call re-reads the decision instead of the one it replaced.
|
|
5984
|
+
*/
|
|
5985
|
+
function forgetApprovalCache(cwd: string): void {
|
|
5986
|
+
mcpApprovalCache.delete(cwd);
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5989
|
+
/** Runs one action and shows it. Both the menu and a typed subcommand land here. */
|
|
5990
|
+
function runAction(ctx: ExtensionContext, action: string, name: string | undefined, scope: string | undefined): void {
|
|
5991
|
+
const project = ctx.cwd;
|
|
5992
|
+
switch (action) {
|
|
5993
|
+
case "status":
|
|
5994
|
+
emit("piagent-mcp-status", mcpActions.status({ projectPath: project, scope }), ctx);
|
|
5995
|
+
return;
|
|
5996
|
+
case "get":
|
|
5997
|
+
emit("piagent-mcp-detail", mcpActions.detail({ projectPath: project, name: name as string, scope }), ctx);
|
|
5998
|
+
return;
|
|
5999
|
+
case "doctor":
|
|
6000
|
+
emit("piagent-mcp-doctor", mcpActions.doctor({ projectPath: project }), ctx);
|
|
6001
|
+
return;
|
|
6002
|
+
case "approve":
|
|
6003
|
+
case "reject": {
|
|
6004
|
+
const report = mcpActions.decide({
|
|
6005
|
+
projectPath: project,
|
|
6006
|
+
name: name as string,
|
|
6007
|
+
decision: action === "approve" ? "approved" : "rejected"
|
|
6008
|
+
});
|
|
6009
|
+
forgetApprovalCache(project);
|
|
6010
|
+
emit("piagent-mcp-decision", report, ctx);
|
|
6011
|
+
return;
|
|
6012
|
+
}
|
|
6013
|
+
case "reset": {
|
|
6014
|
+
const report = mcpActions.reset({ projectPath: project, name });
|
|
6015
|
+
forgetApprovalCache(project);
|
|
6016
|
+
emit("piagent-mcp-decision", report, ctx);
|
|
6017
|
+
return;
|
|
6018
|
+
}
|
|
6019
|
+
case "enable":
|
|
6020
|
+
case "disable":
|
|
6021
|
+
emit("piagent-mcp-toggle", mcpActions.toggle({
|
|
6022
|
+
projectPath: project,
|
|
6023
|
+
name: name as string,
|
|
6024
|
+
scope,
|
|
6025
|
+
enabled: action === "enable"
|
|
6026
|
+
}), ctx);
|
|
6027
|
+
return;
|
|
6028
|
+
default:
|
|
6029
|
+
fail(ctx, `unknown subcommand: ${action}. Run /piagent-mcp help.`);
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
|
|
6033
|
+
/**
|
|
6034
|
+
* The menu. Falls back to the plain status report when there is no select UI
|
|
6035
|
+
* — print mode and JSON mode have no way to answer a prompt, and blocking
|
|
6036
|
+
* there would hang a non-interactive run.
|
|
6037
|
+
*/
|
|
6038
|
+
async function runMenu(ctx: ExtensionContext): Promise<void> {
|
|
6039
|
+
const menu = mcpActions.menuOptions({ projectPath: ctx.cwd });
|
|
6040
|
+
const chosen = ctx.hasUI === false
|
|
6041
|
+
? undefined
|
|
6042
|
+
: await selectValueFromUi(ctx, "MCP", menu.entries, menu.entries.find((entry) => entry.recommended)?.value);
|
|
6043
|
+
if (!chosen) {
|
|
6044
|
+
emit("piagent-mcp-status", mcpActions.status({ projectPath: ctx.cwd }), ctx);
|
|
6045
|
+
pi.sendMessage(
|
|
6046
|
+
{ customType: "piagent-mcp-help", content: mcpActions.HELP_LINES.join("\n"), display: true, details: {} },
|
|
6047
|
+
{ triggerTurn: false }
|
|
6048
|
+
);
|
|
6049
|
+
return;
|
|
6050
|
+
}
|
|
6051
|
+
if (chosen === "help") {
|
|
6052
|
+
emit("piagent-mcp-help", mcpActions.help(), ctx);
|
|
6053
|
+
return;
|
|
6054
|
+
}
|
|
6055
|
+
const terminal = mcpActions.terminalOnly(chosen);
|
|
6056
|
+
if (terminal) {
|
|
6057
|
+
emit("piagent-mcp-terminal", terminal, ctx);
|
|
6058
|
+
return;
|
|
6059
|
+
}
|
|
6060
|
+
if (!mcpActions.SERVER_ACTIONS.has(chosen) || chosen === "reset") {
|
|
6061
|
+
// `reset` without a name forgets everything, which is a bigger answer
|
|
6062
|
+
// than the menu asked for, so it still picks a server here.
|
|
6063
|
+
if (chosen !== "reset") {
|
|
6064
|
+
runAction(ctx, chosen, undefined, undefined);
|
|
6065
|
+
return;
|
|
6066
|
+
}
|
|
6067
|
+
}
|
|
6068
|
+
const choices = mcpActions.serverChoices({ projectPath: ctx.cwd, action: chosen });
|
|
6069
|
+
if (choices.length === 0) {
|
|
6070
|
+
fail(ctx, `no server here can be ${chosen === "get" ? "inspected" : `${chosen}d`}.`);
|
|
6071
|
+
return;
|
|
6072
|
+
}
|
|
6073
|
+
const server = choices.length === 1
|
|
6074
|
+
? choices[0].value
|
|
6075
|
+
: await selectValueFromUi(ctx, `Which server to ${chosen}?`, choices);
|
|
6076
|
+
if (!server) {
|
|
6077
|
+
fail(ctx, `no server chosen. Run \`/piagent-mcp ${chosen} <name>\` directly.`);
|
|
6078
|
+
return;
|
|
6079
|
+
}
|
|
6080
|
+
runAction(ctx, chosen, server, undefined);
|
|
6081
|
+
}
|
|
6082
|
+
|
|
6083
|
+
pi.registerCommand("piagent-mcp", {
|
|
6084
|
+
description: "Show and manage MCP servers, scopes and approvals without a model follow-up",
|
|
6085
|
+
getArgumentCompletions: (prefix: string) => {
|
|
6086
|
+
const typed = String(prefix ?? "");
|
|
6087
|
+
const parts = typed.split(/\s+/);
|
|
6088
|
+
if (parts.length <= 1) {
|
|
6089
|
+
const items = [...ACTIONS, "help"]
|
|
6090
|
+
.filter((name) => name.startsWith(parts[0] ?? ""))
|
|
6091
|
+
.map((name) => ({ value: name, label: name }));
|
|
6092
|
+
return items.length > 0 ? items : null;
|
|
6093
|
+
}
|
|
6094
|
+
if (!mcpActions.SERVER_ACTIONS.has(parts[0])) return null;
|
|
6095
|
+
let names: string[] = [];
|
|
6096
|
+
try {
|
|
6097
|
+
names = collectServers({ projectPath: process.cwd() }).map((server) => server.name);
|
|
6098
|
+
} catch {
|
|
6099
|
+
return null;
|
|
6100
|
+
}
|
|
6101
|
+
const seen = [...new Set(names)].filter((name) => name.startsWith(parts[parts.length - 1] ?? ""));
|
|
6102
|
+
return seen.length > 0 ? seen.map((name) => ({ value: name, label: name })) : null;
|
|
6103
|
+
},
|
|
6104
|
+
handler: async (args, ctx) => {
|
|
6105
|
+
const tokens = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
6106
|
+
const flags = new Map<string, string>();
|
|
6107
|
+
const positionals: string[] = [];
|
|
6108
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
6109
|
+
if (tokens[i] === "--scope" && tokens[i + 1]) {
|
|
6110
|
+
flags.set("scope", tokens[i + 1]);
|
|
6111
|
+
i += 1;
|
|
6112
|
+
continue;
|
|
6113
|
+
}
|
|
6114
|
+
positionals.push(tokens[i]);
|
|
6115
|
+
}
|
|
6116
|
+
const requested = (positionals.shift() ?? "").toLowerCase();
|
|
6117
|
+
// `list` is what the terminal calls it, and muscle memory arrives here.
|
|
6118
|
+
const action = requested === "list" ? "status" : requested;
|
|
6119
|
+
const name = positionals[0];
|
|
6120
|
+
const scope = flags.get("scope");
|
|
6121
|
+
|
|
6122
|
+
try {
|
|
6123
|
+
if (action === "") {
|
|
6124
|
+
await runMenu(ctx);
|
|
6125
|
+
return;
|
|
6126
|
+
}
|
|
6127
|
+
if (action === "help" || action === "--help") {
|
|
6128
|
+
emit("piagent-mcp-help", mcpActions.help(), ctx);
|
|
6129
|
+
return;
|
|
6130
|
+
}
|
|
6131
|
+
const terminal = mcpActions.terminalOnly(action);
|
|
6132
|
+
if (terminal) {
|
|
6133
|
+
emit("piagent-mcp-terminal", terminal, ctx);
|
|
6134
|
+
return;
|
|
6135
|
+
}
|
|
6136
|
+
if (!ACTIONS.has(action)) {
|
|
6137
|
+
fail(ctx, `unknown subcommand: ${action}. Run /piagent-mcp help.`);
|
|
6138
|
+
return;
|
|
6139
|
+
}
|
|
6140
|
+
if (mcpActions.SERVER_ACTIONS.has(action) && action !== "reset" && !name) {
|
|
6141
|
+
fail(ctx, `${action} needs a server name.`);
|
|
6142
|
+
return;
|
|
6143
|
+
}
|
|
6144
|
+
runAction(ctx, action, name, scope);
|
|
6145
|
+
} catch (error) {
|
|
6146
|
+
fail(ctx, error instanceof Error ? error.message : String(error));
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
});
|
|
6150
|
+
}
|
|
6151
|
+
registerMcpCommand();
|
|
6152
|
+
|
|
5723
6153
|
pi.registerCommand("context-index", {
|
|
5724
6154
|
description: "Show or search the compact project context index without a model follow-up",
|
|
5725
6155
|
handler: async (args, ctx) => {
|