@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/catalog/capabilities.json +2 -2
  3. package/docs/command-reference-vietnamese.md +5 -3
  4. package/docs/company-dev-commands.md +1 -1
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/herdr-workflow.md +30 -0
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/release-install-policy.md +2 -2
  10. package/docs/security-threat-model.md +2 -0
  11. package/docs/team-onboarding.md +2 -2
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/assets/search-en.json +1 -1
  14. package/docs-site/assets/search-vi.json +1 -1
  15. package/docs-site/benchmark.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/en/workflows.html +6 -0
  18. package/docs-site/content/workflows.html +6 -0
  19. package/docs-site/context-engine.html +1 -1
  20. package/docs-site/ecosystem.html +1 -1
  21. package/docs-site/en/architecture.html +1 -1
  22. package/docs-site/en/benchmark.html +1 -1
  23. package/docs-site/en/commands.html +1 -1
  24. package/docs-site/en/context-engine.html +1 -1
  25. package/docs-site/en/ecosystem.html +1 -1
  26. package/docs-site/en/index.html +15 -15
  27. package/docs-site/en/mcp-approval.html +1 -1
  28. package/docs-site/en/mcp-auth.html +1 -1
  29. package/docs-site/en/mcp-servers.html +1 -1
  30. package/docs-site/en/mcp.html +1 -1
  31. package/docs-site/en/permissions.html +1 -1
  32. package/docs-site/en/profiles.html +1 -1
  33. package/docs-site/en/quickstart.html +1 -1
  34. package/docs-site/en/runtime.html +1 -1
  35. package/docs-site/en/scope.html +1 -1
  36. package/docs-site/en/security.html +1 -1
  37. package/docs-site/en/team.html +1 -1
  38. package/docs-site/en/update.html +3 -3
  39. package/docs-site/en/whats-new.html +56 -26
  40. package/docs-site/en/windows.html +1 -1
  41. package/docs-site/en/workflows.html +7 -1
  42. package/docs-site/index.html +15 -15
  43. package/docs-site/mcp-approval.html +1 -1
  44. package/docs-site/mcp-auth.html +1 -1
  45. package/docs-site/mcp-servers.html +1 -1
  46. package/docs-site/mcp.html +1 -1
  47. package/docs-site/permissions.html +1 -1
  48. package/docs-site/profiles.html +1 -1
  49. package/docs-site/quickstart.html +1 -1
  50. package/docs-site/releases.json +102 -0
  51. package/docs-site/runtime.html +1 -1
  52. package/docs-site/scope.html +1 -1
  53. package/docs-site/security.html +1 -1
  54. package/docs-site/team.html +1 -1
  55. package/docs-site/update.html +3 -3
  56. package/docs-site/whats-new.html +56 -26
  57. package/docs-site/windows.html +1 -1
  58. package/docs-site/workflows.html +7 -1
  59. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  60. package/package.json +2 -1
  61. package/packages/piagent-core/extensions/fallback-base-policy.ts +1 -1
  62. package/packages/piagent-core/extensions/guard-types.ts +4 -1
  63. package/packages/piagent-core/{managed/herdr-reporter.mjs → extensions/herdr-reporter.js} +29 -13
  64. package/packages/piagent-core/extensions/piagent-guard.ts +20 -9
  65. package/packages/piagent-core/extensions/session-status.d.ts +1 -0
  66. package/packages/piagent-core/extensions/session-status.js +217 -0
  67. package/packages/piagent-core/managed/approval-refusal.mjs +9 -0
  68. package/packages/piagent-core/managed/check-evidence.mjs +134 -0
  69. package/packages/piagent-core/managed/check-questions.mjs +34 -16
  70. package/packages/piagent-core/managed/command-tools.mjs +52 -9
  71. package/packages/piagent-core/managed/dist/workspace/assets/packages/piagent-core/policies/base-policy.json +2 -1
  72. package/packages/piagent-core/managed/dist/workspace/manifest.json +1 -1
  73. package/packages/piagent-core/managed/fetch-guard.mjs +2 -1
  74. package/packages/piagent-core/managed/helper-roles.mjs +1 -1
  75. package/packages/piagent-core/managed/helper-run.mjs +6 -3
  76. package/packages/piagent-core/managed/host-command.mjs +8 -4
  77. package/packages/piagent-core/managed/ledger.mjs +5 -5
  78. package/packages/piagent-core/managed/member-mcp.mjs +2 -0
  79. package/packages/piagent-core/managed/memory-lessons.mjs +2 -2
  80. package/packages/piagent-core/managed/permission.mjs +11 -3
  81. package/packages/piagent-core/managed/repository-operation.mjs +2 -1
  82. package/packages/piagent-core/managed/session.mjs +5 -5
  83. package/packages/piagent-core/managed/tool-boundary.mjs +5 -5
  84. package/packages/piagent-core/managed/tool-failures.mjs +1 -1
  85. package/packages/piagent-core/managed/workflow.mjs +11 -8
  86. package/packages/piagent-core/package.json +1 -1
  87. package/packages/piagent-core/policies/base-policy.json +2 -1
  88. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
  89. package/packages/piagent-core/runtime/inspection/approval-broker.ts +2 -2
  90. package/packages/piagent-core/runtime/policy/critical-removal.mjs +122 -0
  91. package/packages/piagent-core/runtime/policy/handoff-policy.mjs +23 -4
  92. package/packages/piagent-core/runtime/registration/permission-commands.ts +14 -6
  93. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  94. package/packages/piagent-webui/dist/client/assets/{ActivityPanel-CmjxPmNT.js → ActivityPanel-DeeJLOYx.js} +1 -1
  95. package/packages/piagent-webui/dist/client/assets/App-C9tlGPMb.js +5 -0
  96. package/packages/piagent-webui/dist/client/assets/{GatewayApp-BmkqeXih.js → GatewayApp-Dvo96qoX.js} +17 -17
  97. package/packages/piagent-webui/dist/client/assets/{InspectionApp-9jBsgkVJ.js → InspectionApp-CGgHsOyC.js} +1 -1
  98. package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-RQ0N65d9.js → SessionAgentWorkspace-n8UMMcrP.js} +1 -1
  99. package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-BWyEN4bf.js → SessionInspectorDrawer-B62kkr8J.js} +2 -2
  100. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BZijmuPt.js +3 -0
  101. package/packages/piagent-webui/dist/client/assets/SettingsPage-DKg0SfZQ.js +1 -0
  102. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-DzHdSjpN.js → TerminalPanel-BPTVsNjP.js} +1 -1
  103. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-CEuPTTBd.js → WorkspaceCodeEditor-BHGFFBc2.js} +1 -1
  104. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-B_NwYf4S.js → WorkspaceExplorer-nd4kghwK.js} +3 -3
  105. package/packages/piagent-webui/dist/client/assets/{index-fUpioGMZ.js → index-BiLVX8S-.js} +2 -2
  106. package/packages/piagent-webui/dist/client/assets/{transcript-view-model-acWtHGyE.js → transcript-view-model-BB1niiXd.js} +1 -1
  107. package/packages/piagent-webui/dist/client/assets/{view-model-CWNiS4JN.js → view-model-BrdSO09E.js} +1 -1
  108. package/packages/piagent-webui/dist/client/index.html +1 -1
  109. package/packages/piagent-webui/gateway/managed-projection.ts +1 -1
  110. package/packages/piagent-webui/gateway/runtime-session-controls.ts +1 -1
  111. package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
  112. package/packages/piagent-webui/gateway/session-inspection-registry.ts +1 -1
  113. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +1 -1
  114. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  115. package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
  116. package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
  117. package/schemas/project-profile.schema.json +2 -1
  118. package/scripts/company-terminal.mjs +8 -7
  119. package/scripts/pi-auto.sh +5 -1
  120. package/scripts/piagent-cli.mjs +3 -1
  121. package/scripts/piagent-sessions.mjs +97 -0
  122. package/scripts/piagent-studio.mjs +5 -3
  123. package/packages/piagent-webui/dist/client/assets/App-JrRb7jgn.js +0 -5
  124. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-Wx-q5QQk.js +0 -3
  125. package/packages/piagent-webui/dist/client/assets/SettingsPage-xAt6kSyZ.js +0 -1
