@piagent/platform 1.46.4 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +16 -3
  3. package/README.vi.md +16 -3
  4. package/docs/command-reference-vietnamese.md +4 -4
  5. package/docs/company-dev-commands.md +6 -5
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/languages.json +2 -2
  8. package/docs/mcp-and-tools.md +21 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/security-threat-model.md +3 -1
  13. package/docs/team-onboarding.md +2 -2
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/assets/search-en.json +1 -1
  16. package/docs-site/assets/search-vi.json +1 -1
  17. package/docs-site/benchmark.html +1 -1
  18. package/docs-site/commands.html +1 -1
  19. package/docs-site/content/en/mcp.html +8 -0
  20. package/docs-site/content/mcp.html +8 -0
  21. package/docs-site/context-engine.html +1 -1
  22. package/docs-site/ecosystem.html +1 -1
  23. package/docs-site/en/architecture.html +1 -1
  24. package/docs-site/en/benchmark.html +1 -1
  25. package/docs-site/en/commands.html +1 -1
  26. package/docs-site/en/context-engine.html +1 -1
  27. package/docs-site/en/ecosystem.html +1 -1
  28. package/docs-site/en/index.html +15 -15
  29. package/docs-site/en/mcp-approval.html +1 -1
  30. package/docs-site/en/mcp-auth.html +1 -1
  31. package/docs-site/en/mcp-servers.html +1 -1
  32. package/docs-site/en/mcp.html +9 -1
  33. package/docs-site/en/permissions.html +1 -1
  34. package/docs-site/en/profiles.html +1 -1
  35. package/docs-site/en/quickstart.html +1 -1
  36. package/docs-site/en/runtime.html +1 -1
  37. package/docs-site/en/scope.html +1 -1
  38. package/docs-site/en/security.html +1 -1
  39. package/docs-site/en/team.html +1 -1
  40. package/docs-site/en/update.html +3 -3
  41. package/docs-site/en/whats-new.html +59 -32
  42. package/docs-site/en/windows.html +1 -1
  43. package/docs-site/en/workflows.html +1 -1
  44. package/docs-site/index.html +15 -15
  45. package/docs-site/mcp-approval.html +1 -1
  46. package/docs-site/mcp-auth.html +1 -1
  47. package/docs-site/mcp-servers.html +1 -1
  48. package/docs-site/mcp.html +9 -1
  49. package/docs-site/permissions.html +1 -1
  50. package/docs-site/profiles.html +1 -1
  51. package/docs-site/quickstart.html +1 -1
  52. package/docs-site/releases.json +87 -0
  53. package/docs-site/runtime.html +1 -1
  54. package/docs-site/scope.html +1 -1
  55. package/docs-site/security.html +1 -1
  56. package/docs-site/team.html +1 -1
  57. package/docs-site/update.html +3 -3
  58. package/docs-site/whats-new.html +59 -32
  59. package/docs-site/windows.html +1 -1
  60. package/docs-site/workflows.html +1 -1
  61. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  62. package/package.json +2 -1
  63. package/packages/piagent-core/extensions/guard-types.ts +10 -2
  64. package/packages/piagent-core/extensions/piagent-guard.ts +106 -169
  65. package/packages/piagent-core/extensions/policy-core.js +7 -7
  66. package/packages/piagent-core/extensions/shell-ast.js +85 -0
  67. package/packages/piagent-core/extensions/shell-command-words.js +19 -0
  68. package/packages/piagent-core/managed/command-tools.mjs +70 -26
  69. package/packages/piagent-core/managed/fetch-guard.mjs +3 -3
  70. package/packages/piagent-core/managed/host-command.mjs +15 -7
  71. package/packages/piagent-core/managed/member-mcp.mjs +96 -0
  72. package/packages/piagent-core/managed/memory-lessons.mjs +1 -1
  73. package/packages/piagent-core/managed/permission.mjs +19 -12
  74. package/packages/piagent-core/managed/repository-operation.mjs +1 -1
  75. package/packages/piagent-core/managed/resource-loader.mjs +19 -7
  76. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +4 -0
  77. package/packages/piagent-core/managed/session.mjs +4 -4
  78. package/packages/piagent-core/managed/simulator-commands.mjs +117 -0
  79. package/packages/piagent-core/mcp/mcp-server-catalog.js +18 -1
  80. package/packages/piagent-core/package.json +1 -1
  81. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +1 -1
  82. package/packages/piagent-core/runtime/inspection/approval-broker.ts +33 -16
  83. package/packages/piagent-core/runtime/mcp-repository-gate.d.mts +10 -0
  84. package/packages/piagent-core/runtime/mcp-repository-gate.mjs +150 -0
  85. package/packages/piagent-core/runtime/policy/handoff-policy.mjs +144 -0
  86. package/packages/piagent-core/runtime/registration/permission-commands.ts +2 -2
  87. package/packages/piagent-webui/dist/client/assets/{App-CiwY9VpU.js → App-fZeIRoaD.js} +1 -1
  88. package/packages/piagent-webui/dist/client/assets/{GatewayApp-DrNJW3v_.js → GatewayApp-CpC4lgYK.js} +17 -15
  89. package/packages/piagent-webui/dist/client/assets/{InspectionApp-BBiwSaV0.js → InspectionApp-pD-BqBXm.js} +1 -1
  90. package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DwyPebIX.js → SessionAgentWorkspace-3tM7Q6HH.js} +1 -1
  91. package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CpVzaf_G.js → SessionInspectorDrawer-BJ94aQ6y.js} +2 -2
  92. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BFfqMcuL.js +3 -0
  93. package/packages/piagent-webui/dist/client/assets/SettingsPage-CxpZ7cdM.js +1 -0
  94. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-B8j_r6-y.js → TerminalPanel-CtZyb0tj.js} +1 -1
  95. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-BDTPvq-0.js → WorkspaceCodeEditor-BS_ZrjPN.js} +1 -1
  96. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DVsNU0eu.js → WorkspaceExplorer-CkFBpcM7.js} +3 -3
  97. package/packages/piagent-webui/dist/client/assets/{index-uhJMXxqD.js → index-CNdhIouM.js} +2 -2
  98. package/packages/piagent-webui/dist/client/assets/{index-Dqb2hNan.css → index-CjM70M7D.css} +1 -1
  99. package/packages/piagent-webui/dist/client/assets/transcript-view-model-CpEMY1xb.js +3 -0
  100. package/packages/piagent-webui/dist/client/index.html +2 -2
  101. package/packages/piagent-webui/gateway/gateway-events.ts +5 -1
  102. package/packages/piagent-webui/server/gateway-websocket.ts +5 -1
  103. package/schemas/piagent-webui/approval-v1.schema.json +3 -1
  104. package/schemas/project-profile.schema.json +16 -0
  105. package/scripts/company-terminal.mjs +5 -5
  106. package/scripts/setup.sh +1 -1
  107. package/scripts/verify-release-identity.mjs +1 -1
  108. package/packages/piagent-core/runtime/policy/bypass-policy.mjs +0 -42
  109. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +0 -3
  110. package/packages/piagent-webui/dist/client/assets/SettingsPage-Ca-qRSZa.js +0 -1
  111. package/packages/piagent-webui/dist/client/assets/transcript-view-model-Bq0nkxyu.js +0 -3
