@piagent/platform 1.49.1 → 1.51.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 +40 -0
- package/catalog/capabilities.json +2 -2
- package/docs/command-reference-vietnamese.md +5 -3
- package/docs/company-dev-commands.md +1 -1
- package/docs/distribution-standard.md +6 -6
- package/docs/herdr-workflow.md +30 -0
- 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 +2 -0
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/workflows.html +6 -0
- package/docs-site/content/workflows.html +6 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +15 -15
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +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 +56 -26
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +7 -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 +102 -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 +56 -26
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +7 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +2 -1
- package/packages/piagent-core/extensions/fallback-base-policy.ts +1 -1
- package/packages/piagent-core/extensions/guard-types.ts +4 -1
- package/packages/piagent-core/{managed/herdr-reporter.mjs → extensions/herdr-reporter.js} +29 -13
- package/packages/piagent-core/extensions/piagent-guard.ts +20 -9
- package/packages/piagent-core/extensions/session-status.d.ts +1 -0
- package/packages/piagent-core/extensions/session-status.js +217 -0
- package/packages/piagent-core/managed/approval-refusal.mjs +9 -0
- package/packages/piagent-core/managed/check-evidence.mjs +134 -0
- package/packages/piagent-core/managed/check-questions.mjs +34 -16
- package/packages/piagent-core/managed/command-tools.mjs +52 -9
- package/packages/piagent-core/managed/dist/workspace/assets/packages/piagent-core/policies/base-policy.json +2 -1
- package/packages/piagent-core/managed/dist/workspace/manifest.json +1 -1
- package/packages/piagent-core/managed/fetch-guard.mjs +2 -1
- package/packages/piagent-core/managed/helper-roles.mjs +1 -1
- package/packages/piagent-core/managed/helper-run.mjs +6 -3
- package/packages/piagent-core/managed/host-command.mjs +8 -4
- package/packages/piagent-core/managed/ledger.mjs +5 -5
- package/packages/piagent-core/managed/member-mcp.mjs +2 -0
- package/packages/piagent-core/managed/memory-lessons.mjs +2 -2
- package/packages/piagent-core/managed/permission.mjs +11 -3
- package/packages/piagent-core/managed/repository-operation.mjs +2 -1
- package/packages/piagent-core/managed/session.mjs +5 -5
- package/packages/piagent-core/managed/tool-boundary.mjs +5 -5
- package/packages/piagent-core/managed/tool-failures.mjs +1 -1
- package/packages/piagent-core/managed/workflow.mjs +11 -8
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/policies/base-policy.json +2 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +2 -2
- package/packages/piagent-core/runtime/policy/critical-removal.mjs +122 -0
- package/packages/piagent-core/runtime/policy/handoff-policy.mjs +23 -4
- package/packages/piagent-core/runtime/registration/permission-commands.ts +14 -6
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-webui/dist/client/assets/{ActivityPanel-CmjxPmNT.js → ActivityPanel-DeeJLOYx.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/App-C9tlGPMb.js +5 -0
- package/packages/piagent-webui/dist/client/assets/{GatewayApp-BmkqeXih.js → GatewayApp-Dvo96qoX.js} +17 -17
- package/packages/piagent-webui/dist/client/assets/{InspectionApp-9jBsgkVJ.js → InspectionApp-CGgHsOyC.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-RQ0N65d9.js → SessionAgentWorkspace-n8UMMcrP.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-BWyEN4bf.js → SessionInspectorDrawer-B62kkr8J.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BZijmuPt.js +3 -0
- package/packages/piagent-webui/dist/client/assets/SettingsPage-DKg0SfZQ.js +1 -0
- package/packages/piagent-webui/dist/client/assets/{TerminalPanel-DzHdSjpN.js → TerminalPanel-BPTVsNjP.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-CEuPTTBd.js → WorkspaceCodeEditor-BHGFFBc2.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-B_NwYf4S.js → WorkspaceExplorer-nd4kghwK.js} +3 -3
- package/packages/piagent-webui/dist/client/assets/{index-fUpioGMZ.js → index-BiLVX8S-.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/{transcript-view-model-acWtHGyE.js → transcript-view-model-BB1niiXd.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{view-model-CWNiS4JN.js → view-model-BrdSO09E.js} +1 -1
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/managed-projection.ts +1 -1
- package/packages/piagent-webui/gateway/runtime-session-controls.ts +1 -1
- package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +1 -1
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +1 -1
- package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
- package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
- package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
- package/schemas/project-profile.schema.json +2 -1
- package/scripts/company-terminal.mjs +8 -7
- package/scripts/pi-auto.sh +5 -1
- package/scripts/piagent-cli.mjs +3 -1
- package/scripts/piagent-sessions.mjs +97 -0
- package/scripts/piagent-studio.mjs +5 -3
- package/packages/piagent-webui/dist/client/assets/App-JrRb7jgn.js +0 -5
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-Wx-q5QQk.js +0 -3
- package/packages/piagent-webui/dist/client/assets/SettingsPage-xAt6kSyZ.js +0 -1
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"file": "assets/packages/piagent-core/policies/base-policy.json",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "4e049d4663ad3aec174941e6df39c677123a8ad50e66b6449e96969bf53cae6a"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"file": "assets/adapters/backend-api/profile.json",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { asksOnlyHandoff } from './permission.mjs';
|
|
1
|
+
import { alwaysBypass, 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
|
|
@@ -16,6 +16,7 @@ export const trustedHost = host => TRUSTED.includes(String(host).toLowerCase().r
|
|
|
16
16
|
|
|
17
17
|
// null: fetch it; 'ask': the member approves first; 'refuse': a helper's fetch the member would have to approve.
|
|
18
18
|
export function fetchDecision(owner, role, raw) {
|
|
19
|
+
if (alwaysBypass(owner)) return null;
|
|
19
20
|
let url; try { url = new URL(raw); } catch { return null; } // web_fetch refuses it on its own
|
|
20
21
|
const host = url.hostname.toLowerCase();
|
|
21
22
|
if (trustedHost(host)) return null;
|
|
@@ -87,7 +87,7 @@ export function countedCheck(managed, tool) {
|
|
|
87
87
|
try { const result = await tool.execute(...args); ok = !result?.isError; return result; }
|
|
88
88
|
finally {
|
|
89
89
|
const after = counted && before !== null ? await folder() : null;
|
|
90
|
-
if (after !== null && after === before) await managed.run.afterTool('bash', args[1], ok, () => managed.digest());
|
|
90
|
+
if (after !== null && after === before) await managed.run.afterTool('bash', args[1], ok, () => managed.digest(), managed);
|
|
91
91
|
}
|
|
92
92
|
} };
|
|
93
93
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// refusal, starts once more: Studio then gives the new helper the next model
|
|
5
5
|
// of its role that serves (an auto role's list).
|
|
6
6
|
import { fenced, HELPER_UNTRUSTED_RULE } from './untrusted.mjs';
|
|
7
|
+
import { relevantChecks } from './check-evidence.mjs';
|
|
7
8
|
import { jobTitle } from './call-purpose.mjs';
|
|
8
9
|
import { ManagedToolBoundary } from './tool-boundary.mjs';
|
|
9
10
|
import { managedResourceLoader } from './resource-loader.mjs';
|
|
@@ -20,6 +21,8 @@ import { trackToolFailures } from './tool-failures.mjs';
|
|
|
20
21
|
import { helperMemoryTools, renewer } from './memory.mjs';
|
|
21
22
|
import { answeredSettlement, failedSettlement, settleHelper, OUTCOME_WORDS } from './helper-settlement.mjs';
|
|
22
23
|
import { viaLink } from './studio-link.mjs';
|
|
24
|
+
import { unsandboxedBash } from './command-tools.mjs';
|
|
25
|
+
import { alwaysBypass } from './permission.mjs';
|
|
23
26
|
|
|
24
27
|
const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
|
|
25
28
|
// A helper failure that another model of its role may not have: the model's
|
|
@@ -143,8 +146,8 @@ async function answerUnder(managed, key, task, signal, harness, snapshot, parts,
|
|
|
143
146
|
// Studio's logs name the job by its title (the brief's first line without one).
|
|
144
147
|
(managed.jobTitles ??= new WeakMap()).set(runtime, jobTitle(task, title));
|
|
145
148
|
const setup = HELPER_SETUP[role], memory = helperMemoryTools(managed, () => grant, renew);
|
|
146
|
-
boundary = new ManagedToolBoundary({ cwd: managed.cwd, sdkRoot: managed.sdk, protectedRoots: managed.protectedRoots, readOnly: true, commands: setup.commands });
|
|
147
|
-
const checks = role === 'verify' ? `\n\nRepository checks: ${managed.checks.commands.length ? managed.checks.commands.map(c => '`' + c + '`').join(', ') : 'none declared or detected; choose the tests, type check or build that cover the claims'}.${managed.run?.fullAsked || managed.run?.scopeChoice === 'full' ? '' : ' Run only the tests that cover the claims: the member decides about a whole-suite run.'}` : '';
|
|
149
|
+
boundary = new ManagedToolBoundary({ cwd: managed.cwd, sdkRoot: managed.sdk, protectedRoots: managed.protectedRoots, readOnly: true, commands: setup.commands }); boundary.unsandboxed = () => alwaysBypass(managed);
|
|
150
|
+
const checks = role === 'verify' ? `\n\nRepository checks: ${managed.checks.commands.length ? relevantChecks(managed, managed.checks.commands).map(c => '`' + c + '`').join(', ') : 'none declared or detected; choose the tests, type check or build that cover the claims'}.${managed.run?.fullAsked || managed.run?.scopeChoice === 'full' ? '' : ' Run only the tests that cover the claims: the member decides about a whole-suite run.'}` : '';
|
|
148
151
|
// One helper session answers the brief (with one part of the patch for a
|
|
149
152
|
// review in parts); its reply and tokens. A helper runs at its role's
|
|
150
153
|
// level from Studio (fixed by the Harness, or its main agent's).
|
|
@@ -152,7 +155,7 @@ async function answerUnder(managed, key, task, signal, harness, snapshot, parts,
|
|
|
152
155
|
const { session } = await managed.api.createAgentSession({ cwd: managed.cwd, model, modelRuntime: runtime, thinkingLevel: grant.effort || 'off',
|
|
153
156
|
settingsManager: managed.api.SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } }, cacheWarming: 'off' }),
|
|
154
157
|
sessionManager: managed.api.SessionManager.inMemory(managed.cwd), noTools: 'builtin', tools: [...READ_TOOLS, ...(setup.commands ? ['bash'] : []), ...(setup.web ? ['web_search', 'web_fetch'] : []), ...memory.map(tool => tool.name)],
|
|
155
|
-
customTools: [...boundary.tools(managed.api).filter(tool => READ_TOOLS.includes(tool.name) || setup.commands && tool.name === 'bash').map(tool => countedCheck(managed, tool)),
|
|
158
|
+
customTools: [...unsandboxedBash(managed, boundary.tools(managed.api)).filter(tool => READ_TOOLS.includes(tool.name) || setup.commands && tool.name === 'bash').map(tool => countedCheck(managed, tool)),
|
|
156
159
|
...(setup.web ? managed.webTools(runtime, key) : []), ...memory], resourceLoader: managedResourceLoader(managed.api, { systemPrompt: setup.prompt + HELPER_UNTRUSTED_RULE }) });
|
|
157
160
|
sessions.add(session); trackToolFailures(managed, session, role); watched.add(who); watchHelper(managed, who, session, task, title);
|
|
158
161
|
const abort = () => void session.abort(); signal?.addEventListener('abort', abort, { once: true });
|
|
@@ -51,16 +51,20 @@ const TAIL = 50 * 1024;
|
|
|
51
51
|
const tail = text => text.length > TAIL ? `[… ${text.length - TAIL} characters before …]\n${text.slice(-TAIL)}` : text;
|
|
52
52
|
const shell = () => process.platform === 'darwin' ? '/bin/zsh' : fs.existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
|
|
53
53
|
|
|
54
|
+
export function spawnOnHost(command, cwd, userHome = os.homedir(), spawnImpl = spawn) {
|
|
55
|
+
const login = shell(), user = os.userInfo().username;
|
|
56
|
+
return spawnImpl(login, ['-lc', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
env: { HOME: userHome, USER: user, LOGNAME: user, SHELL: login, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8', TERM: 'dumb',
|
|
58
|
+
GIT_TERMINAL_PROMPT: '0', NONINTERACTIVE: '1', HOMEBREW_NO_ENV_HINTS: '1', DEBIAN_FRONTEND: 'noninteractive' } });
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
// Runs one command as the member, in a login shell (Homebrew, SDKMAN… on
|
|
55
62
|
// PATH as in their Terminal). Nothing of Piagent's own environment (Studio
|
|
56
63
|
// tokens) is passed; no input, so a prompt ends the command instead of hanging.
|
|
57
64
|
export function runHostCommand({ command, cwd, userHome = os.homedir(), timeout = 600, signal, onUpdate, spawnImpl = spawn }) {
|
|
58
65
|
return new Promise((resolve, reject) => {
|
|
59
66
|
if (signal?.aborted) return reject(Error('Command aborted'));
|
|
60
|
-
const
|
|
61
|
-
const child = spawnImpl(login, ['-lc', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
-
env: { HOME: userHome, USER: user, LOGNAME: user, SHELL: login, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8', TERM: 'dumb',
|
|
63
|
-
GIT_TERMINAL_PROMPT: '0', NONINTERACTIVE: '1', HOMEBREW_NO_ENV_HINTS: '1', DEBIAN_FRONTEND: 'noninteractive' } });
|
|
67
|
+
const child = spawnOnHost(command, cwd, userHome, spawnImpl);
|
|
64
68
|
let output = '', ended = null, last = 0;
|
|
65
69
|
const kill = why => { ended ??= why; try { process.kill(-child.pid, 'SIGTERM'); } catch {} setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref(); };
|
|
66
70
|
const seconds = Math.min(Math.max(Number(timeout) || 600, 1), 1800);
|
|
@@ -120,7 +120,7 @@ export function rejudgeBrief(item) {
|
|
|
120
120
|
// One step of the completion gate (true: the gate goes round again). A
|
|
121
121
|
// rejected premise objection goes back to its helper, which withdraws it or
|
|
122
122
|
// keeps it for the member; then the items the main agent never decided are
|
|
123
|
-
// put to it once, with no new review.
|
|
123
|
+
// put to it once each, with no new review: a later review's findings are new items.
|
|
124
124
|
export async function settleLedger(managed, run, { note, send, signal }) {
|
|
125
125
|
const rejected = toRejudge(run)[0];
|
|
126
126
|
if (rejected) {
|
|
@@ -133,9 +133,9 @@ export async function settleLedger(managed, run, { note, send, signal }) {
|
|
|
133
133
|
await note(`Harness: the ${rejected.role} subagent keeps its objection; the member decides.\n${itemText(rejected)}`, { phase: 'dispute', role: rejected.role, id: rejected.id });
|
|
134
134
|
return true;
|
|
135
135
|
}
|
|
136
|
-
const pending = undecided(run);
|
|
137
|
-
if (!pending.length
|
|
138
|
-
|
|
136
|
+
const asked = run.decidedAsked ??= new Set(), pending = undecided(run).filter(item => !asked.has(item.id));
|
|
137
|
+
if (!pending.length) return false;
|
|
138
|
+
for (const item of pending) asked.add(item.id);
|
|
139
139
|
await send(decideText(pending), { phase: 'answer', ids: pending.map(i => i.id), issues: pending.slice(0, 10).map(i => ({ kind: i.kind, detail: i.detail })) });
|
|
140
140
|
return true;
|
|
141
141
|
}
|
|
@@ -151,7 +151,7 @@ export function ledgerWords(run) {
|
|
|
151
151
|
const items = ledgerOf(run), count = s => items.filter(i => i.status === s).length, words = [];
|
|
152
152
|
if (!items.length && !run.minorFindings) return null;
|
|
153
153
|
for (const [status, label] of [['accepted', 'accepted'], ['rejected', 'rejected'], ['withdrawn', 'withdrawn by the helper'], ['cleared', 'cleared by a later check'], ['disputed', 'for the member'], ['open', 'left without a decision'], ['noted', 'noted']])
|
|
154
|
-
if (count(status)) words.push(`${count(status)} ${label}`);
|
|
154
|
+
if (count(status)) words.push(`${count(status)} ${label}${status === 'open' ? ` (${items.filter(i => i.status === 'open').map(i => i.id).join(', ')})` : ''}`);
|
|
155
155
|
if (run.minorFindings) words.push(`${run.minorFindings} minor finding(s) listed`);
|
|
156
156
|
return `objections: ${words.join(', ')}`;
|
|
157
157
|
}
|
|
@@ -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 { alwaysBypass } from './permission.mjs';
|
|
5
6
|
import { mcpWriteAction } from '../runtime/policy/handoff-policy.mjs';
|
|
6
7
|
import { userToolchains } from './toolchain.mjs';
|
|
7
8
|
import { fenced } from './untrusted.mjs';
|
|
@@ -62,6 +63,7 @@ export function memberMcpGuard(cwd, adapterDir, owner = null) {
|
|
|
62
63
|
const fromAdapter = name => { const source = pi.getAllTools().find(tool => tool.name === name)?.sourceInfo?.path; return !!source && (source === adapterDir || inside(source, adapterDir)); };
|
|
63
64
|
pi.on('tool_call', async (event, ctx) => {
|
|
64
65
|
const decision = evaluateMcpApproval(cwd, event.toolName, event.input ?? {});
|
|
66
|
+
if (owner && alwaysBypass(owner)) return undefined;
|
|
65
67
|
if (decision.block) return { block: true, reason: decision.reason };
|
|
66
68
|
const called = mcpToolCalled(event.toolName, event.input, fromAdapter);
|
|
67
69
|
const action = called && await mcpWriteAction(called.tool);
|
|
@@ -55,7 +55,7 @@ export class CheckLessons {
|
|
|
55
55
|
// shape it becomes a failure lesson for the team memory.
|
|
56
56
|
#toolError(call,event,output) {
|
|
57
57
|
if(!COMMAND_TOOLS.has(call.tool)||call.check||this.lessons.length>=MAX_LESSONS)return;
|
|
58
|
-
if(/managed[-_](?:tool[-_]cancelled|operation[-_]denied|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
58
|
+
if(/managed[-_](?:tool[-_]cancelled|operation[-_](?:denied|expired|cancelled)|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
59
59
|
const line=output.split('\n').find(l=>ERROR_LINE.test(l));
|
|
60
60
|
if(!line)return;
|
|
61
61
|
const command=commandShape(call.command);
|
|
@@ -85,7 +85,7 @@ export class CheckLessons {
|
|
|
85
85
|
const output=textOf(event.result), c=call.check;
|
|
86
86
|
const exitFailed=event.isError || (event.result?.details?.exitCode !== undefined && event.result.details.exitCode!==0);
|
|
87
87
|
if(exitFailed || (c.piped && failedOutput(output))) {
|
|
88
|
-
if(/managed[-_](?:tool[-_]cancelled|operation[-_]denied|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
88
|
+
if(/managed[-_](?:tool[-_]cancelled|operation[-_](?:denied|expired|cancelled)|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
|
|
89
89
|
this.failed++;this.last='failed';
|
|
90
90
|
const diagnostic=output.split('\n').find(l=>/error|fail|exception|not found|EADDRINUSE/i.test(l)) ?? 'Check exited unsuccessfully';
|
|
91
91
|
if(this.failures.size>=20)this.failures.delete(this.failures.keys().next().value);
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
// tools, uploads), where the team's Harness allows it
|
|
8
8
|
// (harness.configuration.bypass_mode, an administrator's switch in Studio).
|
|
9
9
|
// The sandbox itself stays: bypass removes questions, not the boundary.
|
|
10
|
-
|
|
10
|
+
// "always-bypass" (owner, 2026-11-02) removes both, as the member's own decision
|
|
11
|
+
// after a warning; no Harness switch governs it. Only committing and pushing
|
|
12
|
+
// code and an rm on a critical path are asked (critical-removal.mjs); the
|
|
13
|
+
// project's autoDeny rules hold; what needs the member's password stays theirs.
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { criticalRemovalReason } from '../runtime/policy/critical-removal.mjs';
|
|
16
|
+
import { codeHandoffReason, handoffReason, mustConfirm } from '../runtime/policy/handoff-policy.mjs';
|
|
11
17
|
|
|
12
|
-
export const PERMISSIONS = ['workspace-write', 'trusted-full-access', 'bypass'];
|
|
18
|
+
export const PERMISSIONS = ['workspace-write', 'trusted-full-access', 'bypass', 'always-bypass'];
|
|
13
19
|
const ENTRY = 'agent-watch-permission';
|
|
14
20
|
|
|
15
21
|
// Only an explicit yes from Studio: an Agent Watch that does not carry the
|
|
@@ -41,11 +47,13 @@ export function recordBypass(owner, manager) {
|
|
|
41
47
|
// Whether this conversation skips the questions Full access still asks
|
|
42
48
|
// (everything but the handoff).
|
|
43
49
|
export const asksOnlyHandoff = owner => effectivePermission(owner) === 'bypass';
|
|
44
|
-
export const
|
|
50
|
+
export const alwaysBypass = owner => effectivePermission(owner) === 'always-bypass';
|
|
51
|
+
export const broadAccess = owner => ['trusted-full-access', 'bypass', 'always-bypass'].includes(effectivePermission(owner));
|
|
45
52
|
// Why a command that leaves the sandbox must be asked about: the reason, or
|
|
46
53
|
// 'ask' in Ask first, or null to run it. Every mode asks about the handoff.
|
|
47
54
|
export const networkConfirmation = async (owner, command) => {
|
|
48
55
|
const mode = effectivePermission(owner);
|
|
56
|
+
if (mode === 'always-bypass') return criticalRemovalReason(command, { cwd: owner.cwd ?? process.cwd(), home: owner.boundary?.userHome ?? os.homedir() }) ?? codeHandoffReason(command);
|
|
49
57
|
if (mode === 'bypass') return handoffReason(command);
|
|
50
58
|
if (mode === 'trusted-full-access') return mustConfirm(command);
|
|
51
59
|
return 'ask';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { broadAccess } from './permission.mjs';
|
|
2
|
+
import { approvalRefusal } from './approval-refusal.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -121,7 +122,7 @@ export function fetchOriginTool(self) {
|
|
|
121
122
|
reason:`Fetch origin branches from ${plan.repository}`,riskClass:'low',allowConsequence:'Download origin branches once; keep the current branch and working files unchanged.',denyConsequence:'No network request or credential read.'},
|
|
122
123
|
terminalConfirm:()=>ctx?.ui?.confirm?.('Tải nhánh từ origin',`Tải các nhánh của ${plan.repository} về ${plan.cwd} một lần.\n\nĐồng ý: chỉ tải nhánh; nhánh hiện tại và file đang làm không đổi.\nTừ chối: không gửi request mạng, không đọc thông tin đăng nhập.`)??Promise.resolve(false),
|
|
123
124
|
unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
|
|
124
|
-
if(!decision.allowed||!decision.consume()||signal?.aborted)throw
|
|
125
|
+
if(!decision.allowed||!decision.consume()||signal?.aborted)throw approvalRefusal(decision);
|
|
125
126
|
return executeRepositoryFetch(self.boundary,plan,signal);
|
|
126
127
|
}};
|
|
127
128
|
}
|
|
@@ -28,8 +28,8 @@ import { beforeDelegate, resolveTool } from './objections.mjs'; import { fanOut,
|
|
|
28
28
|
import { HELPER_CALLS, HELPER_ROLES, helperRoles, helperPrompt, webPrompt, delegateDescription, mainWebLookups, limitedWebTools } from './helper-roles.mjs'; import { delegateParameters, delegateArgs, SPEC_PROMPT } from './helper-settlement.mjs';
|
|
29
29
|
import { loadAgentResources } from './agent-skills.mjs';
|
|
30
30
|
import { loadPiagentThemes } from '../runtime/terminal/terminal-style.mjs';
|
|
31
|
-
import { restorePermission, recordBypass, permissionSetter } from './permission.mjs';
|
|
32
|
-
import { commandTools, servicesPrompt } from './command-tools.mjs'; import { waiveChecksTool } from './check-waiver.mjs'; import { attachMemory, memoryToolsFor, openRound, closeRound, prepareMemoryContext, trackMemoryLessons } from './memory.mjs';
|
|
31
|
+
import { alwaysBypass, restorePermission, recordBypass, permissionSetter } from './permission.mjs';
|
|
32
|
+
import { commandTools, hostBash, servicesPrompt, unsandboxedBash } from './command-tools.mjs'; import { waiveChecksTool } from './check-waiver.mjs'; import { attachMemory, memoryToolsFor, openRound, closeRound, prepareMemoryContext, trackMemoryLessons } from './memory.mjs';
|
|
33
33
|
|
|
34
34
|
const PROVIDER = 'agent_watch_managed';
|
|
35
35
|
export const HELPER_BEAT_MS = 60_000;
|
|
@@ -113,7 +113,7 @@ export class ManagedSession {
|
|
|
113
113
|
const preview = { model_id: candidate.id, provider: candidate.owned_by, provider_model_id: candidate.provider_model_id };
|
|
114
114
|
const model = self.installModel(self.modelRuntime, preview);
|
|
115
115
|
self.wrapStreams(self.modelRuntime, 'main');
|
|
116
|
-
const customTools = processEditTools(self, self.boundary.tools(api));
|
|
116
|
+
const customTools = processEditTools(self, unsandboxedBash(self, self.boundary.tools(api)));
|
|
117
117
|
customTools.push(fetchOriginTool(self));
|
|
118
118
|
// With a research helper the web is its work (pages read on its cheaper model), beyond the Harness's few quick lookups.
|
|
119
119
|
if (!helperRoles(manifest).includes('research')) customTools.push(...self.webTools(self.modelRuntime, 'main'));
|
|
@@ -170,7 +170,7 @@ export class ManagedSession {
|
|
|
170
170
|
self.session.managedExecution = true;
|
|
171
171
|
self.turnRetry = enableCompanyTurnRetry(self.session, settings, self.studioLink);
|
|
172
172
|
// The member's access for this conversation: Ask first, Full access or Bypass (permission.mjs).
|
|
173
|
-
self.permission = restorePermission(manager); recordBypass(self, manager); self.session.managedSetPermission = permissionSetter(self, manager);
|
|
173
|
+
self.permission = restorePermission(manager); recordBypass(self, manager); self.session.managedSetPermission = permissionSetter(self, manager); self.boundary.unsandboxed = () => alwaysBypass(self);
|
|
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
|
|
176
176
|
self.session.managedFastServed = () => { const route = self.routes.get(self.modelRuntime); return route ? fastServed(route) : null; };
|
|
@@ -323,7 +323,7 @@ export class ManagedSession {
|
|
|
323
323
|
// Native ! commands use the same worker boundary as model bash calls.
|
|
324
324
|
self.session.executeBash = async command => {
|
|
325
325
|
try {
|
|
326
|
-
const result = await self.boundary.invoke('bash', { command });
|
|
326
|
+
const result = alwaysBypass(self) ? await hostBash(self, { command }) : await self.boundary.invoke('bash', { command });
|
|
327
327
|
return { output: textContent(result), exitCode: 0, cancelled: false, truncated: false };
|
|
328
328
|
} finally { await self.refreshReview(); }
|
|
329
329
|
};
|
|
@@ -21,7 +21,7 @@ import { proxyEnvironment } from './package-proxy.mjs';
|
|
|
21
21
|
import { startBoundaryPackageProxy } from './boundary-package-proxy.mjs';
|
|
22
22
|
import { SourceRevertRequests } from './source-revert-requests.mjs';
|
|
23
23
|
import { BWRAP, bubblewrapArgs, credentialFiles, referenceFolders } from './linux-sandbox.mjs';
|
|
24
|
-
import { agentWatchDataDirectory } from './store.mjs';
|
|
24
|
+
import { agentWatchDataDirectory } from './store.mjs'; import { spawnOnHost } from './host-command.mjs';
|
|
25
25
|
const TOOL_NAMES = Object.freeze(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
|
|
26
26
|
const MAX_WIRE_BYTES = 12 * 1024 * 1024;
|
|
27
27
|
const workerPath = fileURLToPath(new URL('./tool-worker.mjs', import.meta.url));
|
|
@@ -112,7 +112,7 @@ ${proxyPort && !network ? `(allow mach-lookup (global-name "com.apple.trustd.age
|
|
|
112
112
|
(allow network-outbound (literal ${literal(docker)}))` : ''}` : loopback ? '(allow network-outbound (remote ip "localhost:*"))' : proxyPort ? `(allow network-outbound (remote ip "localhost:${Number(proxyPort)}"))` : ''}
|
|
113
113
|
${denied.map(root => `(deny file-read* file-write* (require-all (subpath ${literal(root)})${skills.filter(dir => inside(dir, root) && dir !== root).map(dir => ` (require-not (subpath ${literal(dir)}))`).join('')}))`).join('\n')}
|
|
114
114
|
(deny file-read* file-read-metadata file-write* (require-all (require-any (regex #"(^|/)(\\.env([./]|$)|credentials([./]|$)|\\.npmrc$|\\.netrc$)") (regex #"/\\.[^/]+/auth\\.json$") ${projectRoots.map(root => `(literal ${literal(path.join(root, 'auth.json'))})`).join(' ')})
|
|
115
|
-
(require-not (regex #"(^|/)\\.env\\.(example|sample|template|dist|defaults)$"))))
|
|
115
|
+
(require-not (regex #"(^|/)\\.env\\.(example|sample|template|dist|defaults)$")))) (allow file-read* file-read-metadata file-write* (require-all (subpath ${literal(temporary)}) (regex #"/\\.env([.][^/]*)?$")))
|
|
116
116
|
`;
|
|
117
117
|
}
|
|
118
118
|
// Toolchains from the pinned node/git and Homebrew, compilers and SDK; the
|
|
@@ -369,9 +369,9 @@ export class ManagedToolBoundary {
|
|
|
369
369
|
if (Buffer.byteLength(message) > MAX_WIRE_BYTES) throw new Error('managed-tool-input-too-large');
|
|
370
370
|
return await new Promise((resolve, reject) => {
|
|
371
371
|
guard?.();
|
|
372
|
-
const [sandbox, sandboxArgs] = this.linux ? [BWRAP, this.bubblewrap(network, docker)]
|
|
372
|
+
const [sandbox, sandboxArgs] = this.unsandboxed?.() ? [null, []] : this.linux ? [BWRAP, this.bubblewrap(network, docker)]
|
|
373
373
|
: ['/usr/bin/sandbox-exec', ['-p', docker ? this.dockerProfile : network ? this.networkProfile : isolated ? this.isolatedProfile : this.profile]];
|
|
374
|
-
const child = spawn(sandbox, [...sandboxArgs, this.node, this.workerPath, this.sdkPath, this.cwd], {
|
|
374
|
+
const child = spawn(sandbox ?? this.node, [...sandboxArgs, ...(sandbox ? [this.node] : []), this.workerPath, this.sdkPath, this.cwd], {
|
|
375
375
|
cwd: this.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
376
376
|
env: docker ? this.environment.docker : network ? this.environment.network : this.environment.offline,
|
|
377
377
|
});
|
|
@@ -455,7 +455,7 @@ export class ManagedToolBoundary {
|
|
|
455
455
|
// A process the agent keeps running across turns (background-process.mjs):
|
|
456
456
|
// the plain command sandbox and environment, marked as this conversation's
|
|
457
457
|
// background process instead of the turn's, so stopStrays leaves it.
|
|
458
|
-
async spawnBackground(command, key) { return this.spawnSandboxed(['/bin/bash', '-c', command], key); }
|
|
458
|
+
async spawnBackground(command, key) { return this.unsandboxed?.() ? { child: spawnOnHost(command, this.cwd, this.userHome), isolated: false } : this.spawnSandboxed(['/bin/bash', '-c', command], key); }
|
|
459
459
|
// `argv` in the plain command sandbox; stdin 'pipe' for a process that
|
|
460
460
|
// answers requests (ts-diagnostics.mjs).
|
|
461
461
|
async spawnSandboxed(argv, key, { stdin = 'ignore' } = {}) {
|
|
@@ -15,7 +15,7 @@ const INPUT_CHARS = 500, ERROR_HEAD = 1000, ERROR_TAIL = 2000;
|
|
|
15
15
|
// One request carries at most BATCH failures; KEEP wait for the next one.
|
|
16
16
|
const BATCH = 20, KEEP = 100, ATTEMPTS = 3;
|
|
17
17
|
// The member stopped the tool or declined to approve it: not a failure.
|
|
18
|
-
const NOT_FAILURES = new Set(['managed_tool_cancelled', 'managed_helper_cancelled', 'managed_operation_denied']);
|
|
18
|
+
const NOT_FAILURES = new Set(['managed_tool_cancelled', 'managed_helper_cancelled', 'managed_operation_denied', 'managed_operation_expired', 'managed_operation_cancelled']);
|
|
19
19
|
|
|
20
20
|
const text = result => typeof result === 'string' ? result
|
|
21
21
|
: Array.isArray(result?.content) ? result.content.filter(part => part?.type === 'text').map(part => part.text ?? '').join('\n') : '';
|
|
@@ -4,7 +4,8 @@ import { TsDiagnostics } from './ts-diagnostics.mjs';
|
|
|
4
4
|
import { checkpointsOf } from './checkpoints.mjs';
|
|
5
5
|
import { declaredChecks, detectedChecks, repositoryChecks, isCheckCommand, isReadOnlyCommand, nonCodePath, onlyNonCode } from './repository-checks.mjs';
|
|
6
6
|
export { declaredChecks, detectedChecks, repositoryChecks, isCheckCommand, isReadOnlyCommand, nonCodePath, onlyNonCode };
|
|
7
|
-
import { askCheckScope, asksFullChecks, beforeCheckOrPush, checkScope } from './check-questions.mjs';
|
|
7
|
+
import { askCheckScope, asksFullChecks, beforeCheckOrPush, checkScope, suitePart } from './check-questions.mjs';
|
|
8
|
+
import { recordCheck, relevantChecks } from './check-evidence.mjs';
|
|
8
9
|
import { LANE_LIMITS, patchStats, changeLane, lanePolicy } from './risk-lanes.mjs';
|
|
9
10
|
import { briefCounts, decisionsText, ledgerGuard, ledgerOf, ledgerWords, reviewFocus, saveLedger, setStatus, settleLedger } from './ledger.mjs';
|
|
10
11
|
|
|
@@ -81,7 +82,7 @@ export function workflowPrompt(policy, checks) {
|
|
|
81
82
|
const L = LANE_LIMITS;
|
|
82
83
|
lines.push(`- Size: the process follows the size of your change. A small change (at most ${L.tinyFiles} files and ${L.tinyLines} changed lines, no migration, auth, security, CI, dependency or AGENTS.md file) ends after its checks, without a review: do not ask for one. A risky change (a complex request, one of those files, more than ${L.riskyFiles} files or ${L.riskyLines} lines) must pass the checks${policy.review !== 'off' ? ' and the harness review' : ''} even where they are only suggested.`);
|
|
83
84
|
}
|
|
84
|
-
return lines.length ? `\n\nProcess for code changes (company Harness):\n${lines.join('\n')}\n${process.platform === 'darwin' ? 'End-to-end and UI checks run in bash with the project\'s Playwright (@playwright/test; add it with npm when the project lacks it) and the browsers installed on this Mac, against a server the test or start_process starts; while a local proxy listens they need run_with_network. When Playwright\'s browsers are missing, ask with run_on_host to run `npx playwright install chromium`.' : 'End-to-end tests that start a local server and a browser run through run_with_network, which the user approves.'} If a check cannot run because the machine lacks something (a runtime such as a JDK, Docker, a database or another service, the network), do not stop there: request the approval that makes it run (run_with_network, run_with_docker, start_docker, or run_on_host to install what is missing) or ask the member with ask_user; never install or start anything without that approval.\nA command stops after 10 minutes and anything left running in the background stops when the turn ends: run the checks that cover your change first and a long suite in parts, not in the background.${process.platform === 'darwin' ? ' A dev server, watcher or local service you need across turns runs with start_process (read it with process_output, stop it with stop_process).' : ''}` : '';
|
|
85
|
+
return lines.length ? `\n\nProcess for code changes (company Harness):\n${lines.join('\n')}\n${process.platform === 'darwin' ? 'A bash command stops after 10 minutes unless you pass timeout in seconds, at most 1800: a long suite needs that, not another tool. End-to-end and UI checks run in bash with the project\'s Playwright (@playwright/test; add it with npm when the project lacks it) and the browsers installed on this Mac, against a server the test or start_process starts; while a local proxy listens they need run_with_network. When Playwright\'s browsers are missing, ask with run_on_host to run `npx playwright install chromium`.' : 'End-to-end tests that start a local server and a browser run through run_with_network, which the user approves.'} If a check cannot run because the machine lacks something (a runtime such as a JDK, Docker, a database or another service, the network), do not stop there: request the approval that makes it run (run_with_network, run_with_docker, start_docker, or run_on_host to install what is missing) or ask the member with ask_user; never install or start anything without that approval.\nA command stops after 10 minutes and anything left running in the background stops when the turn ends: run the checks that cover your change first and a long suite in parts, not in the background.${process.platform === 'darwin' ? ' A dev server, watcher or local service you need across turns runs with start_process (read it with process_output, stop it with stop_process).' : ''}` : '';
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
// Every helper checks the brief it was given: it may object with evidence, it
|
|
@@ -173,7 +174,7 @@ export class RunProcess {
|
|
|
173
174
|
// asked for, and where the harness last sent the agent back (to read
|
|
174
175
|
// its answer and see whether the code moved).
|
|
175
176
|
ledger: [], disputes: [], verifyFails: [], claimsAsked: false, claimsAt: null, claimsRechecked: false, claimsDone: false, mainReviews: 0, minorFindings: 0,
|
|
176
|
-
sentAt: null, sentDigest: null, rejudged: false, reviewSettled: false,
|
|
177
|
+
sentAt: null, sentDigest: null, rejudged: false, reviewSettled: false,
|
|
177
178
|
// How much of the suite the member wants run (check-questions.mjs).
|
|
178
179
|
scopeChoice: null, scopeWords: null, fullAsked: asksFullChecks(request), pushAllowedOn: undefined,
|
|
179
180
|
// The patch's risk lane (workflow.lanes) and the main agent's own web lookups.
|
|
@@ -197,15 +198,17 @@ export class RunProcess {
|
|
|
197
198
|
changedWithoutPlan() {
|
|
198
199
|
this.unplannedChange = true; this.planSkipped = true;
|
|
199
200
|
}
|
|
200
|
-
|
|
201
|
+
// `owner`: the conversation the command ran in, for what passed in which repository.
|
|
202
|
+
async afterTool(name, args, ok, digestOf, owner = null) {
|
|
201
203
|
if (['write', 'edit'].includes(name) && ok) this.edited = true;
|
|
202
204
|
if (name === 'bash' && isCheckCommand(args?.command, this.checks.commands)) {
|
|
203
205
|
const scope = checkScope(args.command, this.checks.commands), digest = await digestOf(), last = this.lastCheck;
|
|
204
206
|
this.checksRun += 1; if (!ok) this.checksFailed += 1;
|
|
205
207
|
// A lint after a partial test run does not make it the whole suite.
|
|
206
208
|
this.lastCheck = { ok, digest, mutations: this.mutations, scope: scope ?? (last?.scope === 'partial' && last.digest === digest ? 'partial' : 'other') };
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
await recordCheck(this, owner, args.command, scope, ok, suitePart(args.command));
|
|
210
|
+
// The member's "run it all" covers one run in each repository a push needs; after a failure they are asked again.
|
|
211
|
+
if (scope === 'suite' && this.scopeChoice === 'full' && (!ok || !this.fullFor?.size)) { this.scopeChoice = null; this.fullFor = null; }
|
|
209
212
|
// A command may change files, so it counts. Without a git repository to
|
|
210
213
|
// compare the code against, one that only reads does not: counting every
|
|
211
214
|
// command reported code changes in turns that only read and checked.
|
|
@@ -298,7 +301,7 @@ export async function completionGate(managed, run, signal) {
|
|
|
298
301
|
}
|
|
299
302
|
if (policy.verify === 'require' && (!run.verified || run.scopeWords) && !run.docsOnly && !verifyGaveUp && run.waivedDigest !== digest) {
|
|
300
303
|
run.fixLoops += 1;
|
|
301
|
-
const list = run.checks.commands.length ? run.checks.commands.map(c => '`' + c + '`').join(', ') : 'the tests, type check or build that cover the change';
|
|
304
|
+
const list = run.checks.commands.length ? relevantChecks(managed, run.checks.commands).map(c => '`' + c + '`').join(', ') : 'the tests, type check or build that cover the change';
|
|
302
305
|
const words = run.scopeWords; run.scopeWords = null;
|
|
303
306
|
const ask = words ? `The member answered about testing: "${String(words).slice(0, 500)}". Follow that and fix what fails.`
|
|
304
307
|
: run.scopeChoice === 'partial' ? 'Run only the tests that cover your change (one file, a filter, one package), not the whole suite, and fix what fails.'
|
|
@@ -446,7 +449,7 @@ export function processEditTools(session, tools) {
|
|
|
446
449
|
}
|
|
447
450
|
return result;
|
|
448
451
|
}
|
|
449
|
-
finally { await session.run?.afterTool(tool.name, args[1], ok, () => session.digest()); await session.refreshReview(); }
|
|
452
|
+
finally { await session.run?.afterTool(tool.name, args[1], ok, () => session.digest(), session); await session.refreshReview(); }
|
|
450
453
|
} };
|
|
451
454
|
});
|
|
452
455
|
}
|
|
@@ -319,6 +319,9 @@ export function registerSessionStartHook(pi: ExtensionAPI, dependencies: Session
|
|
|
319
319
|
if (permissionProfile.mode === "trusted-full-access") {
|
|
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
|
+
if (permissionProfile.mode === "always-bypass") {
|
|
323
|
+
ctx.ui.notify("Warning: Piagent always bypass is active, by your decision. Only committing and pushing code and removing a critical path (rm -rf ~) are asked; deploys, MCP, network and credentials run. The project's autoDeny rules, output redaction and /undo checkpoints remain.", "warning");
|
|
324
|
+
}
|
|
322
325
|
if (permissionProfile.mode === "bypass") {
|
|
323
326
|
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
327
|
}
|
|
@@ -8,7 +8,7 @@ const PUBLIC_REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
|
8
8
|
const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/;
|
|
9
9
|
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
10
10
|
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
11
|
-
const MAX_PENDING = 32, MAX_RECENT = 64, DEFAULT_TTL_MS = 5 * 60_000;
|
|
11
|
+
const MAX_PENDING = 32, MAX_RECENT = 64, DEFAULT_TTL_MS = 5 * 60_000, MAX_TTL_MS = 30 * 60_000;
|
|
12
12
|
|
|
13
13
|
type Identity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string;
|
|
14
14
|
agentOperationId: string; toolCallId: string };
|
|
@@ -161,7 +161,7 @@ export class PiApprovalBroker {
|
|
|
161
161
|
if (!binding || !authority || authority.taskState !== "active" || input.expectedTask && (authority.identity.taskId !== input.expectedTask.taskId || authority.identity.taskRunId !== input.expectedTask.taskRunId)
|
|
162
162
|
|| !REF.test(authority.identity.agentOperationId)
|
|
163
163
|
|| !toolCallId || binding.pending.size >= MAX_PENDING) return this.#unavailable(input);
|
|
164
|
-
const now = Date.now(), requestedAt = new Date(now).toISOString(), expiresAt = new Date(now + Math.min(Math.max(input.ttlMs ?? DEFAULT_TTL_MS, 5_000),
|
|
164
|
+
const now = Date.now(), requestedAt = new Date(now).toISOString(), expiresAt = new Date(now + Math.min(Math.max(input.ttlMs ?? DEFAULT_TTL_MS, 5_000), MAX_TTL_MS)).toISOString();
|
|
165
165
|
const approvalRef = ref("approval", binding.secret, [toolCallId, now, randomUUID()]);
|
|
166
166
|
const decisionToken = randomBytes(32).toString("base64url");
|
|
167
167
|
const nextRevision = this.#nextRevision(binding, "requested", approvalRef);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// What Always bypass asks about besides commit and push: an rm or rmdir whose
|
|
2
|
+
// target is a critical path, by the rules of Claude's bypassPermissions
|
|
3
|
+
// (permission-modes, "Critical paths" and "The mounted drive prompt"). A text
|
|
4
|
+
// check, not a boundary: other commands and scripts can delete these paths too.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { evaluateExecPolicyCore } from '../../extensions/policy-core.js';
|
|
8
|
+
import { astCommands } from '../../extensions/shell-ast.js';
|
|
9
|
+
import { executable } from './handoff-policy.mjs';
|
|
10
|
+
|
|
11
|
+
const TOP_LEVEL = new Set(['bin', 'boot', 'dev', 'etc', 'home', 'lib', 'lib64', 'media', 'mnt', 'opt', 'proc', 'root', 'run', 'sbin', 'srv', 'sys', 'tmp', 'usr', 'var', 'Applications', 'Library', 'System', 'Users', 'Volumes', 'private']);
|
|
12
|
+
const DIRECTORY_PRINTER = /(?:^|[\s;&|(])([A-Za-z_]\w*)=["']?(?:\$\(|`)\s*(?:pwd|git\s+rev-parse\s+--show-toplevel|dirname\b|realpath\b)/g;
|
|
13
|
+
const MOUNTED = [/^\/Volumes\/[^/]+(\/[^/]+)?$/, /^\/System\/Volumes(\/[^/]+){0,2}$/, /^\/media\/[^/]+(\/[^/]+){0,2}$/, /^\/run\/media(\/[^/]+){0,3}$/, /^\/mnt\/[^/]+$/, /^\/mnt\/[A-Za-z]\/[^/]+$/];
|
|
14
|
+
const DYNAMIC = /\$(?:\(…\)|[A-Za-z_@*#?!\d]\w*)/;
|
|
15
|
+
const onlyDynamic = /^\$(?:\(…\)|[A-Za-z_@*#?!\d]\w*)$/;
|
|
16
|
+
|
|
17
|
+
// On Windows the checks below see one spelling of a path: `D:\\a\\B`, `d:/a/b` and
|
|
18
|
+
// Git Bash's `/d/a/b` are all `/d:/a/b`.
|
|
19
|
+
function comparable(value) {
|
|
20
|
+
if (process.platform !== 'win32') return value;
|
|
21
|
+
return value.replace(/\\/g, '/').toLowerCase().replace(/^\/?([a-z]):?(?=\/|$)/, (_, drive) => `/${drive}:`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// `{/,}` is `/` by the time it runs.
|
|
25
|
+
function expanded(word) {
|
|
26
|
+
const brace = /^(.*?)\{([^{}]*,[^{}]*)\}(.*)$/.exec(word);
|
|
27
|
+
return brace ? brace[2].split(',').flatMap(part => expanded(brace[1] + part + brace[3])) : [word];
|
|
28
|
+
}
|
|
29
|
+
function parseTargets(args) {
|
|
30
|
+
const targets = []; let recursive = false, options = true;
|
|
31
|
+
for (const arg of args) {
|
|
32
|
+
if (options && arg === '--') options = false;
|
|
33
|
+
else if (options && arg.startsWith('-') && arg !== '-') recursive ||= arg === '--recursive' || (!arg.startsWith('--') && /[rR]/.test(arg));
|
|
34
|
+
else targets.push(arg);
|
|
35
|
+
}
|
|
36
|
+
return { targets, recursive };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isCritical(target, cwd, home) {
|
|
40
|
+
if (target === '/' || path.posix.dirname(target) === '/') return true;
|
|
41
|
+
return target === home || target === cwd || cwd.startsWith(`${target}/`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Why this one target needs the member, or null.
|
|
45
|
+
function targetReason(raw, { cwd, project, home, recursive, guarded, printers, singleQuotedScript, afterCd }) {
|
|
46
|
+
const DANGER = text => `Dangerous rm operation on critical path: ${text}`;
|
|
47
|
+
if (/^\\+$/.test(raw)) return DANGER(raw);
|
|
48
|
+
let word = raw.replace(/^~(?=\/|$)/, home).replace(/\$\{?HOME\}?(?=\/|$)/, home).replace(/\$\{?PWD\}?(?=\/|$)/, cwd);
|
|
49
|
+
if (/^~/.test(word)) return DANGER(raw);
|
|
50
|
+
for (const name of printers) if (new RegExp(`\\$${name}(?:\\b|/|$)`).test(word)) return `Dangerous rm operation: ${raw} comes from a directory-printing substitution`;
|
|
51
|
+
const dynamic = DYNAMIC.exec(word);
|
|
52
|
+
if (dynamic) {
|
|
53
|
+
const name = dynamic[0].slice(1), positional = /^[@*\d#]/.test(name);
|
|
54
|
+
if (onlyDynamic.test(word) && word.includes('(…)') && recursive) return `Dangerous rm operation: ${raw} is only the output of a command`;
|
|
55
|
+
const under = /^\$[^/]*\/(\*(\/\*)*\/?)?$/.test(word), glob = under && !(guarded.has(name) || (positional && singleQuotedScript));
|
|
56
|
+
if (glob) return `Dangerous rm operation: ${raw} removes from the filesystem root when the variable is empty`;
|
|
57
|
+
if (/^\$[^/]+\/[^/]+$/.test(word) && TOP_LEVEL.has(word.split('/')[1]) && !guarded.has(name)) return DANGER(`${raw} (/${word.split('/')[1]} when the variable is empty)`);
|
|
58
|
+
const before = word.slice(0, dynamic.index);
|
|
59
|
+
// A trailing substitution: the path that remains when it expands empty.
|
|
60
|
+
if (word.slice(dynamic.index) === dynamic[0] && before.startsWith('/') && isCritical(path.posix.resolve(comparable(before)), cwd, home)) return DANGER(raw);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const glob = /\/?\*\/?$/.test(word) || word === '*';
|
|
64
|
+
if (glob && (/\*\/(\*\/?)?$/.test(word) || (afterCd && !word.startsWith('/')))) return `Dangerous rm operation: ${raw} cannot be checked before the command runs`;
|
|
65
|
+
const literal = word.replace(/\/?\*$/, '') || (word.startsWith('/') ? '/' : '.');
|
|
66
|
+
const resolved = path.posix.resolve(cwd, comparable(literal));
|
|
67
|
+
if (isCritical(resolved, cwd, home) || isCritical(resolved, project, home)) return DANGER(raw);
|
|
68
|
+
if ((recursive || glob) && MOUNTED.some(rule => rule.test(resolved))) return `Dangerous rm operation on mounted drive path: ${raw}`;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Why this command must be asked about in Always bypass, or null.
|
|
73
|
+
export function criticalRemovalReason(command, options) {
|
|
74
|
+
const cwd = comparable(options.cwd), home = comparable(options.home);
|
|
75
|
+
const text = String(command), commands = astCommands(text);
|
|
76
|
+
if (commands === null) return /\b(rm|rmdir)\b/.test(text) ? 'Dangerous rm operation not ruled out: the shell may read this command differently than it was parsed' : null;
|
|
77
|
+
const guarded = new Set([...text.matchAll(/\$\{([A-Za-z_]\w*):\?/g)].map(match => match[1]));
|
|
78
|
+
const printers = new Set([...text.matchAll(DIRECTORY_PRINTER)].map(match => match[1]));
|
|
79
|
+
const singleQuotedScript = /\s-[a-z]*c\s+'/.test(text);
|
|
80
|
+
let base = cwd, afterCd = false;
|
|
81
|
+
for (const { words } of commands) {
|
|
82
|
+
const [name, ...args] = executable(words), program = String(name ?? '').split('/').pop();
|
|
83
|
+
if (program === 'cd') {
|
|
84
|
+
afterCd = true;
|
|
85
|
+
if (args[0] && !DYNAMIC.test(args[0])) base = path.posix.resolve(base, comparable(args[0].replace(/^~(?=\/|$)/, home)));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (program !== 'rm' && program !== 'rmdir') continue;
|
|
89
|
+
const { targets, recursive } = parseTargets(args);
|
|
90
|
+
for (const target of targets.flatMap(expanded)) {
|
|
91
|
+
const reason = targetReason(target, { cwd: base, project: cwd, home, recursive, guarded, printers, singleQuotedScript, afterCd });
|
|
92
|
+
if (reason) return reason;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A project's own refusals (profile `autoDeny`) in a company session, as a deny
|
|
99
|
+
// rule holds in every mode: the reason, or null.
|
|
100
|
+
export function projectDenyReason(command, cwd) {
|
|
101
|
+
let profile;
|
|
102
|
+
try { profile = JSON.parse(fs.readFileSync(path.join(cwd, '.pi', 'piagent-profile.json'), 'utf8')); } catch { return null; }
|
|
103
|
+
const rules = (Array.isArray(profile?.autoDeny) ? profile.autoDeny : []).filter(rule => typeof rule?.pattern === 'string' && rule.pattern.trim())
|
|
104
|
+
.map((rule, index) => ({ id: `auto-deny-${index + 1}`, action: 'forbid', match: rule.match ?? 'contains', value: rule.pattern, reason: String(rule.reason ?? "Refused by this project's autoDeny rule") }));
|
|
105
|
+
if (!rules.length) return null;
|
|
106
|
+
const refused = evaluateExecPolicyCore(String(command), { policy: { execPolicy: { defaultMode: 'enforce', bannedPrefixSuggestions: [], rules } }, mode: 'enforce' });
|
|
107
|
+
return refused.decision === 'forbid' ? refused.reasons[0] ?? 'Refused by this project' : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Claude's time limit: two minutes, then a denial that lets the work go on; after
|
|
111
|
+
// three unanswered prompts, denied at once until the member sends a message.
|
|
112
|
+
export const REMOVAL_TTL_MS = 120_000;
|
|
113
|
+
export const REMOVAL_EXPIRED = 'the member did not answer within two minutes, so this removal did not run. Carry on with the rest of the work: name the exact paths to remove instead, or leave the removal to the member and say so.';
|
|
114
|
+
const unanswered = new Map();
|
|
115
|
+
const memberMessages = manager => (manager?.getEntries?.() ?? []).filter(entry => entry.type === 'message' && entry.message?.role === 'user').length;
|
|
116
|
+
const current = (id, manager) => {
|
|
117
|
+
const turn = memberMessages(manager), held = unanswered.get(id);
|
|
118
|
+
if (held?.turn === turn) return held;
|
|
119
|
+
const fresh = { turn, count: 0 }; unanswered.set(id, fresh); return fresh;
|
|
120
|
+
};
|
|
121
|
+
export const removalsHeld = (id, manager) => current(id, manager).count >= 3;
|
|
122
|
+
export const noteRemovalExpired = (id, manager) => { current(id, manager).count += 1; };
|