@piagent/platform 1.2.0 → 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 +133 -1
- package/README.md +1 -1
- package/docs/command-reference-vietnamese.md +8 -3
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +49 -3
- package/docs/operator-manual-vietnamese.md +20 -8
- package/docs/publishing-for-teams.md +1 -0
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +4 -3
- package/docs/team-onboarding.md +3 -3
- package/docs/usage-observability.md +29 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/mcp-approval.html +54 -2
- package/docs-site/content/quickstart.html +1 -1
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +55 -3
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +2 -2
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/package.json +1 -1
- package/packages/piagent-core/capabilities/capability-core.js +41 -4
- package/packages/piagent-core/capabilities/capability-sources.js +73 -12
- package/packages/piagent-core/extensions/piagent-guard.ts +139 -25
- package/packages/piagent-core/extensions/policy-core.js +1586 -98
- package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
- package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
- package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
- package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
- package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
- package/packages/piagent-core/package.json +1 -1
- package/scripts/configure-model-scope.sh +19 -3
- package/scripts/configure-subagents.sh +17 -4
- package/scripts/init-project.sh +15 -8
- package/scripts/mcp-manage.mjs +111 -22
- 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/quality-benchmark.sh +37 -9
- package/scripts/setup.sh +21 -13
- package/scripts/uninstall-global.sh +10 -4
- package/scripts/verify-local.sh +19 -0
- package/scripts/verify-release-identity.mjs +29 -0
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
extractShellGlobCandidates,
|
|
13
13
|
extractShellPathCandidates,
|
|
14
14
|
findProtectedPathInCommand,
|
|
15
|
+
unresolvedPathExpansions,
|
|
15
16
|
globMatchesPath,
|
|
16
17
|
matchesProtectedPath,
|
|
17
18
|
normalizePathCandidate,
|
|
@@ -42,7 +43,14 @@ import {
|
|
|
42
43
|
} from "./redaction-core.js";
|
|
43
44
|
import { detectProfileName } from "./project-shape.js";
|
|
44
45
|
import { evaluateUpdateCheck, isInstalledPlatform, readUpdateCache, startUpdateProbe } from "./update-check.js";
|
|
45
|
-
import {
|
|
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";
|
|
46
54
|
import { approvalState } from "../mcp/mcp-approval-store.js";
|
|
47
55
|
import { evaluateServerReadiness, readinessNotice } from "../mcp/mcp-auth-readiness.js";
|
|
48
56
|
import * as mcpActions from "../mcp/mcp-command-actions.js";
|
|
@@ -1994,16 +2002,20 @@ function prepareToolInputForPolicy(
|
|
|
1994
2002
|
}
|
|
1995
2003
|
|
|
1996
2004
|
// 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,
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
|
|
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 {
|
|
2002
2015
|
if (normalizeActionToken(toolName) === "mcp") {
|
|
2003
2016
|
return typeof input.server === "string" && input.server.trim() ? input.server.trim() : undefined;
|
|
2004
2017
|
}
|
|
2005
|
-
|
|
2006
|
-
return direct?.[1];
|
|
2018
|
+
return attributeDirectTool(toolName, candidates);
|
|
2007
2019
|
}
|
|
2008
2020
|
|
|
2009
2021
|
// Servers a repository defines are not usable until somebody on this machine has
|
|
@@ -2014,25 +2026,71 @@ function mcpServerFromToolCall(toolName: string, input: Record<string, unknown>)
|
|
|
2014
2026
|
//
|
|
2015
2027
|
// Recomputed only when one of the files behind the decision changes, because
|
|
2016
2028
|
// this runs on every tool call.
|
|
2017
|
-
|
|
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 }>();
|
|
2018
2039
|
|
|
2019
|
-
function
|
|
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.
|
|
2020
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.
|
|
2021
2055
|
const signature = [
|
|
2022
|
-
...servers.map((server) => `${server.
|
|
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)}`),
|
|
2023
2059
|
`store:${fileSignature(path.join(os.homedir(), ".pi", "piagent-mcp-approvals.json"))}`
|
|
2024
2060
|
].join("|");
|
|
2025
2061
|
const cached = mcpApprovalCache.get(cwd);
|
|
2026
|
-
if (cached?.signature === signature) return cached.
|
|
2062
|
+
if (cached?.signature === signature) return cached.gate;
|
|
2027
2063
|
|
|
2028
|
-
const blocked = new Map<string, string>();
|
|
2064
|
+
const blocked = new Map<string, { state: string; origin: string }>();
|
|
2029
2065
|
for (const server of servers) {
|
|
2030
2066
|
const state = approvalState({ projectPath: cwd, name: server.name, entry: server.entry });
|
|
2031
2067
|
if (state.state === "approved") continue;
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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;
|
|
2036
2094
|
}
|
|
2037
2095
|
|
|
2038
2096
|
function fileSignature(file: string): string {
|
|
@@ -2049,15 +2107,26 @@ function evaluateMcpApproval(
|
|
|
2049
2107
|
toolName: string,
|
|
2050
2108
|
input: Record<string, unknown>
|
|
2051
2109
|
): { block: boolean; reason?: string } {
|
|
2052
|
-
const
|
|
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());
|
|
2053
2120
|
if (!server) return { block: false };
|
|
2054
|
-
const
|
|
2055
|
-
if (!
|
|
2056
|
-
const explanation = state === "rejected"
|
|
2121
|
+
const record = gate.blocked.get(server);
|
|
2122
|
+
if (!record) return { block: false };
|
|
2123
|
+
const explanation = record.state === "rejected"
|
|
2057
2124
|
? "it was rejected for this project"
|
|
2058
|
-
: state === "changed"
|
|
2125
|
+
: record.state === "changed"
|
|
2059
2126
|
? "its definition changed since it was approved"
|
|
2060
|
-
: "
|
|
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";
|
|
2061
2130
|
return {
|
|
2062
2131
|
block: true,
|
|
2063
2132
|
// Named as commands, because this is read inside a session: the reader can
|
|
@@ -2069,6 +2138,18 @@ function evaluateMcpApproval(
|
|
|
2069
2138
|
};
|
|
2070
2139
|
}
|
|
2071
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
|
+
|
|
2072
2153
|
function evaluateMcpProxyShellProtectedAccess(
|
|
2073
2154
|
cwd: string,
|
|
2074
2155
|
prepared: PreparedToolInput,
|
|
@@ -2112,6 +2193,10 @@ function evaluateMcpProxyShellProtectedAccess(
|
|
|
2112
2193
|
reason: `MCP command resolves to protected path: ${resolvedProtectedHit.candidate} resolves to ${resolvedProtectedHit.resolved} matching ${resolvedProtectedHit.pattern}`
|
|
2113
2194
|
};
|
|
2114
2195
|
}
|
|
2196
|
+
const unresolved = protectedPaths.length > 0 ? unresolvedPathExpansions(command) : [];
|
|
2197
|
+
if (unresolved.length > 0) {
|
|
2198
|
+
return { block: true, reason: unresolvedExpansionReason("MCP command", unresolved) };
|
|
2199
|
+
}
|
|
2115
2200
|
return { block: false };
|
|
2116
2201
|
}
|
|
2117
2202
|
|
|
@@ -3284,6 +3369,19 @@ function modelLabel(ctx: ExtensionContext): string {
|
|
|
3284
3369
|
return model.name ?? model.id ?? "unknown";
|
|
3285
3370
|
}
|
|
3286
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
|
+
|
|
3287
3385
|
function buildUsageSnapshot(ctx: ExtensionContext, thinkingLevel?: string): UsageSnapshot {
|
|
3288
3386
|
const contextUsage = ctx.getContextUsage();
|
|
3289
3387
|
const contextWithThinking = ctx as ExtensionContext & { getThinkingLevel?: () => string };
|
|
@@ -3310,7 +3408,8 @@ function buildUsageSnapshot(ctx: ExtensionContext, thinkingLevel?: string): Usag
|
|
|
3310
3408
|
availableInCommand: false,
|
|
3311
3409
|
howToRead: [
|
|
3312
3410
|
"Inside Pi TUI: run /session for exact tokens and cost.",
|
|
3313
|
-
"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."
|
|
3314
3413
|
]
|
|
3315
3414
|
}
|
|
3316
3415
|
};
|
|
@@ -3338,6 +3437,12 @@ function formatUsageSnapshot(snapshot: UsageSnapshot): string {
|
|
|
3338
3437
|
"",
|
|
3339
3438
|
"```bash",
|
|
3340
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",
|
|
3341
3446
|
"```"
|
|
3342
3447
|
].join("\n");
|
|
3343
3448
|
}
|
|
@@ -3823,7 +3928,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3823
3928
|
const explicitProfile = Boolean(process.env.PIAGENT_PROFILE?.trim());
|
|
3824
3929
|
const profile = loadProfile(ctx.cwd, projectTrusted);
|
|
3825
3930
|
const name = profile.displayName || profile.projectId || path.basename(ctx.cwd);
|
|
3826
|
-
|
|
3931
|
+
const operatorSessionName = currentSessionName(ctx);
|
|
3932
|
+
if (!hasOperatorSessionName(operatorSessionName)) {
|
|
3933
|
+
pi.setSessionName(`pi:${name}`);
|
|
3934
|
+
}
|
|
3827
3935
|
const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
|
|
3828
3936
|
? ""
|
|
3829
3937
|
: " (run /onboard-project to select a profile)";
|
|
@@ -4047,6 +4155,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4047
4155
|
reason: `Command resolves to protected path: ${resolvedProtectedHit.candidate} resolves to ${resolvedProtectedHit.resolved} matching ${resolvedProtectedHit.pattern}`
|
|
4048
4156
|
};
|
|
4049
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
|
+
}
|
|
4050
4164
|
|
|
4051
4165
|
const confirmationReasons = execDecision.mode !== "off" && execDecision.decision === "prompt"
|
|
4052
4166
|
? [...execDecision.reasons]
|