@@ -26,10 +26,10 @@ export function projectInstructions(cwd, top = cwd) {
26
26
  // discovered in a project. Skills and commands are text (agent-skills.mjs):
27
27
  // what they ask for runs with the session's own tools.
28
28
  // themes: Piagent's own (the company Terminal's /style), loaded by the host.
29
- export function managedResourceLoader(api, { systemPrompt, extensions = [], runtime = api.createExtensionRuntime(), agentsFiles = [], skills = [], prompts = [], themes = [] } = {}) {
29
+ export function managedResourceLoader(api, { systemPrompt, extensions = [], runtime = api.createExtensionRuntime(), errors = [], agentsFiles = [], skills = [], prompts = [], themes = [] } = {}) {
30
30
  const empty = Object.freeze([]);
31
31
  return Object.freeze({
32
- getExtensions: () => ({ extensions, errors: empty, runtime }),
32
+ getExtensions: () => ({ extensions, errors: [...errors], runtime }),
33
33
  getSkills: () => ({ skills: [...skills], diagnostics: empty }),
34
34
  getPrompts: () => ({ prompts: [...prompts], diagnostics: empty }),
35
35
  getThemes: () => ({ themes: [...themes], diagnostics: empty }),
@@ -45,12 +45,24 @@ export function managedResourceLoader(api, { systemPrompt, extensions = [], runt
45
45
 
46
46
  // Extensions the company Terminal adds itself (its commands and how Harness
47
47
  // notes read), built the way Pi builds an inline extension. Never project code.
48
- export async function inlineExtensions(api, sdkRoot, cwd, factories = []) {
48
+ // `paths`: installed extensions loaded the way Pi loads a package's (only the
49
+ // member's MCP adapter, member-mcp.mjs); one that fails to load is reported in
50
+ // `errors` and the session opens without it.
51
+ export async function inlineExtensions(api, sdkRoot, cwd, factories = [], paths = []) {
49
52
  const runtime = api.createExtensionRuntime();
50
- if (!factories.length) return { extensions: [], runtime };
53
+ if (!factories.length && !paths.length) return { extensions: [], runtime };
51
54
  const load = (file) => import(pathToFileURL(path.join(sdkRoot, 'dist', ...file.split('/'))).href);
52
- const [{ loadExtensionFromFactory }, { createEventBus }] = await Promise.all([load('core/extensions/loader.js'), load('core/event-bus.js')]);
53
- const bus = createEventBus(), extensions = [];
55
+ const [{ loadExtensionFromFactory, loadExtensions }, { createEventBus }] = await Promise.all([load('core/extensions/loader.js'), load('core/event-bus.js')]);
56
+ const bus = createEventBus(), extensions = [], errors = [];
54
57
  for (const [index, factory] of factories.entries()) extensions.push(await loadExtensionFromFactory(factory, cwd, bus, runtime, `<inline:company-terminal-${index + 1}>`));
55
- return { extensions, runtime };
58
+ if (paths.length) {
59
+ // Pi's theme exists only once a terminal draws; the MCP adapter colours its
60
+ // status line with it in every mode, and without one (RPC, the WebUI) its
61
+ // start failed. The Terminal sets its own theme later.
62
+ const { theme, initTheme } = await load('modes/interactive/theme/theme.js');
63
+ try { void theme.fg; } catch { initTheme(); }
64
+ try { const loaded = await loadExtensions(paths, cwd, bus, runtime); extensions.push(...loaded.extensions); errors.push(...loaded.errors); }
65
+ catch (error) { errors.push({ path: paths.join(', '), error: error instanceof Error ? error.message : String(error) }); }
66
+ }
67
+ return { extensions, runtime, errors };
56
68
  }
@@ -21,11 +21,15 @@ const DOCKER_DAEMON = /Cannot connect to the Docker daemon|connect to the docker
21
21
  // A runtime or tool the command needs is not on this machine (or not the
22
22
  // version the project asks for).
23
23
  const TOOLCHAIN = /Unable to locate a Java Runtime|No Java runtime present|JAVA_HOME is not (?:set|defined correctly)|(?:^|\n)\S*?(?:ba|z)?sh: (?:line \d+: )?[\w.+-]+: command not found|zsh: command not found: [\w.+-]+|xcrun: error: (?:unable to find utility|invalid active developer path)|env: '?[\w.+-]+'?: No such file or directory|Unsupported class file major version|invalid target release: \d+|release version \d+ not supported|requires? (?:at least )?Java \d+|The engine "node" is incompatible|Unsupported engine/i;
24
+ // The iOS Simulator (CoreSimulator) and Android's adb/emulator, which the
25
+ // sandbox does not reach.
26
+ const SIMULATOR = /CoreSimulator|simctl|Unable to boot device|Unable to lookup in current state|Failed to start launchd_sim|adb: (?:cannot connect|failed to check server version|no devices|device offline)|cannot connect to daemon|\bemulator: (?:ERROR|PANIC)|Unable to find a destination matching|xcodebuild: error: Unable to find a destination/i;
24
27
  // The member's sign-in, which the sandbox does not have.
25
28
  const SIGN_IN = /could not read (?:Username|Password) for|terminal prompts disabled|Authentication failed for|Permission denied \(publickey|Host key verification failed|not logged into any GitHub hosts|please run:\s+gh auth login|Could not open a connection to your authentication agent|\bENEEDAUTH\b|\bE401\b|\b401 Unauthorized|requires authentication|denied: requested access to the resource is denied|pull access denied/i;
26
29
 
27
30
  export function sandboxDiagnostic(message, { network = false, isolated = false, docker = false, engine = false } = {}) {
28
31
  const text=String(message);
32
+ if(!network&&SIMULATOR.test(text))return 'managed-simulator-unreachable: Lệnh bash trong sandbox không tới được iOS Simulator hay Android emulator. Chạy lệnh simulator (xcrun simctl, xcodebuild cho simulator, adb, emulator, ./gradlew installDebug, flutter, npx expo) bằng run_on_simulator.\n'+text;
29
33
  if(docker&&DOCKER_DAEMON.test(text))return 'managed-docker-unreachable: Lệnh chạy với Docker nhưng Docker engine không trả lời (Docker Desktop/Colima/OrbStack chưa chạy hoặc đang khởi động). Gọi start_docker để xin member duyệt mở Docker, rồi chạy lại đúng lệnh này bằng run_with_docker.\n'+text;
30
34
  if(!docker&&DOCKER_DAEMON.test(text))return (engine?'managed-docker-blocked: Lệnh bash thường không dùng được Docker trong chế độ công ty. Lệnh docker, docker compose, hoặc test cần Docker (Testcontainers) chạy qua run_with_docker để người dùng duyệt đúng lệnh đó.\n':'managed-docker-unavailable: Docker engine trên máy chưa chạy. Gọi start_docker để xin member duyệt mở Docker, rồi chạy lệnh bằng run_with_docker.\n')+text;
31
35
  if(TOOLCHAIN.test(text))return 'managed-toolchain-missing: Máy thiếu runtime/công cụ mà lệnh cần, hoặc sai version project yêu cầu. Đừng dừng ở đây: xem project cần gì (pom.xml, build.gradle, .nvmrc, .tool-versions, package.json engines…) rồi dùng run_on_host để xin member duyệt cài trên máy (ví dụ brew install openjdk@21, brew install node@22, sdk install java 21-tem), sau đó chạy lại lệnh. Nếu project build được trong Docker thì có thể đề xuất run_with_docker. Trình cài cần mật khẩu (sudo, file .pkg) thì hỏi member bằng ask_user.\n'+text;
@@ -24,7 +24,7 @@ import { readPatchDigest, readPatchSnapshot, readChangedFiles, readHead } from '
24
24
  import { memberTurn } from './continuation.mjs';
25
25
  import { workflowPolicy, repositoryChecks, sessionPlanTool, currentPlan, workflowPrompt, RunProcess, completionGate, pendingBaseline, pendingPaths, pendingHead, processEditTools } from './workflow.mjs';
26
26
  import { workflowContextTool, workflowContextExtension } from './workflow-context.mjs';
27
- import { beforeDelegate, resolveTool } from './objections.mjs'; import { fanOut, withDroppedNote } from './copies.mjs';
27
+ import { beforeDelegate, resolveTool } from './objections.mjs'; import { fanOut, withDroppedNote } from './copies.mjs'; import { withMemberMcp } from './member-mcp.mjs';
28
28
  import { HELPER_CALLS, HELPER_ROLES, helperRoles, helperPrompt, webPrompt, delegateDescription, mainWebLookups, limitedWebTools } from './helper-roles.mjs'; import { delegateParameters, delegateArgs, SPEC_PROMPT } from './helper-settlement.mjs';
29
29
  import { loadAgentResources } from './agent-skills.mjs';
30
30
  import { loadPiagentThemes } from '../runtime/terminal/terminal-style.mjs';
@@ -137,8 +137,8 @@ export class ManagedSession {
137
137
  self.checks = repositoryChecks(agentsFiles, self.cwd, self.boundary.repositoryTop);
138
138
  // The Harness workflow (possibly changed on a later enrollment) adds its process to the prompt.
139
139
  const loader = managedResourceLoader(api, { systemPrompt: () => BASE_PROMPT + referencePrompt(self.boundary.userHome) + TEST_PROMPT + servicesPrompt(self.boundary) + webPrompt(helperRoles(manifest), mainWebLookups(manifest)) + helperPrompt(helperRoles(self.manifest)) + workflowPrompt(workflowPolicy(self.manifest), self.checks), agentsFiles,
140
- skills: self.resources.skills, prompts: self.resources.prompts, themes: terminalExtensions.length ? await loadPiagentThemes(sdk) : [], ...await inlineExtensions(api, sdk, self.cwd,
141
- [...terminalExtensions, workflowContextExtension(self.cwd, () => self.session.sessionManager)]) });
140
+ skills: self.resources.skills, prompts: self.resources.prompts, themes: terminalExtensions.length ? await loadPiagentThemes(sdk) : [], ...await inlineExtensions(api, sdk, self.cwd, // and the member's MCP servers (member-mcp.mjs)
141
+ ...withMemberMcp(self.cwd, [...terminalExtensions, workflowContextExtension(self.cwd, () => self.session.sessionManager)], undefined, self)) });
142
142
  const settings = api.SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } }, cacheWarming: 'off', enableInstallTelemetry: false, enableAnalytics: false, enableSkillCommands: true });
143
143
  const manager = sessionManager ?? api.SessionManager.inMemory(self.cwd); attachMemory(self, manager, agentDir, g => verifyGrant(g, self.manifest, 'main')); self.studioLink = new StudioLink({ origin: self.origin, agentDir, notify: event => self.notify(event), ui: () => self.session?.extensionRunner?.getUIContext?.() });
144
144
  const scope = scopeOf(self.origin, manifest);
@@ -169,7 +169,7 @@ export class ManagedSession {
169
169
  self.session = result.session;
170
170
  self.session.managedExecution = true;
171
171
  self.turnRetry = enableCompanyTurnRetry(self.session, settings, self.studioLink);
172
- // The member's access for this conversation: ask first, or Bypass (permission.mjs).
172
+ // The member's access for this conversation: Ask first, Full access or Bypass (permission.mjs).
173
173
  self.permission = restorePermission(manager); recordBypass(self, manager); self.session.managedSetPermission = permissionSetter(self, manager);
174
174
  // Fast mode, where the Harness allows it (fast-mode.mjs): off until the member turns it on.
175
175
  self.fast = restoreFast(manager); recordFast(self, manager); self.session.managedSetFast = fastSetter(self, manager); self.session.managedFastState = () => fastState(self); // null where the Harness does not allow it
@@ -0,0 +1,117 @@
1
+ import path from 'node:path';
2
+
3
+ // What run_on_simulator runs on the member's machine: the iOS Simulator and
4
+ // Android emulator, and the builds that install an app on them (the sandbox
5
+ // reaches neither CoreSimulator nor adb). Only these programs, joined by &&,
6
+ // || or a pipe into an output filter; output may go to a file in the project
7
+ // (a screenshot), nothing else leaves it. Anything wider is run_on_host.
8
+
9
+ const XCRUN = new Set(['simctl', 'xcodebuild', 'devicectl', 'xcresulttool', 'xctrace']);
10
+ // `simctl spawn` starts a program of the simulator's runtime as the member:
11
+ // only `log` (the app's logs).
12
+ const simctl = words => words[1] !== 'simctl' || words[2] !== 'spawn' || words.slice(3).filter(word => !word.startsWith('-'))[1] === 'log';
13
+ const NPX = /^(expo|react-native|@react-native-community\/cli|cap|@capacitor\/cli)(@[\w.-]+)?$/;
14
+ // Program → whether its arguments are allowed (`words` without assignments,
15
+ // `inProject` a path check against the project folder).
16
+ const PROGRAMS = {
17
+ xcrun: words => XCRUN.has(words[1]) && simctl(words),
18
+ xcodebuild: () => true,
19
+ open: words => words.length === 3 && words[1] === '-a' && words[2] === 'Simulator',
20
+ adb: () => true, emulator: () => true, avdmanager: () => true, sdkmanager: () => true,
21
+ gradlew: (words, inProject) => inProject(words[0]),
22
+ // Taps, typing and the screen's accessibility tree; `axe init` writes into other tools' settings.
23
+ axe: words => !!words[1] && words[1] !== 'init',
24
+ xcodegen: words => words[1] === 'generate',
25
+ flutter: () => true,
26
+ pod: words => words[1] === 'install',
27
+ npx: words => NPX.test(words[1] === '--yes' || words[1] === '-y' ? words[2] ?? '' : words[1] ?? ''),
28
+ // Waiting for a boot, and reading what a build printed.
29
+ sleep: words => words.length === 2 && /^\d{1,3}$/.test(words[1]),
30
+ xcbeautify: () => true, xcpretty: () => true,
31
+ grep: () => true, head: () => true, tail: () => true, wc: () => true, sort: () => true, uniq: () => true, jq: () => true,
32
+ cd: (words, inProject) => words.length === 2 && inProject(words[1]),
33
+ mkdir: (words, inProject) => { const paths = words.slice(1).filter(word => word !== '-p'); return paths.length > 0 && paths.every(inProject); },
34
+ };
35
+ // Variables a command may set for itself; never what changes how programs load.
36
+ const ASSIGNMENT = /^([A-Z][A-Z0-9_]*)=/;
37
+ const UNSAFE_VARIABLE = /^(PATH|DYLD_\w*|LD_\w*|NODE_OPTIONS|BASH_ENV|ENV|ZDOTDIR|SHELL|HOME)$/;
38
+
39
+ // A path in the project folder: relative without climbing out of it, or
40
+ // absolute inside it (agents name files by their full path). Without the
41
+ // folder, only a relative one.
42
+ function insideProject(cwd) {
43
+ return target => {
44
+ if (typeof target !== 'string' || !target || target.startsWith('~') || target.includes('$') || target.split('/').includes('..')) return false;
45
+ if (!target.startsWith('/')) return true;
46
+ if (!cwd) return false;
47
+ const relative = path.relative(path.resolve(cwd), path.resolve(target));
48
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
49
+ };
50
+ }
51
+
52
+ // Shell words with their quotes resolved, and the operators between them.
53
+ // null for what this reader does not follow (an unclosed quote).
54
+ function tokens(command) {
55
+ const out = []; let word = null, quote = null;
56
+ const push = () => { if (word !== null) { out.push({ word }); word = null; } };
57
+ for (let i = 0; i < command.length; i += 1) {
58
+ const c = command[i];
59
+ if (quote) { if (c === quote) quote = null; else if (c === '\\' && quote === '"' && i + 1 < command.length) word += command[++i]; else word += c; continue; }
60
+ if (c === "'" || c === '"') { quote = c; word ??= ''; continue; }
61
+ if (c === '\\' && i + 1 < command.length) { word = (word ?? '') + command[++i]; continue; }
62
+ if (/\s/.test(c)) { push(); continue; }
63
+ const two = command.slice(i, i + 2);
64
+ if (['&&', '||', '>>', '2>'].includes(two)) {
65
+ push();
66
+ if (two === '2>' && command[i + 2] === '&' && command[i + 3] === '1') { out.push({ op: '2>&1' }); i += 3; continue; }
67
+ out.push({ op: two }); i += 1; continue;
68
+ }
69
+ if (c === '|' || c === '>') { push(); out.push({ op: c }); continue; }
70
+ word = (word ?? '') + c;
71
+ }
72
+ if (quote) return null;
73
+ push();
74
+ return out;
75
+ }
76
+
77
+ // Why the command is not a simulator command, or null when it is. `cwd`:
78
+ // the project folder, where output files and `cd` may go.
79
+ export function simulatorCommandReason(command, cwd = null) {
80
+ const text = String(command ?? ''), inProject = insideProject(cwd);
81
+ if (!text.trim()) return 'the command is empty';
82
+ if (/[`;\n]|\$\(|<\(|>\(|</.test(text) || text.replaceAll('&&', '').replaceAll('2>&1', '').includes('&')) return 'only commands joined by &&, || or | are run (no ;, &, $( ), backticks or subshells)';
83
+ const list = tokens(text);
84
+ if (!list) return 'an unclosed quote';
85
+ let words = [];
86
+ const close = () => {
87
+ if (!words.length) return 'an empty part between operators';
88
+ while (words.length && ASSIGNMENT.test(words[0])) {
89
+ const name = ASSIGNMENT.exec(words[0])[1];
90
+ if (UNSAFE_VARIABLE.test(name)) return `${name} cannot be set here`;
91
+ words = words.slice(1);
92
+ }
93
+ const name = words[0]?.split('/').pop();
94
+ const allowed = Object.hasOwn(PROGRAMS, name) && PROGRAMS[name](words, inProject);
95
+ words = [];
96
+ return allowed ? null : `${name ?? 'this part'} is not a simulator or app build command (use run_on_host for other commands)`;
97
+ };
98
+ for (let i = 0; i < list.length; i += 1) {
99
+ const { word, op } = list[i];
100
+ if (word !== undefined) { words.push(word); continue; }
101
+ if (op === '2>&1') continue;
102
+ if (op === '>' || op === '>>' || op === '2>') {
103
+ const target = list[i + 1]?.word;
104
+ if (!(target === '/dev/null' || inProject(target))) return 'output may go only to a file inside the project or /dev/null';
105
+ i += 1; continue;
106
+ }
107
+ const reason = close(); if (reason) return reason;
108
+ }
109
+ return close();
110
+ }
111
+
112
+ // A lone `emulator …` keeps running while the virtual device is up: it is
113
+ // started detached and the tool returns once it is launched.
114
+ export function detachedEmulator(command) {
115
+ const list = tokens(String(command ?? ''));
116
+ return !!list && list.length > 0 && list.every(token => token.word !== undefined) && list[0].word.split('/').pop() === 'emulator';
117
+ }
@@ -151,6 +151,18 @@ export const CATALOG_SERVERS = {
151
151
  directTools: false
152
152
  }
153
153
  },
154
+ // Atlassian's own remote server (Jira and Confluence Cloud). It registers its
155
+ // OAuth client itself (the metadata sits at the origin, not at the protected
156
+ // resource), so the member signs in with /mcp-auth and their own Jira access.
157
+ atlassian: {
158
+ description: "Atlassian Rovo MCP server: Jira and Confluence Cloud, with the member's own Atlassian login (OAuth).",
159
+ config: {
160
+ url: "https://mcp.atlassian.com/v1/mcp",
161
+ auth: "oauth",
162
+ lifecycle: "lazy",
163
+ directTools: false
164
+ }
165
+ },
154
166
  // Converts .docx, .xlsx, .pptx and .pdf to Markdown locally. Local matters:
155
167
  // the alternative to this is uploading a document somewhere to read it, and a
156
168
  // document worth converting is usually one worth not uploading.
@@ -202,6 +214,10 @@ export const CATALOG_PREREQUISITES = {
202
214
  "Developer Preview: no Pi OAuth end-to-end release proof exists yet",
203
215
  "Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified"
204
216
  ],
217
+ atlassian: [
218
+ "a Jira or Confluence Cloud site (Server and Data Center are not served by this endpoint)",
219
+ "sign in once with /mcp-auth atlassian; a site admin may have to allow the Atlassian MCP connection"
220
+ ],
205
221
  markitdown: [
206
222
  "uv on PATH (https://docs.astral.sh/uv); the first run downloads a Python runtime and roughly 120 MB of dependencies",
207
223
  "note: markitdown-mcp is published as an alpha, so pin changes deliberately"
@@ -244,11 +260,12 @@ export const CATALOG_PRESETS = {
244
260
  "google-mail": ["gmail"],
245
261
  google: ["google-drive", "google-docs", "google-sheets"],
246
262
  "google-all": ["google-drive", "gmail", "google-docs", "google-sheets"],
263
+ atlassian: ["atlassian"],
247
264
  core: ["context7", "chrome-devtools", "github"],
248
265
  popular: ["context7", "chrome-devtools", "playwright", "github", "figma-desktop"],
249
266
  all: [
250
267
  "context7", "chrome-devtools", "playwright", "github", "figma-desktop", "figma",
251
- "markitdown", "google-drive", "gmail", "google-docs", "google-sheets"
268
+ "markitdown", "atlassian", "google-drive", "gmail", "google-docs", "google-sheets"
252
269
  ]
253
270
  };
254
271
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.46.4",
3
+ "version": "1.48.0",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -320,7 +320,7 @@ export function registerSessionStartHook(pi: ExtensionAPI, dependencies: Session
320
320
  ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
321
321
  }
322
322
  if (permissionProfile.mode === "bypass") {
323
- ctx.ui.notify("Piagent bypass is active: no confirmation, protected-path or policy gate applies in this personal session. Output redaction and /undo checkpoints remain.", "warning");
323
+ ctx.ui.notify("Piagent bypass is active: the work runs without confirmation, protected-path or policy gates in this personal session. The handoff (push, PR, publish, deploy, remote tools, MCP writes) is still asked; output redaction and /undo checkpoints remain.", "warning");
324
324
  }
325
325
  const mcpNotice = dependencies.mcpReadinessNotice(ctx.cwd);
326
326
  if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
@@ -40,8 +40,15 @@ type Binding = { cwd: string; rawSessionId: string; runtimeInstanceId: string; s
40
40
  type Pending = { request: Request; resolve(value: ApprovalGuardDecision): void; receipt: Receipt | null; timer: NodeJS.Timeout;
41
41
  finalWaiters: Array<(receipt: Receipt) => void>; recheck(): boolean };
42
42
  export type ApprovalBrokerEvent = { kind: "requested" | "resolved" | "expired" | "cancelled"; approvalRef: string; request?: Request; receipt?: Receipt };
43
- export type ApprovalGuardDecision = { allowed: boolean; brokered: boolean; receipt: Receipt | null;
43
+ // What the member chose: allow this exact action once, allow the same exact
44
+ // action for the rest of the session (offered only where the request allows
45
+ // it, never for a handoff), deny it, or deny it and stop the turn so the
46
+ // agent does not try again. A decision without a choice is once or deny.
47
+ export type ApprovalChoice = "once" | "session" | "deny" | "stop";
48
+ export type ApprovalGuardDecision = { allowed: boolean; brokered: boolean; receipt: Receipt | null; choice: ApprovalChoice;
44
49
  consume(): boolean; cancel(reasonCode?: string): void };
50
+ const choiceOf = (allowed: boolean, value?: { scope?: unknown; stop?: unknown }): ApprovalChoice =>
51
+ allowed ? (value?.scope === "session" ? "session" : "once") : (value?.stop === true ? "stop" : "deny");
45
52
  export type ApprovalUnavailableFallback = "terminal-confirm" | "deny";
46
53
 
47
54
  function canonical(value: unknown): string {
@@ -54,6 +61,12 @@ function exactTimestamp(value: unknown): value is string {
54
61
  if (typeof value !== "string" || !TIMESTAMP.test(value)) return false;
55
62
  const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
56
63
  }
64
+ // The member's answer in the terminal: their choice where the caller offers
65
+ // the four, else yes or no.
66
+ function terminalChoice(input: { terminalConfirm(): Promise<boolean>; terminalChoose?(): Promise<ApprovalChoice> }): () => Promise<ApprovalChoice> {
67
+ return input.terminalChoose ? () => input.terminalChoose!() : () => input.terminalConfirm().then((allow) => allow ? "once" as const : "deny" as const);
68
+ }
69
+
57
70
  function exactKeys(value: unknown, keys: string[]): boolean {
58
71
  return Boolean(value && typeof value === "object" && !Array.isArray(value)
59
72
  && canonical(Object.keys(value as Record<string, unknown>).sort()) === canonical([...keys].sort()));
@@ -135,7 +148,7 @@ export class PiApprovalBroker {
135
148
  }
136
149
 
137
150
  async request(input: { cwd: string; rawSessionId: string; toolCallId: string; action: ApprovalActionDraft;
138
- expectedTask?: { taskId: string; taskRunId: string } | null; terminalConfirm(): Promise<boolean>;
151
+ expectedTask?: { taskId: string; taskRunId: string } | null; terminalConfirm(): Promise<boolean>; terminalChoose?(): Promise<ApprovalChoice>;
139
152
  unavailableFallback?: ApprovalUnavailableFallback; recheck?(): boolean; ttlMs?: number }): Promise<ApprovalGuardDecision> {
140
153
  const binding = this.#bindings.get(key(input.cwd, input.rawSessionId));
141
154
  const authority = binding?.authority();
@@ -166,8 +179,8 @@ export class PiApprovalBroker {
166
179
  while (binding.requests.size > MAX_PENDING + MAX_RECENT) binding.requests.delete(binding.requests.keys().next().value as string);
167
180
  });
168
181
  this.#emit(binding, { kind: "requested", approvalRef, request: clone(request) });
169
- void Promise.resolve().then(input.terminalConfirm).then(
170
- (allow) => this.#settle(binding, approvalRef, allow ? "allow" : "deny", "terminal", ref("decision", binding.secret, [approvalRef, "terminal", now])),
182
+ void Promise.resolve().then(terminalChoice(input)).then(
183
+ (choice) => this.#settle(binding, approvalRef, choice === "once" || choice === "session" ? "allow" : "deny", "terminal", ref("decision", binding.secret, [approvalRef, "terminal", now]), choice),
171
184
  () => this.#settle(binding, approvalRef, "deny", "terminal", ref("decision", binding.secret, [approvalRef, "terminal-error", now]))
172
185
  );
173
186
  return result;
@@ -191,20 +204,21 @@ export class PiApprovalBroker {
191
204
  }
192
205
  if (Date.now() >= Date.parse(pending.request.expiresAt)) { this.#expire(binding, approvalRef); throw new Error("approval-expired"); }
193
206
  const final = new Promise<Receipt>((resolve) => pending.finalWaiters.push(resolve));
194
- this.#settle(binding, approvalRef, decision.decision, "webui", decision.decisionId);
207
+ this.#settle(binding, approvalRef, decision.decision, "webui", decision.decisionId, choiceOf(decision.decision === "allow", decision));
195
208
  if (decision.decision === "deny") return clone(binding.recent.at(-1)!);
196
209
  return final;
197
210
  }
198
211
 
199
- #terminalOnly(confirm: () => Promise<boolean>): Promise<ApprovalGuardDecision> {
200
- return Promise.resolve().then(confirm).then((allowed) => ({ allowed, brokered: false, receipt: null,
201
- consume: () => allowed, cancel: () => undefined }), () => ({ allowed: false, brokered: false, receipt: null,
202
- consume: () => false, cancel: () => undefined }));
212
+ #terminalOnly(choose: () => Promise<ApprovalChoice>): Promise<ApprovalGuardDecision> {
213
+ return Promise.resolve().then(choose).then((choice) => {
214
+ const allowed = choice === "once" || choice === "session";
215
+ return { allowed, brokered: false, receipt: null, choice, consume: () => allowed, cancel: () => undefined };
216
+ }, () => ({ allowed: false, brokered: false, receipt: null, choice: "deny" as const, consume: () => false, cancel: () => undefined }));
203
217
  }
204
218
 
205
- #unavailable(input: { terminalConfirm(): Promise<boolean>; unavailableFallback?: ApprovalUnavailableFallback }): Promise<ApprovalGuardDecision> {
206
- if (input.unavailableFallback !== "deny") return this.#terminalOnly(input.terminalConfirm);
207
- return Promise.resolve({ allowed: false, brokered: false, receipt: null, consume: () => false, cancel: () => undefined });
219
+ #unavailable(input: { terminalConfirm(): Promise<boolean>; terminalChoose?(): Promise<ApprovalChoice>; unavailableFallback?: ApprovalUnavailableFallback }): Promise<ApprovalGuardDecision> {
220
+ if (input.unavailableFallback !== "deny") return this.#terminalOnly(terminalChoice(input));
221
+ return Promise.resolve({ allowed: false, brokered: false, receipt: null, choice: "deny", consume: () => false, cancel: () => undefined });
208
222
  }
209
223
 
210
224
  #action(binding: Binding, draft: ApprovalActionDraft, cwd: string): Record<string, unknown> {
@@ -238,9 +252,12 @@ export class PiApprovalBroker {
238
252
  }
239
253
 
240
254
  #validDecision(value: unknown, request?: Request, receipt?: Receipt | null): boolean {
255
+ const optional = value && typeof value === "object" ? ["scope", "stop"].filter((name) => Object.hasOwn(value, name)) : [];
241
256
  if (!exactKeys(value, ["schemaVersion", "version", "recordType", "approvalRef", "decisionId", "decisionToken", "identity", "actionDigest",
242
- "expectedRevisions", "decision", "reason", "decidedAt", "expiresAt", "decisionSurface", "executor", "directExecution"])) return false;
257
+ "expectedRevisions", "decision", "reason", "decidedAt", "expiresAt", "decisionSurface", "executor", "directExecution", ...optional])) return false;
243
258
  const item = value as any;
259
+ if (Object.hasOwn(item, "scope") && !(["once", "session"].includes(item.scope) && (item.scope === "once" || item.decision === "allow"))) return false;
260
+ if (Object.hasOwn(item, "stop") && !(typeof item.stop === "boolean" && (!item.stop || item.decision === "deny"))) return false;
244
261
  if (item.schemaVersion !== 1 || item.version !== "piagent-webui-approval-v1" || item.recordType !== "decision"
245
262
  || !REF.test(item.approvalRef) || !REF.test(item.decisionId) || typeof item.decisionToken !== "string" || item.decisionToken.length < 32
246
263
  || !["allow", "deny"].includes(item.decision) || !exactTimestamp(item.decidedAt) || !exactTimestamp(item.expiresAt)
@@ -258,7 +275,7 @@ export class PiApprovalBroker {
258
275
  return value === null || Boolean(REVISION.test(value.workspaceRevision) && (value.indexRevision === null || REVISION.test(value.indexRevision)) && DIGEST.test(value.preimageDigest));
259
276
  }
260
277
 
261
- #settle(binding: Binding, approvalRef: string, decision: "allow" | "deny", surface: "webui" | "terminal", decisionId: string): void {
278
+ #settle(binding: Binding, approvalRef: string, decision: "allow" | "deny", surface: "webui" | "terminal", decisionId: string, choice: ApprovalChoice = decision === "allow" ? "once" : "deny"): void {
262
279
  const pending = binding.pending.get(approvalRef); if (!pending || pending.receipt) return;
263
280
  clearTimeout(pending.timer); const resolvedAt = new Date().toISOString();
264
281
  const postRevision = this.#nextRevision(binding, "resolved", [approvalRef, decision, surface]);
@@ -272,7 +289,7 @@ export class PiApprovalBroker {
272
289
  auditRef: ref("approval-audit", binding.secret, [approvalRef, resolvedAt, surface, decision]), executor: "pi-guard", directExecution: false };
273
290
  pending.receipt = receipt; binding.recent.push(receipt); binding.recent = binding.recent.slice(-MAX_RECENT);
274
291
  this.#emit(binding, { kind: "resolved", approvalRef, receipt: clone(receipt) });
275
- const decisionResult: ApprovalGuardDecision = { allowed: decision === "allow", brokered: true, receipt: clone(receipt),
292
+ const decisionResult: ApprovalGuardDecision = { allowed: decision === "allow", brokered: true, receipt: clone(receipt), choice,
276
293
  consume: () => this.#consume(binding, receipt), cancel: (reason = "guard-cancelled") => this.#cancelPermit(binding, receipt, reason) };
277
294
  pending.resolve(decisionResult);
278
295
  if (decision === "deny") this.#finish(binding, pending, receipt);
@@ -323,7 +340,7 @@ export class PiApprovalBroker {
323
340
  deduplicated: false, auditRef: ref("approval-audit", binding.secret, [approvalRef, resolvedAt, surface]), executor: "pi-guard", directExecution: false };
324
341
  pending.receipt = receipt; binding.recent.push(receipt); binding.recent = binding.recent.slice(-MAX_RECENT);
325
342
  this.#emit(binding, { kind: state === "expired" ? "expired" : "cancelled", approvalRef, receipt: clone(receipt) });
326
- pending.resolve({ allowed: false, brokered: true, receipt: clone(receipt), consume: () => false, cancel: () => undefined }); this.#finish(binding, pending, receipt);
343
+ pending.resolve({ allowed: false, brokered: true, receipt: clone(receipt), choice: "deny", consume: () => false, cancel: () => undefined }); this.#finish(binding, pending, receipt);
327
344
  }
328
345
 
329
346
  #finish(binding: Binding, pending: Pending, receipt: Receipt): void {
@@ -0,0 +1,10 @@
1
+ export interface RepositoryMcpGate {
2
+ blocked: Map<string, { state: string; origin: string }>;
3
+ serverNames: Set<string>;
4
+ unverifiable: string[];
5
+ }
6
+
7
+ export const mcpApprovalCache: Map<string, { signature: string; gate: RepositoryMcpGate }>;
8
+ export function mcpServerFromToolCall(toolName: string, input: Record<string, unknown>, candidates: Iterable<string>): string | undefined;
9
+ export function repositoryMcpGate(cwd: string): RepositoryMcpGate;
10
+ export function evaluateMcpApproval(cwd: string, toolName: string, input: Record<string, unknown>): { block: boolean; reason?: string };
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import {
5
+ REPOSITORY_SCOPES,
6
+ collectServers,
7
+ configPathForScope,
8
+ mcpDecisionInputs,
9
+ unverifiableMcpConfig
10
+ } from "../mcp/mcp-config-layers.js";
11
+ import { approvalState } from "../mcp/mcp-approval-store.js";
12
+ import { attributeDirectTool } from "../mcp/mcp-tool-naming.js";
13
+
14
+ // Servers a repository defines are not usable until somebody on this machine has
15
+ // approved them. This is the enforcement point, shared by personal Piagent
16
+ // (piagent-guard) and company sessions (managed/member-mcp.mjs): the adapter
17
+ // owns the connection and neither can prevent one being opened, but no tool
18
+ // call reaches an unapproved server. The residual exposure is the connection
19
+ // itself, which is worth stating plainly rather than describing this as a full
20
+ // block.
21
+
22
+ // The same token form the guard's action policy uses: `MCP`, `mcp` and `Mcp`
23
+ // all name the adapter's proxy tool.
24
+ const actionToken = (value) => String(value)
25
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, "-")
28
+ .replace(/^-+|-+$/g, "");
29
+
30
+ // Which MCP server a tool call is addressed to. The adapter exposes one proxy
31
+ // tool named `mcp` that carries the server in `input.server`, and, under
32
+ // directTools, one tool per server tool named `<prefix>_<tool>` where the prefix
33
+ // is derived from the server name. Direct names are matched against the servers
34
+ // this gate is holding rather than against a fixed pattern, because the prefix
35
+ // is the server's own name and there is nothing constant to anchor on.
36
+ export function mcpServerFromToolCall(toolName, input, candidates) {
37
+ if (actionToken(toolName) === "mcp") {
38
+ return typeof input?.server === "string" && input.server.trim() ? input.server.trim() : undefined;
39
+ }
40
+ return attributeDirectTool(toolName, candidates);
41
+ }
42
+
43
+ // Recomputed only when one of the files behind the decision changes, because
44
+ // this runs on every tool call. Approving or rejecting a server clears the
45
+ // project's entry.
46
+ export const mcpApprovalCache = new Map();
47
+
48
+ function fileSignature(file) {
49
+ try {
50
+ const stat = fs.statSync(file);
51
+ return `${stat.mtimeMs}:${stat.size}`;
52
+ } catch {
53
+ return "absent";
54
+ }
55
+ }
56
+
57
+ // `blocked`: the servers a call must not reach, with why. `unverifiable`:
58
+ // conditions under which no tool call can be checked at all, each with the
59
+ // sentence to show. Those are not "a server is unapproved" but "this
60
+ // repository's config has put the gate in a state where it cannot tell which
61
+ // server a call belongs to", and the only sound answer to that is to stop.
62
+ export function repositoryMcpGate(cwd) {
63
+ // Repository scopes plus anything an `imports` key drags in from a file the
64
+ // clone carries. Collecting those here is the whole point: a server the gate
65
+ // never enumerates is a server it silently permits.
66
+ const servers = collectServers({ projectPath: cwd, scopes: [...REPOSITORY_SCOPES] });
67
+ const repositoryFiles = [...REPOSITORY_SCOPES].map((scope) => ({
68
+ scope,
69
+ file: configPathForScope(scope, { projectPath: cwd })
70
+ }));
71
+ // Every file the decision reads, listed by the module that reads them rather
72
+ // than restated here. Building this by hand is what went wrong: the scan grew
73
+ // to read merged settings from all four scopes and to stat import targets
74
+ // outside the repository, the hand-written list did not, and an already-loaded
75
+ // guard went on permitting calls after a personal global config had put the
76
+ // session in the state this gate exists to refuse.
77
+ const signature = [
78
+ ...servers.map((server) => `${server.origin}:${server.name}:${fileSignature(server.file)}`),
79
+ ...repositoryFiles.map((layer) => `layer:${layer.scope}:${fileSignature(layer.file)}`),
80
+ ...mcpDecisionInputs({ projectPath: cwd }).map((file) => `input:${file}:${fileSignature(file)}`),
81
+ `store:${fileSignature(path.join(os.homedir(), ".pi", "piagent-mcp-approvals.json"))}`
82
+ ].join("|");
83
+ const cached = mcpApprovalCache.get(cwd);
84
+ if (cached?.signature === signature) return cached.gate;
85
+
86
+ const blocked = new Map();
87
+ for (const server of servers) {
88
+ const state = approvalState({ projectPath: cwd, name: server.name, entry: server.entry });
89
+ if (state.state === "approved") continue;
90
+ // A name defined more than once keeps its blocking entry: the approved copy
91
+ // does not vouch for the one nobody has looked at.
92
+ blocked.set(server.name, { state: state.state, origin: server.origin });
93
+ }
94
+
95
+ // Two things a repository-carried config can do that leave nothing to check.
96
+ //
97
+ // It can ask for tools with no prefix, so a direct tool arrives under a bare
98
+ // name carrying no evidence of its server. Refusing only the proxy there was
99
+ // not a fix: the proxy is the one form that names its server, so blocking it
100
+ // and allowing the bare names left the hole exactly where it was.
101
+ //
102
+ // It can also import a kind whose config this platform cannot enumerate, and
103
+ // then the set of servers reaching the session is simply unknown. Blocking the
104
+ // servers that could be listed says nothing about the ones that could not.
105
+ //
106
+ // Both stop every tool call. A repository can make a session refuse to run;
107
+ // it must not be able to make one run a server nobody approved.
108
+ // Read from the shared module rather than decided here, so `piagent-mcp
109
+ // doctor` reports exactly what this refuses. The two disagreeing is how an
110
+ // operator ends up reading "PASS" while every tool call is being stopped.
111
+ const unverifiable = unverifiableMcpConfig({ projectPath: cwd }).map((problem) => problem.detail);
112
+
113
+ const gate = { blocked, serverNames: new Set(servers.map((server) => server.name)), unverifiable };
114
+ mcpApprovalCache.set(cwd, { signature, gate });
115
+ return gate;
116
+ }
117
+
118
+ // Whether a tool call may go ahead in `cwd`: `{ block: false }`, or the
119
+ // reason it may not, naming the commands that resolve it.
120
+ export function evaluateMcpApproval(cwd, toolName, input) {
121
+ const gate = repositoryMcpGate(cwd);
122
+
123
+ // Every tool call, not only the ones that look like MCP. Under either of these
124
+ // conditions a call cannot be traced to a server, so there is no such thing as
125
+ // a call this gate can clear.
126
+ if (gate.unverifiable.length > 0) {
127
+ return { block: true, reason: `Blocked every tool call: ${gate.unverifiable.join(" ")}` };
128
+ }
129
+
130
+ const server = mcpServerFromToolCall(toolName, input, gate.blocked.keys());
131
+ if (!server) return { block: false };
132
+ const record = gate.blocked.get(server);
133
+ if (!record) return { block: false };
134
+ const explanation = record.state === "rejected"
135
+ ? "it was rejected for this project"
136
+ : record.state === "changed"
137
+ ? "its definition changed since it was approved"
138
+ : record.origin.startsWith("import:")
139
+ ? `this repository imports it from ${record.origin.slice("import:".length)} config and nobody on this machine has approved it here`
140
+ : "this repository defines it and nobody on this machine has approved it";
141
+ return {
142
+ block: true,
143
+ // Named as commands, because this is read inside a session: the reader can
144
+ // run these where they already are, without shelling out.
145
+ reason:
146
+ `Blocked MCP server ${server}: ${explanation}. ` +
147
+ `Review it with \`/piagent-mcp get ${server}\`, then \`/piagent-mcp approve ${server}\` to allow it ` +
148
+ `or \`/piagent-mcp reject ${server}\` to refuse it.`
149
+ };
150
+ }