@piagent/platform 1.47.0 → 1.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +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 +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 +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/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 +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 +58 -31
- 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 +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 +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 +58 -31
- 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 +110 -24
- 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/inspection/runtime-event-store.ts +5 -1
- 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-core/runtime/solver/runtime-features.ts +52 -10
- 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/gateway/session-inspection-registry.ts +9 -4
- 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
|
@@ -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 {
|
|
@@ -252,7 +252,11 @@ export class RuntimeEventStore {
|
|
|
252
252
|
const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW ?? 0)
|
|
253
253
|
| (creating ? fs.constants.O_CREAT | fs.constants.O_EXCL : 0);
|
|
254
254
|
const descriptor = fs.openSync(target, flags, 0o600);
|
|
255
|
-
|
|
255
|
+
// No fsync: it ran for every event, Terminal sessions included, and three of
|
|
256
|
+
// them sat between Enter and the message showing. A store belongs to one
|
|
257
|
+
// runtime instance, and one left damaged by a power cut only sends the
|
|
258
|
+
// dashboard back to a snapshot (resyncRequired).
|
|
259
|
+
try { fs.writeFileSync(descriptor, `${JSON.stringify(event)}\n`); fs.fchmodSync(descriptor, 0o600); } finally { fs.closeSync(descriptor); }
|
|
256
260
|
segment.count += 1; this.events.push(event); this.eventById.set(event.eventId, event);
|
|
257
261
|
if (candidate) this.emissionGuard.recordAccepted(candidate);
|
|
258
262
|
try { this.enforceRetention(); } catch { this.corruptions.push("event-retention-failed"); }
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// What every permission mode still asks about, Bypass included (owner,
|
|
2
|
+
// 2026-10-29: "Bypass bypasses the work and stops at the handoff").
|
|
3
|
+
//
|
|
4
|
+
// handoffReason: the work leaves this machine. A push, a pull request or a
|
|
5
|
+
// merge, a release, a package publish, a deploy, a registry push or login, a
|
|
6
|
+
// remote shell, a cloud or cluster tool, an upload. Asked in every mode, in
|
|
7
|
+
// personal and company sessions alike.
|
|
8
|
+
// mustConfirm: the handoff plus what is destructive or irreversible here:
|
|
9
|
+
// database migrations, deletes whose target is only known at run time,
|
|
10
|
+
// removing Docker volumes, sudo. Asked in Full access; Bypass runs these.
|
|
11
|
+
//
|
|
12
|
+
// Both read the command twice: through the shared scanner (policy-core.js)
|
|
13
|
+
// and through a shell syntax tree (extensions/shell-ast.js), which also finds
|
|
14
|
+
// commands in functions, case arms, heredoc substitutions and `bash -c` text.
|
|
15
|
+
// `git -C api push`, `env X=1 git push` and `cd api && git push` are one push.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { evaluateExecPolicyCore, stripWrapper } from '../../extensions/policy-core.js';
|
|
19
|
+
import { gitSubcommandWords } from '../../extensions/shell-command-words.js';
|
|
20
|
+
import { astCommands } from '../../extensions/shell-ast.js';
|
|
21
|
+
|
|
22
|
+
export { stripWrapper };
|
|
23
|
+
|
|
24
|
+
const POLICY = JSON.parse(fs.readFileSync(fileURLToPath(new URL('../../policies/base-policy.json', import.meta.url)), 'utf8'));
|
|
25
|
+
// Local, reversible exec-policy prompts that Full access runs without asking
|
|
26
|
+
// (personal and company sessions).
|
|
27
|
+
export const FULL_ACCESS_RUNS = new Set(['prompt-git-add-broad']);
|
|
28
|
+
// requireConfirmationPatterns that are not a handoff: they stay with mustConfirm.
|
|
29
|
+
const LOCAL_PATTERNS = new Set(['migration']);
|
|
30
|
+
// Tools that reach other machines or accounts.
|
|
31
|
+
const REMOTE = new Set(['ssh', 'scp', 'sftp', 'vercel', 'netlify', 'fly', 'flyctl', 'heroku', 'firebase', 'aws', 'gcloud', 'gsutil', 'az',
|
|
32
|
+
'kubectl', 'helm', 'terraform', 'tofu', 'pulumi', 'eas', 'wrangler', 'supabase', 'railway', 'render', 'doctl']);
|
|
33
|
+
// Package runners: the package they run is the command (`npx vercel deploy`).
|
|
34
|
+
const RUNNERS = new Set(['npx', 'bunx', 'pnpx']);
|
|
35
|
+
// Commands that run the command after them, with the options that take a value.
|
|
36
|
+
const LAUNCHERS = new Map([['timeout', new Set(['-s', '--signal', '-k', '--kill-after'])], ['xargs', new Set(['-I', '-n', '-P', '-L', '-d', '-s', '-E', '-a'])],
|
|
37
|
+
['watch', new Set(['-n', '--interval', '-d'])], ['caffeinate', new Set(['-t', '-w'])]]);
|
|
38
|
+
const PUBLISHERS = [['cargo', 'publish'], ['twine', 'upload'], ['gem', 'push'], ['pod', 'trunk', 'push'], ['dotnet', 'nuget', 'push'], ['mvn', 'deploy'],
|
|
39
|
+
['gradle', 'publish'], ['gradlew', 'publish'], ['poetry', 'publish'], ['uv', 'publish'], ['flutter', 'pub', 'publish'], ['dart', 'pub', 'publish'], ['vsce', 'publish'], ['ovsx', 'publish']];
|
|
40
|
+
|
|
41
|
+
const parse = command => evaluateExecPolicyCore(String(command), { policy: POLICY, mode: 'enforce' });
|
|
42
|
+
const quote = word => /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${String(word).replace(/'/g, `'"'"'`)}'`;
|
|
43
|
+
// What the syntax tree finds that the scanner may not: each command it reads,
|
|
44
|
+
// checked by the same exec policy as a line of its own.
|
|
45
|
+
function treeFindings(command) {
|
|
46
|
+
const commands = astCommands(command) ?? [];
|
|
47
|
+
return commands.map(({ words }) => ({ words, result: words.length ? parse(words.map(quote).join(' ')) : null }));
|
|
48
|
+
}
|
|
49
|
+
// The simple commands a shell line runs, as both readers see them (inline
|
|
50
|
+
// `bash -c`, substitutions and compound statements included).
|
|
51
|
+
export const commandSegments = command => [...parse(command).segments, ...(astCommands(command) ?? []).map(({ words }) => ({ command: words.join(' '), words }))];
|
|
52
|
+
|
|
53
|
+
function executable(words) {
|
|
54
|
+
let current = stripWrapper(words);
|
|
55
|
+
for (;;) {
|
|
56
|
+
if (RUNNERS.has(current[0]) || (['pnpm', 'yarn'].includes(current[0]) && current[1] === 'dlx')) {
|
|
57
|
+
current = current.slice(current[0] === 'pnpm' || current[0] === 'yarn' ? 2 : 1);
|
|
58
|
+
while (current[0]?.startsWith('-')) current = current.slice(1);
|
|
59
|
+
} else if (LAUNCHERS.has(current[0])) {
|
|
60
|
+
const valued = LAUNCHERS.get(current[0]), duration = current[0] === 'timeout';
|
|
61
|
+
current = current.slice(1);
|
|
62
|
+
while (current[0]?.startsWith('-')) current = current.slice(valued.has(current[0]) ? 2 : 1);
|
|
63
|
+
if (duration) current = current.slice(1);
|
|
64
|
+
} else break;
|
|
65
|
+
current = stripWrapper(current);
|
|
66
|
+
}
|
|
67
|
+
return gitSubcommandWords(current);
|
|
68
|
+
}
|
|
69
|
+
const positional = args => args.filter(arg => !arg.startsWith('-'));
|
|
70
|
+
|
|
71
|
+
function segmentHandoff(words) {
|
|
72
|
+
const [name, ...args] = executable(words);
|
|
73
|
+
const sub = positional(args);
|
|
74
|
+
if (!name) return null;
|
|
75
|
+
if (REMOTE.has(name)) return `${name} reaches another machine or account`;
|
|
76
|
+
if (name === 'git') {
|
|
77
|
+
if (words.some((word, index) => (word === '-c' && /^alias\./i.test(words[index + 1] ?? '')) || /^-calias\./i.test(word))) return 'git runs an alias defined in the command';
|
|
78
|
+
if (['push', 'send-email', 'request-pull'].includes(sub[0])) return `git ${sub[0]} sends to a remote`;
|
|
79
|
+
if (['subtree', 'lfs'].includes(sub[0]) && sub[1] === 'push') return `git ${sub[0]} push sends to a remote`;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (name === 'rsync' && args.some(arg => /^[^/\s]*:/.test(arg) && !arg.startsWith('-'))) return 'rsync to another machine';
|
|
83
|
+
if (['docker', 'podman'].includes(name)) {
|
|
84
|
+
if (['push', 'login'].includes(sub[0])) return `${name} ${sub[0]} sends to a registry`;
|
|
85
|
+
if (args.includes('--push')) return `${name} build --push sends to a registry`;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (['npm', 'pnpm', 'yarn', 'bun'].includes(name) && (sub[0] === 'publish' || (sub[0] === 'npm' && sub[1] === 'publish'))) return `${name} publish uploads a package`;
|
|
89
|
+
const publisher = PUBLISHERS.find(entry => entry[0] === name.replace(/^\.\//, '') && entry.slice(1).every((word, index) => sub[index] === word));
|
|
90
|
+
if (publisher) return `${publisher.join(' ')} uploads a package`;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Why this command hands the work off (asked in every mode), or null.
|
|
95
|
+
export async function handoffReason(command) {
|
|
96
|
+
const result = parse(command);
|
|
97
|
+
const pattern = result.reasons.find(reason => reason.startsWith('Confirmation required: the command runs ')
|
|
98
|
+
&& !LOCAL_PATTERNS.has(reason.slice('Confirmation required: the command runs '.length)));
|
|
99
|
+
if (pattern) return pattern;
|
|
100
|
+
const tree = treeFindings(command);
|
|
101
|
+
for (const segment of [...result.segments, ...tree]) {
|
|
102
|
+
const reason = segmentHandoff(segment.words);
|
|
103
|
+
if (reason) return reason;
|
|
104
|
+
}
|
|
105
|
+
for (const { result: found } of tree) {
|
|
106
|
+
const reason = found?.reasons.find(item => item.startsWith('Confirmation required: the command runs ') && !LOCAL_PATTERNS.has(item.slice('Confirmation required: the command runs '.length)));
|
|
107
|
+
if (reason) return reason;
|
|
108
|
+
}
|
|
109
|
+
const { findShellExternalConfirmationReason } = await import('../../extensions/guard-shell-analysis.ts');
|
|
110
|
+
const segments = [...result.segments, ...tree.flatMap(({ result: found }) => found?.segments ?? [])];
|
|
111
|
+
return findShellExternalConfirmationReason(segments, { defaultMode: 'enforce', ...POLICY.externalActionPolicy }) ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// What the syntax tree finds that the scanner let through: a refused or asked
|
|
115
|
+
// command hidden in a function, a case arm or a heredoc substitution. The
|
|
116
|
+
// personal guard and Full access add it to what the scanner decided.
|
|
117
|
+
export function treeExecFinding(command) {
|
|
118
|
+
for (const { result } of treeFindings(command)) {
|
|
119
|
+
if (result && result.decision !== 'allow' && result.reasons.length) return { decision: result.decision, reason: result.reasons[0] };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Why Full access must still ask about this command, or null.
|
|
125
|
+
export async function mustConfirm(command) {
|
|
126
|
+
const handoff = await handoffReason(command);
|
|
127
|
+
if (handoff) return handoff;
|
|
128
|
+
const result = parse(command);
|
|
129
|
+
const kept = result.reasons.filter(reason => ![...FULL_ACCESS_RUNS].some(id => reason.startsWith(`Prompt required by exec policy ${id}:`)));
|
|
130
|
+
if (result.decision !== 'allow' && kept.length) return kept[0];
|
|
131
|
+
const tree = treeExecFinding(command);
|
|
132
|
+
if (tree && ![...FULL_ACCESS_RUNS].some(id => tree.reason.startsWith(`Prompt required by exec policy ${id}:`))) return tree.reason;
|
|
133
|
+
if (result.segments.some(segment => ['sudo', 'doas'].includes(segment.words.find(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))))) return 'sudo runs as another user';
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Whether an MCP tool writes to the system behind its server, read from the
|
|
138
|
+
// tool's name with the verbs personal Piagent's guard uses
|
|
139
|
+
// (base-policy.json externalActionPolicy): the verb, or null.
|
|
140
|
+
export async function mcpWriteAction(tool) {
|
|
141
|
+
const { actionTokens, classifyActionTokenSequence } = await import('../../extensions/guard-shell-analysis.ts');
|
|
142
|
+
const result = classifyActionTokenSequence(actionTokens(String(tool)), { defaultMode: 'enforce', ...POLICY.externalActionPolicy });
|
|
143
|
+
return result.kind === 'write' ? result.action : null;
|
|
144
|
+
}
|
|
@@ -65,10 +65,10 @@ export function registerPermissionCommands(pi: ExtensionAPI, deps: Record<string
|
|
|
65
65
|
ctx.ui.notify(message, level);
|
|
66
66
|
if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
|
|
67
67
|
if (permissionProfile.mode === "trusted-full-access") {
|
|
68
|
-
ctx.ui.notify("Trusted full access is active; protected paths, secret redaction, and destructive
|
|
68
|
+
ctx.ui.notify("Trusted full access is active; protected paths, secret redaction, and destructive and handoff confirmations remain enforced.", "warning");
|
|
69
69
|
}
|
|
70
70
|
if (permissionProfile.mode === "bypass") {
|
|
71
|
-
ctx.ui.notify("Bypass is active:
|
|
71
|
+
ctx.ui.notify("Bypass is active: the work is not asked about or blocked in this personal session (deletes and protected paths included). The handoff (push, PR, publish, deploy, remote tools, MCP writes) is still asked; output redaction and /undo checkpoints remain. Company sessions keep their own rules.", "warning");
|
|
72
72
|
}
|
|
73
73
|
emitPermissionStatus(ctx, permissionProfile);
|
|
74
74
|
if (request.trim()) sendWorkflowFollowUp(request.trim());
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
1
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
|
|
3
5
|
import { gitEvidenceRootDetails, workingTreeHasChanges } from "../../extensions/workspace-evidence-roots.js";
|
|
@@ -8,17 +10,57 @@ import type { TaskFeatureInput } from "./task-features.ts";
|
|
|
8
10
|
import type { SolverShadowEvaluation } from "./solver-shadow.ts";
|
|
9
11
|
import { SolverShadowRuntime } from "./solver-shadow.ts";
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
type GitShape = { gitReady: boolean; dirtyTree: boolean };
|
|
14
|
+
|
|
15
|
+
function readGitShape(cwd: string): GitShape {
|
|
16
|
+
try {
|
|
17
|
+
const details = gitEvidenceRootDetails(cwd);
|
|
18
|
+
return { gitReady: details.roots.length > 0, dirtyTree: workingTreeHasChanges(cwd, details) };
|
|
19
|
+
} catch { return { gitReady: false, dirtyTree: true }; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The Git shape runs `git rev-parse` (once per child repository in a parent
|
|
23
|
+
// folder) and `git status`. Read synchronously on every prompt it held the
|
|
24
|
+
// Terminal between Enter and the message showing, longer the larger the
|
|
25
|
+
// repository. It is now read in a child process when the session starts and
|
|
26
|
+
// after each run, and a prompt takes the last answer. Only a prompt with no
|
|
27
|
+
// answer yet reads it inline, as before.
|
|
28
|
+
const SHAPE_FRESH_MS = 5_000;
|
|
29
|
+
const SHAPE_PROBE = `import(${JSON.stringify(new URL("../../extensions/workspace-evidence-roots.js", import.meta.url).href)}).then((git) => {
|
|
30
|
+
let shape; try { const details = git.gitEvidenceRootDetails(process.argv[1]);
|
|
31
|
+
shape = { gitReady: details.roots.length > 0, dirtyTree: git.workingTreeHasChanges(process.argv[1], details) };
|
|
32
|
+
} catch { shape = { gitReady: false, dirtyTree: true }; }
|
|
33
|
+
process.stdout.write(JSON.stringify(shape));
|
|
34
|
+
});`;
|
|
35
|
+
const shapes = new Map<string, { at: number; shape: GitShape }>();
|
|
36
|
+
const probing = new Map<string, Promise<void>>();
|
|
37
|
+
|
|
38
|
+
export function refreshGitShape(cwd: string): Promise<void> {
|
|
39
|
+
const running = probing.get(cwd);
|
|
40
|
+
if (running) return running;
|
|
41
|
+
const startedAt = Date.now();
|
|
42
|
+
const probe = new Promise<void>((resolve) => {
|
|
43
|
+
execFile(process.execPath, ["--input-type=module", "-e", SHAPE_PROBE, cwd], { timeout: 60_000, maxBuffer: 4096, windowsHide: true }, (error, stdout) => {
|
|
44
|
+
try {
|
|
45
|
+
const shape = JSON.parse(String(stdout)) as GitShape;
|
|
46
|
+
if (!error && typeof shape.gitReady === "boolean" && typeof shape.dirtyTree === "boolean") shapes.set(cwd, { at: startedAt, shape });
|
|
47
|
+
} catch { /* the next prompt reads it inline */ }
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
}).finally(() => probing.delete(cwd));
|
|
51
|
+
probing.set(cwd, probe);
|
|
52
|
+
return probe;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gitShape(cwd: string): GitShape {
|
|
56
|
+
const cached = shapes.get(cwd);
|
|
57
|
+
if (!cached) {
|
|
58
|
+
const shape = readGitShape(cwd);
|
|
59
|
+
shapes.set(cwd, { at: Date.now(), shape });
|
|
60
|
+
return shape;
|
|
20
61
|
}
|
|
21
|
-
|
|
62
|
+
if (Date.now() - cached.at > SHAPE_FRESH_MS) void refreshGitShape(cwd);
|
|
63
|
+
return cached.shape;
|
|
22
64
|
}
|
|
23
65
|
|
|
24
66
|
export function runtimeTaskFeatureInput(options: {
|