@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.
- package/CHANGELOG.md +37 -0
- package/README.md +16 -3
- package/README.vi.md +16 -3
- package/docs/command-reference-vietnamese.md +4 -4
- package/docs/company-dev-commands.md +6 -5
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/mcp-and-tools.md +21 -1
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/security-threat-model.md +3 -1
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/mcp.html +8 -0
- package/docs-site/content/mcp.html +8 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +15 -15
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +9 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/update.html +3 -3
- package/docs-site/en/whats-new.html +59 -32
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +15 -15
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +9 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/releases.json +87 -0
- 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/update.html +3 -3
- package/docs-site/whats-new.html +59 -32
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +2 -1
- package/packages/piagent-core/extensions/guard-types.ts +10 -2
- package/packages/piagent-core/extensions/piagent-guard.ts +106 -169
- package/packages/piagent-core/extensions/policy-core.js +7 -7
- package/packages/piagent-core/extensions/shell-ast.js +85 -0
- package/packages/piagent-core/extensions/shell-command-words.js +19 -0
- package/packages/piagent-core/managed/command-tools.mjs +70 -26
- package/packages/piagent-core/managed/fetch-guard.mjs +3 -3
- package/packages/piagent-core/managed/host-command.mjs +15 -7
- package/packages/piagent-core/managed/member-mcp.mjs +96 -0
- package/packages/piagent-core/managed/memory-lessons.mjs +1 -1
- package/packages/piagent-core/managed/permission.mjs +19 -12
- package/packages/piagent-core/managed/repository-operation.mjs +1 -1
- package/packages/piagent-core/managed/resource-loader.mjs +19 -7
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +4 -0
- package/packages/piagent-core/managed/session.mjs +4 -4
- package/packages/piagent-core/managed/simulator-commands.mjs +117 -0
- package/packages/piagent-core/mcp/mcp-server-catalog.js +18 -1
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +1 -1
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +33 -16
- package/packages/piagent-core/runtime/mcp-repository-gate.d.mts +10 -0
- package/packages/piagent-core/runtime/mcp-repository-gate.mjs +150 -0
- package/packages/piagent-core/runtime/policy/handoff-policy.mjs +144 -0
- package/packages/piagent-core/runtime/registration/permission-commands.ts +2 -2
- package/packages/piagent-webui/dist/client/assets/{App-CiwY9VpU.js → App-fZeIRoaD.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{GatewayApp-DrNJW3v_.js → GatewayApp-CpC4lgYK.js} +17 -15
- package/packages/piagent-webui/dist/client/assets/{InspectionApp-BBiwSaV0.js → InspectionApp-pD-BqBXm.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DwyPebIX.js → SessionAgentWorkspace-3tM7Q6HH.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CpVzaf_G.js → SessionInspectorDrawer-BJ94aQ6y.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BFfqMcuL.js +3 -0
- package/packages/piagent-webui/dist/client/assets/SettingsPage-CxpZ7cdM.js +1 -0
- package/packages/piagent-webui/dist/client/assets/{TerminalPanel-B8j_r6-y.js → TerminalPanel-CtZyb0tj.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-BDTPvq-0.js → WorkspaceCodeEditor-BS_ZrjPN.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DVsNU0eu.js → WorkspaceExplorer-CkFBpcM7.js} +3 -3
- package/packages/piagent-webui/dist/client/assets/{index-uhJMXxqD.js → index-CNdhIouM.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/{index-Dqb2hNan.css → index-CjM70M7D.css} +1 -1
- package/packages/piagent-webui/dist/client/assets/transcript-view-model-CpEMY1xb.js +3 -0
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/gateway/gateway-events.ts +5 -1
- package/packages/piagent-webui/server/gateway-websocket.ts +5 -1
- package/schemas/piagent-webui/approval-v1.schema.json +3 -1
- package/schemas/project-profile.schema.json +16 -0
- package/scripts/company-terminal.mjs +5 -5
- package/scripts/setup.sh +1 -1
- package/scripts/verify-release-identity.mjs +1 -1
- package/packages/piagent-core/runtime/policy/bypass-policy.mjs +0 -42
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +0 -3
- package/packages/piagent-webui/dist/client/assets/SettingsPage-Ca-qRSZa.js +0 -1
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
170
|
-
(
|
|
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(
|
|
200
|
-
return Promise.resolve().then(
|
|
201
|
-
|
|
202
|
-
consume: () =>
|
|
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
|
|
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
|
+
}
|