@piagent/platform 1.47.0 → 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 +23 -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 +4 -4
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +1 -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/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 +9 -9
- 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 +1 -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 +33 -18
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +9 -9
- 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 +1 -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 +51 -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 +33 -18
- 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 +104 -23
- 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 +54 -24
- 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 +34 -9
- package/packages/piagent-core/managed/permission.mjs +19 -12
- package/packages/piagent-core/managed/repository-operation.mjs +1 -1
- package/packages/piagent-core/managed/session.mjs +2 -2
- 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/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/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
|
@@ -1,49 +1,55 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { asksOnlyHandoff, broadAccess, effectivePermission, networkConfirmation } from './permission.mjs';
|
|
3
|
+
import { handoffReason } from '../runtime/policy/handoff-policy.mjs';
|
|
3
4
|
import { engineAlive, engineLauncher } from './container-engine.mjs';
|
|
4
5
|
import { MEMBER_ONLY, memberOnlyReason, credentialReason, runHostCommand } from './host-command.mjs';
|
|
5
6
|
import { processTools } from './background-process.mjs';
|
|
6
7
|
import { checkpointsOf } from './checkpoints.mjs';
|
|
7
8
|
import { detachedEmulator, simulatorCommandReason } from './simulator-commands.mjs';
|
|
8
9
|
|
|
9
|
-
// Commands that leave the plain sandbox
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
10
|
+
// Commands that leave the plain sandbox: run_with_network (internet, a local
|
|
11
|
+
// server or browser), run_with_docker when this machine has Docker (the same
|
|
12
|
+
// plus the engine's socket), run_on_host (on the member's machine, outside the
|
|
13
|
+
// sandbox) and run_on_simulator. What each mode asks (permission.mjs):
|
|
14
|
+
// - Ask first: every command.
|
|
15
|
+
// - Full access: run_with_network, run_with_docker and run_on_simulator run,
|
|
16
|
+
// except the handoff and destructive steps (mustConfirm); run_on_host asks
|
|
17
|
+
// every command.
|
|
18
|
+
// - Bypass: all four run, except the handoff (handoffReason: push, PR,
|
|
19
|
+
// publish, deploy, remote and cloud tools, uploads) and, on the host, a
|
|
20
|
+
// command that reaches credentials.
|
|
17
21
|
const PARAMETERS = { type: 'object', properties: { command: { type: 'string', minLength: 1, maxLength: 4000 }, reason: { type: 'string', minLength: 1, maxLength: 300 }, timeout: { type: 'number', minimum: 1, maximum: 1800 } }, required: ['command', 'reason'], additionalProperties: false };
|
|
18
22
|
|
|
19
23
|
const KINDS = {
|
|
20
24
|
network: {
|
|
21
25
|
name: 'run_with_network', label: 'Run with network', scope: 'network-command-once', provider: 'network', riskClass: 'medium',
|
|
22
|
-
description: 'Run one shell command that needs the internet beyond the package registries bash already reaches (a download site or registry the package proxy refuses), or that starts a local server or a browser (end-to-end tests with Playwright\'s Chromium against a server on 127.0.0.1), after the user approves that exact command. Normal bash has no internet; on macOS it reaches servers on this Mac and runs Playwright\'s Chromium against them, except while a local proxy listens. Credentials (.npmrc, SSH keys, Keychain) stay unavailable, so private registries and git push are not possible here.',
|
|
26
|
+
description: 'Run one shell command that needs the internet beyond the package registries bash already reaches (a download site or registry the package proxy refuses), or that starts a local server or a browser (end-to-end tests with Playwright\'s Chromium against a server on 127.0.0.1), after the user approves that exact command (Full access and Bypass run it without asking, but a push, publish, deploy or upload is always asked). Normal bash has no internet; on macOS it reaches servers on this Mac and runs Playwright\'s Chromium against them, except while a local proxy listens. Credentials (.npmrc, SSH keys, Keychain) stay unavailable, so private registries and git push are not possible here.',
|
|
23
27
|
allow: 'Run this exact command once, with internet access; a server it starts accepts connections while it runs.',
|
|
24
28
|
title: 'Chạy lệnh có internet', allowText: 'chạy đúng lệnh này một lần, có internet; server nó mở nhận kết nối trong lúc chạy.',
|
|
25
29
|
confirm: (self, command) => networkConfirmation(self, command),
|
|
26
30
|
},
|
|
27
31
|
docker: {
|
|
28
32
|
name: 'run_with_docker', label: 'Run with Docker', scope: 'docker-command-once', provider: 'docker', riskClass: 'high',
|
|
29
|
-
description: 'Run one shell command that needs the Docker engine of this machine: docker, docker compose (a database or service for tests), docker build, or tests that start containers themselves (Testcontainers), after the user approves that exact command. It has internet access too. Normal bash and run_with_network cannot reach Docker. Mount only folders inside the project. Registry credentials are unavailable: public images pull, private registries and docker push do not. Containers started in the background (-d, compose up -d) keep running after the command: stop them (docker compose down) when the work is done unless the member wants them kept.',
|
|
33
|
+
description: 'Run one shell command that needs the Docker engine of this machine: docker, docker compose (a database or service for tests), docker build, or tests that start containers themselves (Testcontainers), after the user approves that exact command (Full access and Bypass run it without asking, but docker push or login and any other handoff is always asked). It has internet access too. Normal bash and run_with_network cannot reach Docker. Mount only folders inside the project. Registry credentials are unavailable: public images pull, private registries and docker push do not. Containers started in the background (-d, compose up -d) keep running after the command: stop them (docker compose down) when the work is done unless the member wants them kept.',
|
|
30
34
|
allow: 'Run this exact command once, with internet access and the Docker engine; a container it starts can read folders Docker shares from this machine and may keep running.',
|
|
31
35
|
title: 'Chạy lệnh dùng Docker', allowText: 'chạy đúng lệnh này một lần, có internet và Docker; container nó tạo có thể đọc thư mục Docker được chia sẻ trên máy và có thể tiếp tục chạy.',
|
|
32
36
|
confirm: (self, command) => networkConfirmation(self, command),
|
|
33
37
|
},
|
|
34
38
|
host: {
|
|
35
39
|
name: 'run_on_host', label: 'Run on this machine', scope: 'host-command-once', provider: 'host', riskClass: 'high',
|
|
36
|
-
description: 'Run one shell command on the member\'s machine, outside the sandbox, as the member, after they approve that exact command. Use it when the sandbox blocks what the task needs and no other tool covers it: install a runtime or tool the project needs (brew install openjdk@21, brew install node@22, sdk install java…), git pull
|
|
40
|
+
description: 'Run one shell command on the member\'s machine, outside the sandbox, as the member, after they approve that exact command (only Bypass runs it without asking, and even there a push, PR, publish, deploy or a command that reaches credentials is asked). Use it when the sandbox blocks what the task needs and no other tool covers it: install a runtime or tool the project needs (brew install openjdk@21, brew install node@22, sdk install java…), git pull with the member\'s sign-in, git push only when the member asked for it, a private registry, a file outside the project. It has the member\'s files, Keychain and sign-ins, so ask only for what the task needs and say why in reason. No input can be typed: commands that need the member\'s password (sudo, a .pkg installer) or an interactive sign-in are refused; ask the member with ask_user for those.',
|
|
37
41
|
allow: 'Run this exact command once on this machine, outside the sandbox, as you: it can read and change your files and use your sign-ins.',
|
|
38
42
|
title: 'Chạy lệnh trên máy (ngoài sandbox)', allowText: 'chạy đúng lệnh này một lần trên máy bạn, ngoài sandbox, bằng quyền của bạn: lệnh đọc/sửa được file và dùng được tài khoản đã đăng nhập trên máy.',
|
|
39
43
|
// Full access does not cover it: the member approves each command (with
|
|
40
|
-
// what Full access would still have asked about). Bypass asks
|
|
41
|
-
// credentials: a prompt injection's "cat ~/.ssh/id_rsa"
|
|
42
|
-
|
|
44
|
+
// what Full access would still have asked about). Bypass asks about the
|
|
45
|
+
// handoff and about credentials: a prompt injection's "cat ~/.ssh/id_rsa"
|
|
46
|
+
// or "git push --force" is never silent.
|
|
47
|
+
confirm: async (self, command) => asksOnlyHandoff(self) ? credentialReason(command) ?? await handoffReason(command) : await networkConfirmation(self, command) ?? 'ask',
|
|
43
48
|
},
|
|
44
49
|
// The sandbox reaches neither CoreSimulator nor adb. Simulator and app build
|
|
45
50
|
// commands only (simulator-commands.mjs), asked about as network commands:
|
|
46
|
-
// Full access and Bypass run them, except what reaches credentials
|
|
51
|
+
// Full access and Bypass run them, except what reaches credentials and what
|
|
52
|
+
// either mode asks about any command.
|
|
47
53
|
simulator: {
|
|
48
54
|
name: 'run_on_simulator', label: 'Run on simulator', scope: 'simulator-command-once', provider: 'host', riskClass: 'medium',
|
|
49
55
|
description: 'Run one iOS Simulator or Android emulator command on the member\'s machine, outside the sandbox. iOS: xcrun simctl (list, boot, bootstatus -b, install, launch, terminate, openurl, io <device> screenshot <file>, ui appearance, status_bar, spawn <device> log show --info --last 5m --predicate \'process == "<App>"\'), xcodebuild with a simulator destination (build, test: XCUITest is the reliable way to tap, type and answer system dialogs), xcodegen generate, axe (describe-ui reads the screen\'s elements; tap, type, swipe where this Xcode accepts them), open -a Simulator (some Xcode versions run simulators without that window; the device works without it). Android: adb (devices, install, shell am start, shell input tap/text/swipe, exec-out screencap -p > <file>, logcat -d), emulator -avd <name> (started in the background), avdmanager, ./gradlew installDebug. Also flutter, pod install, npx expo/react-native/cap, mkdir -p and cd inside the project. Parts may be joined by && or || and piped into grep, head, tail, xcbeautify or jq; files may be written only inside the project or to /dev/null. Read a screenshot you saved with read to see the screen. Other commands: run_on_host.',
|
|
@@ -60,14 +66,34 @@ async function engineReady(boundary) {
|
|
|
60
66
|
}
|
|
61
67
|
const DOCKER_STOPPED = 'managed-docker-unreachable: Docker engine trên máy không chạy. 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.';
|
|
62
68
|
|
|
63
|
-
//
|
|
69
|
+
// Why a mode that runs commands without asking asks about this one, as the
|
|
70
|
+
// card says it: the mode named as it is.
|
|
71
|
+
export function stillAsks(self, reason) {
|
|
72
|
+
return effectivePermission(self) === 'bypass' ? { en: `Bypass still asks: ${reason}`, vi: `Bypass vẫn hỏi: ${reason}` }
|
|
73
|
+
: { en: `Full access still asks: ${reason}`, vi: `Full access vẫn hỏi: ${reason}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One approval of the member (the dashboard card, else the Terminal). The
|
|
77
|
+
// member may allow it once, allow the same exact action for the rest of the
|
|
78
|
+
// conversation (never a handoff: its scope names it), deny it, or deny it and
|
|
79
|
+
// stop the turn so the agent does not try again.
|
|
80
|
+
const CHOICES = { once: 'Đồng ý một lần', session: 'Đồng ý cho cả cuộc trò chuyện (đúng lệnh này)', deny: 'Từ chối', stop: 'Từ chối và dừng lượt này' };
|
|
64
81
|
export async function approve(self, id, signal, ctx, action, { title, body }) {
|
|
82
|
+
const grantable = !/handoff/.test(String(action.requestedScope ?? '')), key = `${action.toolName}\u0000${action.commandPreview ?? JSON.stringify(action.rawAction ?? {})}`;
|
|
83
|
+
if (grantable && self.sessionApprovals?.has(key)) return;
|
|
65
84
|
const { piApprovalBroker } = await import('../runtime/inspection/approval-broker.ts');
|
|
85
|
+
const choices = ['once', ...(grantable ? ['session'] : []), 'deny', 'stop'];
|
|
66
86
|
const decision = await piApprovalBroker.request({ cwd: self.cwd, rawSessionId: self.session.sessionManager.getSessionId(), toolCallId: id,
|
|
67
87
|
action: { kind: 'external-provider-action', preconditionClass: 'runtime-only', targetPaths: [self.cwd], urlOrigin: null, ...action },
|
|
68
88
|
terminalConfirm: () => ctx?.ui?.confirm?.(title, body) ?? Promise.resolve(false),
|
|
89
|
+
...(ctx?.mode === 'tui' && ctx?.hasUI && typeof ctx.ui?.select === 'function' ? { terminalChoose: async () => {
|
|
90
|
+
const picked = await ctx.ui.select(`${title}\n\n${body}`, choices.map(choice => CHOICES[choice]));
|
|
91
|
+
return choices.find(choice => CHOICES[choice] === picked) ?? 'deny';
|
|
92
|
+
} } : {}),
|
|
69
93
|
unavailableFallback: 'terminal-confirm', recheck: () => !signal?.aborted && Boolean(self.grant) });
|
|
94
|
+
if (!decision.allowed && decision.choice === 'stop') { try { ctx?.abort?.(); } catch { /* the denial below still stands */ } throw Error('managed-operation-denied-stop: bạn đã từ chối và dừng lượt này; không thử lại, chờ member.'); }
|
|
70
95
|
if (!decision.allowed || !decision.consume() || signal?.aborted) throw Error('managed-operation-denied');
|
|
96
|
+
if (decision.choice === 'session' && grantable) (self.sessionApprovals ??= new Set()).add(key);
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
function commandTool(self, kind) {
|
|
@@ -80,10 +106,13 @@ function commandTool(self, kind) {
|
|
|
80
106
|
const notSimulator = kind === 'simulator' ? simulatorCommandReason(args.command, self.cwd) : null;
|
|
81
107
|
if (notSimulator) throw Error(`managed-simulator-command: ${notSimulator}.`);
|
|
82
108
|
const confirm = await spec.confirm(self, args.command);
|
|
83
|
-
if (confirm !== null)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
if (confirm !== null) {
|
|
110
|
+
const why = confirm === 'ask' ? null : stillAsks(self, confirm), handoff = await handoffReason(args.command);
|
|
111
|
+
await approve(self, id, signal, ctx, { toolName: spec.name, rawAction: { command: args.command }, commandPreview: String(args.command),
|
|
112
|
+
provider: spec.provider, requestedScope: handoff ? 'handoff-once' : spec.scope, reason: (why ? `${why.en}. ${args.reason}` : String(args.reason)).slice(0, 300), riskClass: spec.riskClass,
|
|
113
|
+
allowConsequence: spec.allow, denyConsequence: 'The command does not run; the agent is told you declined.' },
|
|
114
|
+
{ title: spec.title, body: `${args.command}\n\nLý do: ${args.reason}${why ? `\n${why.vi}` : ''}\n\nĐồng ý: ${spec.allowText}\nTừ chối: lệnh không chạy, agent được báo bạn đã từ chối.` });
|
|
115
|
+
}
|
|
87
116
|
let ok = false;
|
|
88
117
|
if (self.run) await checkpointsOf(self).take();
|
|
89
118
|
const before = await self.changedFiles();
|
|
@@ -101,7 +130,8 @@ function commandTool(self, kind) {
|
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
// start_docker: the engine the member installed, started after they approve
|
|
104
|
-
// (Bypass
|
|
133
|
+
// (Full access and Bypass start it without asking: local and reversible),
|
|
134
|
+
// then waited for.
|
|
105
135
|
const textResult = text => ({ content: [{ type: 'text', text }], details: {} });
|
|
106
136
|
function startDockerTool(self, { wait = 180_000, every = 2000, launcher: found = engineLauncher, spawnImpl = spawn } = {}) {
|
|
107
137
|
return { name: 'start_docker', label: 'Start Docker',
|
|
@@ -134,10 +164,10 @@ export function commandTools(self, options) {
|
|
|
134
164
|
|
|
135
165
|
// What the system prompt says about services and what the sandbox blocks: it
|
|
136
166
|
// is read for every request, so a Docker started since shows up.
|
|
137
|
-
const HOST_PROMPT = ' When the sandbox blocks something the task needs (a missing runtime such as a JDK or a Node version, the member\'s sign-in for git pull or
|
|
167
|
+
const HOST_PROMPT = ' When the sandbox blocks something the task needs (a missing runtime such as a JDK or a Node version, the member\'s sign-in for git pull or a private registry, a file outside the project), do not stop and do not tell the member to run it: request run_on_host with the exact command; it runs on the member\'s machine once they approve. The work ends at the handoff: commit locally, then report what is ready; push, open or merge a pull request, publish or deploy only when the member asked for it in this conversation, and each of those steps is asked about in every access mode. Only what needs the member themselves (their password for sudo or an installer, an interactive sign-in, a window to click) goes to ask_user, with the exact steps and the options to continue or skip; after their answer, check and carry on.';
|
|
138
168
|
export function servicesPrompt(boundary) {
|
|
139
169
|
const simulator = ' The iOS Simulator and Android emulator are not reachable from bash: boot them, build and install the app, launch it, tap and type, and take screenshots with run_on_simulator (save the screenshot in the project, then read it to see the screen). Do not ask the member to run simulator steps you can run.';
|
|
140
|
-
if (boundary.engine) return ' Docker is available through run_with_docker (the member approves each command): a database or service for tests (docker compose up -d, then the tests through bash or run_with_network), docker build, or Testcontainers tests. If the engine stops answering, start_docker asks the member to start it. Stop what you started when done unless the member wants it kept.' + simulator + HOST_PROMPT;
|
|
170
|
+
if (boundary.engine) return ' Docker is available through run_with_docker (in Ask first the member approves each command): a database or service for tests (docker compose up -d, then the tests through bash or run_with_network), docker build, or Testcontainers tests. If the engine stops answering, start_docker asks the member to start it. Stop what you started when done unless the member wants it kept.' + simulator + HOST_PROMPT;
|
|
141
171
|
if (boundary.dockerCli) return ' Docker is installed but not running: when the task needs it, call start_docker (the member approves), then run_with_docker.' + simulator + HOST_PROMPT;
|
|
142
172
|
return ' Docker is not installed on this machine: when the task needs it, ask the member with ask_user (install it, or check another way).' + simulator + HOST_PROMPT;
|
|
143
173
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { asksOnlyHandoff } from './permission.mjs';
|
|
2
2
|
import { approve } from './command-tools.mjs';
|
|
3
3
|
|
|
4
4
|
// web_fetch is a request to any public https address: a page that tells the
|
|
@@ -6,7 +6,7 @@ import { approve } from './command-tools.mjs';
|
|
|
6
6
|
// Documentation and package registries are read freely. Another host is asked
|
|
7
7
|
// about once per conversation; an address there that carries a query or a
|
|
8
8
|
// fragment (where data rides along) is asked about every time. Bypass asks
|
|
9
|
-
// only about the latter. A helper cannot ask the member: it is told to hand
|
|
9
|
+
// only about the latter: data sent out is a handoff. A helper cannot ask the member: it is told to hand
|
|
10
10
|
// the address to the main agent.
|
|
11
11
|
const TRUSTED = ['github.com', 'raw.githubusercontent.com', 'docs.github.com', 'npmjs.com', 'www.npmjs.com', 'registry.npmjs.org', 'pypi.org', 'docs.python.org',
|
|
12
12
|
'developer.mozilla.org', 'nodejs.org', 'go.dev', 'pkg.go.dev', 'crates.io', 'docs.rs', 'doc.rust-lang.org', 'learn.microsoft.com', 'developer.apple.com',
|
|
@@ -21,7 +21,7 @@ export function fetchDecision(owner, role, raw) {
|
|
|
21
21
|
if (trustedHost(host)) return null;
|
|
22
22
|
const carries = Boolean(url.search || url.hash), known = owner.fetchHosts?.has(host);
|
|
23
23
|
if (role !== 'main') return carries || !known ? 'refuse' : null;
|
|
24
|
-
if (
|
|
24
|
+
if (asksOnlyHandoff(owner)) return carries ? 'ask' : null;
|
|
25
25
|
return carries || !known ? 'ask' : null;
|
|
26
26
|
}
|
|
27
27
|
export async function guardFetch(owner, role, raw, id, signal, ctx) {
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { redactedText } from '../runtime/managed-failure.mjs';
|
|
5
|
+
import { commandSegments, stripWrapper } from '../runtime/policy/handoff-policy.mjs';
|
|
5
6
|
|
|
6
7
|
// Commands the member approved to run on their machine, outside the sandbox
|
|
7
8
|
// (run_on_host, start_docker): installing a runtime the project needs, git
|
|
@@ -13,12 +14,14 @@ import { redactedText } from '../runtime/managed-failure.mjs';
|
|
|
13
14
|
const PASSWORD = new Set(['sudo', 'doas', 'su', 'pkexec', 'passwd']);
|
|
14
15
|
const SIGN_IN = [/^gh auth (login|refresh)\b/, /^(npm|pnpm) (login|adduser)\b/, /^yarn npm login\b/, /^docker login\b(?!.*--password-stdin)/, /^az login\b/,
|
|
15
16
|
/^gcloud auth (login|application-default login)\b/, /^aws (configure\s*$|sso login\b)/, /^(firebase|vercel|wrangler|heroku|netlify) login\b/];
|
|
16
|
-
|
|
17
|
+
// Read through the shared shell parser (policy-core.js): `(sudo ls)`,
|
|
18
|
+
// `echo x & sudo ls` and `if …; then sudo …` are each a sudo.
|
|
17
19
|
export function memberOnlyReason(command) {
|
|
18
|
-
for (const
|
|
19
|
-
const words =
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
for (const { words } of commandSegments(command)) {
|
|
21
|
+
const run = stripWrapper(words), prefix = words.slice(0, words.length - run.length).map(word => word.split('/').pop());
|
|
22
|
+
const elevated = prefix.find(word => PASSWORD.has(word));
|
|
23
|
+
if (elevated) return `${elevated} needs the member's password`;
|
|
24
|
+
const name = run[0], rest = run.join(' ');
|
|
22
25
|
if (PASSWORD.has(name)) return `${name} needs the member's password`;
|
|
23
26
|
if (name === 'installer' || name === 'softwareupdate') return `${name} needs the member's password`;
|
|
24
27
|
if (SIGN_IN.some(rule => rule.test(rest))) return 'an interactive sign-in needs the member';
|
|
@@ -26,8 +29,13 @@ export function memberOnlyReason(command) {
|
|
|
26
29
|
return null;
|
|
27
30
|
}
|
|
28
31
|
// Where credentials live on the member's machine. A command that names one
|
|
29
|
-
// reads or moves secrets, so it is asked about even in Bypass.
|
|
30
|
-
|
|
32
|
+
// reads or moves secrets, so it is asked about even in Bypass. A text check,
|
|
33
|
+
// not a boundary: it catches the usual spellings (~/.ssh, $HOME/.ssh,
|
|
34
|
+
// /Users/me/.ssh, `tar -C ~ .ssh`); the sandbox is what keeps secrets out of
|
|
35
|
+
// plain bash. Folders whose names projects also use (.docker, .pi) count only
|
|
36
|
+
// under a home directory.
|
|
37
|
+
const HOME = String.raw`(?:~|\$HOME|\$\{HOME\}|/Users/[^/\s'"]+|/home/[^/\s'"]+|/root)/`;
|
|
38
|
+
const CREDENTIALS = [/(^|[\s'"=:/])\.(ssh|aws|gnupg|kube|azure|npmrc|netrc|pypirc|git-credentials)(\b|\/|$)/, new RegExp(String.raw`${HOME}\.(docker|pi|codex|claude|piagent|config/gh|config/gcloud)\b`),
|
|
31
39
|
/(^|[\s'"/])\.env(\.[\w-]+)?\b/, /\bauth\.json\b/, /\bcredentials(\.json)?\b/, /\bid_(rsa|ed25519|ecdsa|dsa)\b/, /\.(pem|p12|pfx|key)\b/,
|
|
32
40
|
/\bsecurity\s+(find-(generic|internet)-password|dump-keychain|export)\b/, /Library\/Keychains\b/, /\bprintenv\b|(^|[;&|]\s*)env\s*($|[|;&>])/];
|
|
33
41
|
export function credentialReason(command) {
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { evaluateMcpApproval } from '../runtime/mcp-repository-gate.mjs';
|
|
5
|
+
import { mcpWriteAction } from '../runtime/policy/handoff-policy.mjs';
|
|
5
6
|
import { userToolchains } from './toolchain.mjs';
|
|
6
7
|
import { fenced } from './untrusted.mjs';
|
|
7
8
|
|
|
@@ -11,9 +12,12 @@ import { fenced } from './untrusted.mjs';
|
|
|
11
12
|
// configuration as personal Piagent (piagent-mcp add, ~/.config/mcp/mcp.json,
|
|
12
13
|
// a project's .mcp.json, /mcp) and the same logins (/mcp-auth), so a server the
|
|
13
14
|
// member added once works in both. The member decides which servers exist;
|
|
14
|
-
// company sessions keep the
|
|
15
|
-
// repository defines runs only once someone on this machine approved it,
|
|
16
|
-
//
|
|
15
|
+
// company sessions keep the protections personal Piagent has: a server a
|
|
16
|
+
// repository defines runs only once someone on this machine approved it, a
|
|
17
|
+
// tool that writes to the server's system (create, update, push, merge,
|
|
18
|
+
// publish, send… in its name) is asked about in every access mode, Bypass
|
|
19
|
+
// included, since it hands work to the outside, and what a server returns
|
|
20
|
+
// reaches the agent as data, not instructions.
|
|
17
21
|
|
|
18
22
|
const ADAPTER = 'pi-mcp-adapter';
|
|
19
23
|
const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
@@ -43,14 +47,35 @@ export function memberMcpEnvironment(adapter, home = os.homedir(), env = process
|
|
|
43
47
|
return env;
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
// The MCP tool a call runs: the proxy's `tool` (`mcp` with tool and server),
|
|
51
|
+
// or a direct tool the adapter registered. null for anything else.
|
|
52
|
+
export function mcpToolCalled(toolName, input, fromAdapter) {
|
|
53
|
+
if (toolName === 'mcp') return typeof input?.tool === 'string' && input.tool.trim() ? { tool: input.tool.trim(), server: String(input.server ?? '').trim() } : null;
|
|
54
|
+
return fromAdapter(toolName) ? { tool: toolName, server: '' } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
// The repository approval gate on every tool call (as personal Piagent's
|
|
47
|
-
// guard), and the adapter's results
|
|
48
|
-
|
|
58
|
+
// guard), the member's approval of an MCP write, and the adapter's results
|
|
59
|
+
// fenced as data. Without an owner (no conversation to ask in) a write is refused.
|
|
60
|
+
export function memberMcpGuard(cwd, adapterDir, owner = null) {
|
|
49
61
|
return pi => {
|
|
50
62
|
const fromAdapter = name => { const source = pi.getAllTools().find(tool => tool.name === name)?.sourceInfo?.path; return !!source && (source === adapterDir || inside(source, adapterDir)); };
|
|
51
|
-
pi.on('tool_call', event => {
|
|
63
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
52
64
|
const decision = evaluateMcpApproval(cwd, event.toolName, event.input ?? {});
|
|
53
|
-
|
|
65
|
+
if (decision.block) return { block: true, reason: decision.reason };
|
|
66
|
+
const called = mcpToolCalled(event.toolName, event.input, fromAdapter);
|
|
67
|
+
const action = called && await mcpWriteAction(called.tool);
|
|
68
|
+
if (!action) return undefined;
|
|
69
|
+
if (!owner) return { block: true, reason: `MCP tool ${called.tool} writes outside (${action}) and no member can approve it here.` };
|
|
70
|
+
const { approve } = await import('./command-tools.mjs');
|
|
71
|
+
const name = called.server ? `${called.server} → ${called.tool}` : called.tool, args = JSON.stringify(event.input?.args ?? event.input ?? {}).slice(0, 600);
|
|
72
|
+
try {
|
|
73
|
+
await approve(owner, event.toolCallId, ctx?.signal, ctx, { toolName: event.toolName, rawAction: { tool: called.tool, server: called.server, input: event.input ?? {} }, commandPreview: name,
|
|
74
|
+
provider: called.server || 'mcp', requestedScope: 'mcp-handoff-once', reason: `The MCP tool ${name} writes outside this machine (${action}); every access mode asks before a handoff.`, riskClass: 'high',
|
|
75
|
+
allowConsequence: 'Run this exact MCP call once; it changes the system behind that server.', denyConsequence: 'The call does not run; the agent is told you declined.' },
|
|
76
|
+
{ title: 'Công cụ MCP ghi ra ngoài', body: `${name}\n${args}\n\nCông cụ này ${action} trên hệ thống của server MCP. Mọi chế độ truy cập, kể cả Bypass, đều hỏi trước việc bàn giao ra ngoài.\n\nĐồng ý: chạy đúng lệnh gọi này một lần.\nTừ chối: không chạy, agent được báo bạn đã từ chối.` });
|
|
77
|
+
} catch { return { block: true, reason: `User denied MCP tool ${name}.` }; }
|
|
78
|
+
return undefined;
|
|
54
79
|
});
|
|
55
80
|
pi.on('tool_result', event => {
|
|
56
81
|
if (!fromAdapter(event.toolName)) return undefined;
|
|
@@ -63,9 +88,9 @@ export function memberMcpGuard(cwd, adapterDir) {
|
|
|
63
88
|
|
|
64
89
|
// The company session's own extension factories, with the member's MCP added
|
|
65
90
|
// when the adapter is installed: [factories, extension paths] for inlineExtensions.
|
|
66
|
-
export function withMemberMcp(cwd, factories, home = os.homedir()) {
|
|
91
|
+
export function withMemberMcp(cwd, factories, home = os.homedir(), owner = null) {
|
|
67
92
|
const adapter = memberMcpAdapter(home);
|
|
68
93
|
if (!adapter) return [factories, []];
|
|
69
94
|
memberMcpEnvironment(adapter, home);
|
|
70
|
-
return [[...factories, memberMcpGuard(cwd, adapter.dir)], [adapter.dir]];
|
|
95
|
+
return [[...factories, memberMcpGuard(cwd, adapter.dir, owner)], [adapter.dir]];
|
|
71
96
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// A company conversation's access, chosen by the member and kept with the
|
|
2
|
-
// conversation
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// conversation (runtime/policy/handoff-policy.mjs has both lists):
|
|
3
|
+
// "workspace-write" (Ask first) asks before every command that leaves the
|
|
4
|
+
// sandbox; "trusted-full-access" (Full access) asks only about the handoff and
|
|
5
|
+
// what is destructive (mustConfirm); "bypass" runs the work and asks only about
|
|
6
|
+
// the handoff (handoffReason: push, PR, publish, deploy, remote and cloud
|
|
7
|
+
// tools, uploads), where the team's Harness allows it
|
|
6
8
|
// (harness.configuration.bypass_mode, an administrator's switch in Studio).
|
|
7
|
-
// The sandbox itself stays: bypass removes
|
|
8
|
-
import { mustConfirm } from '../runtime/policy/
|
|
9
|
+
// The sandbox itself stays: bypass removes questions, not the boundary.
|
|
10
|
+
import { handoffReason, mustConfirm } from '../runtime/policy/handoff-policy.mjs';
|
|
9
11
|
|
|
10
12
|
export const PERMISSIONS = ['workspace-write', 'trusted-full-access', 'bypass'];
|
|
11
13
|
const ENTRY = 'agent-watch-permission';
|
|
@@ -36,10 +38,15 @@ export function recordBypass(owner, manager) {
|
|
|
36
38
|
const last = manager.getEntries().filter(e => e.type === 'custom' && e.customType === ALLOWANCE).at(-1)?.data;
|
|
37
39
|
if ((last?.allowed === true) !== allowed) manager.appendCustomEntry(ALLOWANCE, { allowed, at: new Date().toISOString() });
|
|
38
40
|
}
|
|
39
|
-
// Whether this conversation
|
|
40
|
-
|
|
41
|
+
// Whether this conversation skips the questions Full access still asks
|
|
42
|
+
// (everything but the handoff).
|
|
43
|
+
export const asksOnlyHandoff = owner => effectivePermission(owner) === 'bypass';
|
|
41
44
|
export const broadAccess = owner => ['trusted-full-access', 'bypass'].includes(effectivePermission(owner));
|
|
42
|
-
// Why a
|
|
43
|
-
//
|
|
44
|
-
export const networkConfirmation = async (owner, command) =>
|
|
45
|
-
|
|
45
|
+
// Why a command that leaves the sandbox must be asked about: the reason, or
|
|
46
|
+
// 'ask' in Ask first, or null to run it. Every mode asks about the handoff.
|
|
47
|
+
export const networkConfirmation = async (owner, command) => {
|
|
48
|
+
const mode = effectivePermission(owner);
|
|
49
|
+
if (mode === 'bypass') return handoffReason(command);
|
|
50
|
+
if (mode === 'trusted-full-access') return mustConfirm(command);
|
|
51
|
+
return 'ask';
|
|
52
|
+
};
|
|
@@ -107,7 +107,7 @@ export async function executeRepositoryFetch(boundary,plan,signal,{transport=fet
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
// The fetch_origin tool of a company conversation: one approved fetch of the
|
|
110
|
-
// GitHub origin (Bypass
|
|
110
|
+
// GitHub origin (Full access and Bypass fetch without asking: it only downloads).
|
|
111
111
|
export function fetchOriginTool(self) {
|
|
112
112
|
return {name:'fetch_origin',label:'Fetch origin',description:'Request one approved GitHub origin fetch. Refreshes origin branches without changing working files. Does not expose credentials or allow arbitrary network commands. When the conversation folder holds several repositories, pass path: the repository folder inside it. Use this instead of git fetch, which has no GitHub sign-in here.',
|
|
113
113
|
parameters:{type:'object',properties:{path:{type:'string',maxLength:500,description:'Repository folder inside the conversation folder, such as "api"; omit when the conversation folder is the repository.'}},additionalProperties:false},
|
|
@@ -138,7 +138,7 @@ export class ManagedSession {
|
|
|
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
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)])) });
|
|
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
|
|
@@ -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 {
|