@@ -94,7 +94,8 @@
94
94
  "read-only",
95
95
  "workspace-write",
96
96
  "trusted-full-access",
97
- "bypass"
97
+ "bypass",
98
+ "always-bypass"
98
99
  ]
99
100
  },
100
101
  "execPolicy": {
@@ -15,7 +15,7 @@
15
15
  },
16
16
  {
17
17
  "file": "assets/packages/piagent-core/policies/base-policy.json",
18
- "sha256": "422b26bca154240747970d948cdf1ded57b9dcb544d6547dedb4d1ddfda4b4da"
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 login = shell(), user = os.userInfo().username;
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 || run.decideAsked) return false;
138
- run.decideAsked = true;
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
- import { handoffReason, mustConfirm } from '../runtime/policy/handoff-policy.mjs';
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 broadAccess = owner => ['trusted-full-access', 'bypass'].includes(effectivePermission(owner));
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 Error('managed-operation-denied');
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, decideAsked: 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
- async afterTool(name, args, ok, digestOf) {
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
- // The member's "run it all" covers one run; after a fix they are asked again.
208
- if (scope === 'suite' && this.scopeChoice === 'full') this.scopeChoice = null;
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.49.1",
3
+ "version": "1.51.0",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -94,7 +94,8 @@
94
94
  "read-only",
95
95
  "workspace-write",
96
96
  "trusted-full-access",
97
- "bypass"
97
+ "bypass",
98
+ "always-bypass"
98
99
  ]
99
100
  },
100
101
  "execPolicy": {
@@ -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), DEFAULT_TTL_MS)).toISOString();
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; };