@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +1 -1
  3. package/docs/command-reference-vietnamese.md +8 -3
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +49 -3
  6. package/docs/operator-manual-vietnamese.md +20 -8
  7. package/docs/publishing-for-teams.md +1 -0
  8. package/docs/release-install-policy.md +6 -6
  9. package/docs/security-threat-model.md +4 -3
  10. package/docs/team-onboarding.md +3 -3
  11. package/docs/usage-observability.md +29 -0
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/commands.html +1 -1
  14. package/docs-site/content/mcp-approval.html +54 -2
  15. package/docs-site/content/quickstart.html +1 -1
  16. package/docs-site/index.html +1 -1
  17. package/docs-site/mcp-approval.html +55 -3
  18. package/docs-site/mcp-auth.html +1 -1
  19. package/docs-site/mcp-servers.html +1 -1
  20. package/docs-site/mcp.html +1 -1
  21. package/docs-site/permissions.html +1 -1
  22. package/docs-site/profiles.html +1 -1
  23. package/docs-site/quickstart.html +2 -2
  24. package/docs-site/runtime.html +1 -1
  25. package/docs-site/scope.html +1 -1
  26. package/docs-site/security.html +1 -1
  27. package/docs-site/team.html +1 -1
  28. package/docs-site/workflows.html +1 -1
  29. package/package.json +1 -1
  30. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  31. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  32. package/packages/piagent-core/extensions/piagent-guard.ts +139 -25
  33. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  34. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  35. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  36. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  37. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  38. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  39. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  40. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  41. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  42. package/packages/piagent-core/package.json +1 -1
  43. package/scripts/configure-model-scope.sh +19 -3
  44. package/scripts/configure-subagents.sh +17 -4
  45. package/scripts/init-project.sh +15 -8
  46. package/scripts/mcp-manage.mjs +111 -22
  47. package/scripts/pi-model-catalog.sh +11 -1
  48. package/scripts/pi-session-stats.sh +27 -1
  49. package/scripts/pi-usage-history.mjs +567 -0
  50. package/scripts/quality-benchmark.sh +37 -9
  51. package/scripts/setup.sh +21 -13
  52. package/scripts/uninstall-global.sh +10 -4
  53. package/scripts/verify-local.sh +19 -0
  54. 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 { REPOSITORY_SCOPES, collectServers } from "../mcp/mcp-config-layers.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";
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, 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 {
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
- const direct = toolName.match(/^mcp(?:__|[-_:]+)([^_:.-]+)/i);
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
- const mcpApprovalCache = new Map<string, { signature: string; blocked: Map<string, string> }>();
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 unapprovedMcpServers(cwd: string): Map<string, string> {
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.scope}:${server.name}:${fileSignature(server.file)}`),
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.blocked;
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
- blocked.set(server.name, state.state);
2033
- }
2034
- mcpApprovalCache.set(cwd, { signature, blocked });
2035
- return blocked;
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 server = mcpServerFromToolCall(toolName, input);
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 state = unapprovedMcpServers(cwd).get(server);
2055
- if (!state) return { block: false };
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
- : "this repository defines it and nobody on this machine has approved it";
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
- pi.setSessionName(`pi:${name}`);
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]