@piagent/platform 1.46.4 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +16 -3
  3. package/README.vi.md +16 -3
  4. package/docs/command-reference-vietnamese.md +4 -4
  5. package/docs/company-dev-commands.md +6 -5
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/languages.json +2 -2
  8. package/docs/mcp-and-tools.md +21 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/security-threat-model.md +3 -1
  13. package/docs/team-onboarding.md +2 -2
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/assets/search-en.json +1 -1
  16. package/docs-site/assets/search-vi.json +1 -1
  17. package/docs-site/benchmark.html +1 -1
  18. package/docs-site/commands.html +1 -1
  19. package/docs-site/content/en/mcp.html +8 -0
  20. package/docs-site/content/mcp.html +8 -0
  21. package/docs-site/context-engine.html +1 -1
  22. package/docs-site/ecosystem.html +1 -1
  23. package/docs-site/en/architecture.html +1 -1
  24. package/docs-site/en/benchmark.html +1 -1
  25. package/docs-site/en/commands.html +1 -1
  26. package/docs-site/en/context-engine.html +1 -1
  27. package/docs-site/en/ecosystem.html +1 -1
  28. package/docs-site/en/index.html +15 -15
  29. package/docs-site/en/mcp-approval.html +1 -1
  30. package/docs-site/en/mcp-auth.html +1 -1
  31. package/docs-site/en/mcp-servers.html +1 -1
  32. package/docs-site/en/mcp.html +9 -1
  33. package/docs-site/en/permissions.html +1 -1
  34. package/docs-site/en/profiles.html +1 -1
  35. package/docs-site/en/quickstart.html +1 -1
  36. package/docs-site/en/runtime.html +1 -1
  37. package/docs-site/en/scope.html +1 -1
  38. package/docs-site/en/security.html +1 -1
  39. package/docs-site/en/team.html +1 -1
  40. package/docs-site/en/update.html +3 -3
  41. package/docs-site/en/whats-new.html +59 -32
  42. package/docs-site/en/windows.html +1 -1
  43. package/docs-site/en/workflows.html +1 -1
  44. package/docs-site/index.html +15 -15
  45. package/docs-site/mcp-approval.html +1 -1
  46. package/docs-site/mcp-auth.html +1 -1
  47. package/docs-site/mcp-servers.html +1 -1
  48. package/docs-site/mcp.html +9 -1
  49. package/docs-site/permissions.html +1 -1
  50. package/docs-site/profiles.html +1 -1
  51. package/docs-site/quickstart.html +1 -1
  52. package/docs-site/releases.json +87 -0
  53. package/docs-site/runtime.html +1 -1
  54. package/docs-site/scope.html +1 -1
  55. package/docs-site/security.html +1 -1
  56. package/docs-site/team.html +1 -1
  57. package/docs-site/update.html +3 -3
  58. package/docs-site/whats-new.html +59 -32
  59. package/docs-site/windows.html +1 -1
  60. package/docs-site/workflows.html +1 -1
  61. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  62. package/package.json +2 -1
  63. package/packages/piagent-core/extensions/guard-types.ts +10 -2
  64. package/packages/piagent-core/extensions/piagent-guard.ts +106 -169
  65. package/packages/piagent-core/extensions/policy-core.js +7 -7
  66. package/packages/piagent-core/extensions/shell-ast.js +85 -0
  67. package/packages/piagent-core/extensions/shell-command-words.js +19 -0
  68. package/packages/piagent-core/managed/command-tools.mjs +70 -26
  69. package/packages/piagent-core/managed/fetch-guard.mjs +3 -3
  70. package/packages/piagent-core/managed/host-command.mjs +15 -7
  71. package/packages/piagent-core/managed/member-mcp.mjs +96 -0
  72. package/packages/piagent-core/managed/memory-lessons.mjs +1 -1
  73. package/packages/piagent-core/managed/permission.mjs +19 -12
  74. package/packages/piagent-core/managed/repository-operation.mjs +1 -1
  75. package/packages/piagent-core/managed/resource-loader.mjs +19 -7
  76. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +4 -0
  77. package/packages/piagent-core/managed/session.mjs +4 -4
  78. package/packages/piagent-core/managed/simulator-commands.mjs +117 -0
  79. package/packages/piagent-core/mcp/mcp-server-catalog.js +18 -1
  80. package/packages/piagent-core/package.json +1 -1
  81. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +1 -1
  82. package/packages/piagent-core/runtime/inspection/approval-broker.ts +33 -16
  83. package/packages/piagent-core/runtime/mcp-repository-gate.d.mts +10 -0
  84. package/packages/piagent-core/runtime/mcp-repository-gate.mjs +150 -0
  85. package/packages/piagent-core/runtime/policy/handoff-policy.mjs +144 -0
  86. package/packages/piagent-core/runtime/registration/permission-commands.ts +2 -2
  87. package/packages/piagent-webui/dist/client/assets/{App-CiwY9VpU.js → App-fZeIRoaD.js} +1 -1
  88. package/packages/piagent-webui/dist/client/assets/{GatewayApp-DrNJW3v_.js → GatewayApp-CpC4lgYK.js} +17 -15
  89. package/packages/piagent-webui/dist/client/assets/{InspectionApp-BBiwSaV0.js → InspectionApp-pD-BqBXm.js} +1 -1
  90. package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DwyPebIX.js → SessionAgentWorkspace-3tM7Q6HH.js} +1 -1
  91. package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CpVzaf_G.js → SessionInspectorDrawer-BJ94aQ6y.js} +2 -2
  92. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BFfqMcuL.js +3 -0
  93. package/packages/piagent-webui/dist/client/assets/SettingsPage-CxpZ7cdM.js +1 -0
  94. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-B8j_r6-y.js → TerminalPanel-CtZyb0tj.js} +1 -1
  95. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-BDTPvq-0.js → WorkspaceCodeEditor-BS_ZrjPN.js} +1 -1
  96. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DVsNU0eu.js → WorkspaceExplorer-CkFBpcM7.js} +3 -3
  97. package/packages/piagent-webui/dist/client/assets/{index-uhJMXxqD.js → index-CNdhIouM.js} +2 -2
  98. package/packages/piagent-webui/dist/client/assets/{index-Dqb2hNan.css → index-CjM70M7D.css} +1 -1
  99. package/packages/piagent-webui/dist/client/assets/transcript-view-model-CpEMY1xb.js +3 -0
  100. package/packages/piagent-webui/dist/client/index.html +2 -2
  101. package/packages/piagent-webui/gateway/gateway-events.ts +5 -1
  102. package/packages/piagent-webui/server/gateway-websocket.ts +5 -1
  103. package/schemas/piagent-webui/approval-v1.schema.json +3 -1
  104. package/schemas/project-profile.schema.json +16 -0
  105. package/scripts/company-terminal.mjs +5 -5
  106. package/scripts/setup.sh +1 -1
  107. package/scripts/verify-release-identity.mjs +1 -1
  108. package/packages/piagent-core/runtime/policy/bypass-policy.mjs +0 -42
  109. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +0 -3
  110. package/packages/piagent-webui/dist/client/assets/SettingsPage-Ca-qRSZa.js +0 -1
  111. package/packages/piagent-webui/dist/client/assets/transcript-view-model-Bq0nkxyu.js +0 -3
@@ -37,7 +37,7 @@
37
37
  <span class="brand-mark"><img src="/assets/piagent-logo.svg" alt="" width="28" height="28" /></span>
38
38
  <span class="brand-name"><b>Piagent</b> <span>Platform</span></span>
39
39
  </a>
40
- <a class="version-badge" href="/whats-new" data-docs-version="v1.46.4" title="v1.46.4 docs · VI"><span class="dot"></span>v1.46.4</a>
40
+ <a class="version-badge" href="/whats-new" data-docs-version="v1.48.0" title="v1.48.0 docs · VI"><span class="dot"></span>v1.48.0</a>
41
41
  <nav class="top-nav" aria-label="Tài liệu">
42
42
  <a href="/quickstart">Tài liệu</a>
43
43
  <a href="/whats-new" aria-current="page">Changelog</a>
@@ -108,7 +108,36 @@
108
108
  <div class="release-filter" role="toolbar" aria-label="Lọc theo loại thay đổi"><button type="button" data-filter-kind="all" aria-pressed="true">Tất cả</button><button type="button" data-filter-kind="new" aria-pressed="false">Mới</button><button type="button" data-filter-kind="improved" aria-pressed="false">Cải thiện</button><button type="button" data-filter-kind="fixed" aria-pressed="false">Sửa lỗi</button><button type="button" data-filter-kind="security" aria-pressed="false">Bảo mật</button></div>
109
109
  <section class="section releases" data-releases>
110
110
  <h2 id="latest" class="sr-only">Mới nhất</h2>
111
- <article class="release latest" id="v1-46-4" data-release>
111
+ <article class="release latest" id="v1-48-0" data-release>
112
+ <div class="release-meta"><a class="release-version" href="#v1-48-0">v1.48.0</a><time class="release-date" datetime="2026-10-30">30/10/2026</time></div>
113
+ <div class="release-body">
114
+ <h3 data-toc-skip id="v1-48-0-title">Bypass dừng ở bước bàn giao: mọi chế độ hỏi trước khi việc rời khỏi máy</h3>
115
+ <p>Push, pull request, publish và deploy luôn do bạn duyệt, lệnh được kiểm tra bằng cây cú pháp shell đầy đủ, thẻ duyệt có thêm lựa chọn cho cả session và dừng lượt, dashboard stream mượt hơn.</p>
116
+ <ul class="release-items">
117
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Thẻ duyệt có bốn lựa chọn: cho phép một lần, cho lệnh giống hệt chạy cả session, từ chối, và từ chối rồi dừng lượt. Việc bàn giao luôn duyệt từng lần.</span></li>
118
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Project có thể chặn hẳn lệnh bằng autoDeny trong .pi/piagent-profile.json. Luật áp dụng ở mọi chế độ, kể cả Bypass; terminate kết thúc luôn lượt của agent.</span></li>
119
+ <li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Dashboard mượt hơn: câu trả lời đang stream chỉ vẽ lại đoạn cuối, sidebar chỉ vẽ lại dòng thay đổi, kết nối bị rớt được phát hiện và nối lại sớm hơn.</span></li>
120
+ <li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Thẻ duyệt, dashboard, terminal và tài liệu mô tả đúng cách từng chế độ truy cập hoạt động; phiên cá nhân không còn bị ghi là chạy trong sandbox.</span></li>
121
+ <li data-kind="security"><span class="kind kind-security">Bảo mật</span><span>Mọi chế độ truy cập đều hỏi trước khi việc rời khỏi máy: git push dù viết kiểu nào, pull request và merge, release, publish, deploy, push registry, ssh và tool cloud, upload.</span></li>
122
+ <li data-kind="security"><span class="kind kind-security">Bảo mật</span><span>Bypass chạy việc không hỏi và dừng ở bước bàn giao, ở cả phiên cá nhân lẫn công ty, kể cả run_on_host dùng tài khoản của bạn và công cụ MCP ghi ra ngoài.</span></li>
123
+ <li data-kind="security"><span class="kind kind-security">Bảo mật</span><span>Lệnh được đọc bằng bộ quét của policy và cây cú pháp shell: push hay lệnh xoá nguy hiểm nằm trong if/then, function, case, bash -c, eval hay heredoc đều bị bắt.</span></li>
124
+ </ul>
125
+ </div>
126
+ </article>
127
+ <article class="release" id="v1-47-0" data-release>
128
+ <div class="release-meta"><a class="release-version" href="#v1-47-0">v1.47.0</a><time class="release-date" datetime="2026-10-29">29/10/2026</time></div>
129
+ <div class="release-body">
130
+ <h3 data-toc-skip id="v1-47-0-title">Phiên công ty dùng MCP server của bạn và điều khiển simulator iOS, Android</h3>
131
+ <p>Jira, GitHub và mọi MCP server bạn cài cho Piagent giờ dùng được trong phiên công ty, và agent tự build, chạy, chụp màn hình và test app mobile trên simulator.</p>
132
+ <ul class="release-items">
133
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Phiên công ty nạp các MCP server đã cài cho Piagent cá nhân, dùng chung login. Server do repo khai báo vẫn cần duyệt; kết quả tới agent dưới dạng dữ liệu.</span></li>
134
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Preset atlassian thêm Jira và Confluence Cloud: piagent-mcp --preset atlassian, rồi /mcp-auth atlassian bằng tài khoản Atlassian của bạn.</span></li>
135
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>run_on_simulator boot simulator, build, cài và mở app, chạy XCUITest, chụp màn hình, đọc log: xcrun simctl, xcodebuild, adb, emulator, gradlew, flutter, expo.</span></li>
136
+ <li data-kind="security"><span class="kind kind-security">Bảo mật</span><span>run_on_simulator từ chối chương trình khác, ;, &amp;, $( ) và file ngoài project. Full access và Bypass chạy luôn, nhưng vẫn hỏi khi lệnh đụng tới credentials.</span></li>
137
+ </ul>
138
+ </div>
139
+ </article>
140
+ <article class="release" id="v1-46-4" data-release>
112
141
  <div class="release-meta"><a class="release-version" href="#v1-46-4">v1.46.4</a><time class="release-date" datetime="2026-10-29">29/10/2026</time></div>
113
142
  <div class="release-body">
114
143
  <h3 data-toc-skip id="v1-46-4-title">Một subagent thay vì bị chặn; bản cập nhật đọc được models.json có comment</h3>
@@ -390,41 +419,39 @@
390
419
  </ul>
391
420
  </div>
392
421
  </article>
393
- <article class="release" id="v1-42-0" data-release>
394
- <div class="release-meta"><a class="release-version" href="#v1-42-0">v1.42.0</a><time class="release-date" datetime="2026-10-25">25/10/2026</time></div>
395
- <div class="release-body">
396
- <h3 data-toc-skip id="v1-42-0-title">Dashboard thiết kế lại, tin cũ tải mà không nhảy trang</h3>
397
- <p>Dashboard có một thang thiết kế và mọi theme tô tới mọi khung; tin cũ tự tải khi cuộn lên và tin bạn đang đọc đứng yên.</p>
398
- <ul class="release-items">
399
- <li data-kind="new"><span class="kind kind-new">Mới</span><span>Tin cũ tự tải khi cuộn gần đầu, và tin đang đọc không còn bị đẩy đi khi có trang mới chèn phía trên.</span></li>
400
- <li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Cả 13 theme giờ tô tới diff, thẻ duyệt, câu hỏi và tài liệu; header, sidebar, ô soạn tin và trang tạo mới được thiết kế lại.</span></li>
401
- <li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Một lượt đang chạy chỉ có một hiệu ứng chạy: spinner của bước, con trỏ khi chữ đang về, hoặc spinner khi agent đang nghĩ.</span></li>
402
- <li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Website và README mới, có hướng dẫn Cập nhật và gỡ; ghi chú phát hành giờ từ một file duy nhất, file này sinh luôn <code>CHANGELOG.md</code>.</span></li>
403
- <li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Lịch sử từng hiện markdown thô rồi co lại; giờ hiện đúng định dạng ngay từ đầu.</span></li>
404
- <li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Tải nhiều trang tin cũ từng làm mất tin mới nhất mà không báo; cửa sổ giờ giữ 1.000 mục và có nút về tin mới nhất.</span></li>
405
- </ul>
406
- </div>
407
- </article>
408
- <article class="release" id="v1-41-0" data-release>
409
- <div class="release-meta"><a class="release-version" href="#v1-41-0">v1.41.0</a><time class="release-date" datetime="2026-10-24">24/10/2026</time></div>
410
- <div class="release-body">
411
- <h3 data-toc-skip id="v1-41-0-title">Báo cáo thay đổi giao diện với ảnh trước/sau</h3>
412
- <p>Skill mới biến task front-end thành báo cáo có ảnh trước/sau được khoanh khung, thay đổi business và logic, kết quả check, hiện ngay trên dashboard.</p>
413
- <ul class="release-items">
414
- <li data-kind="new"><span class="kind kind-new">Mới</span><span>Skill <code>fe-change-report</code> chụp trang cũ và mới ở chế độ headless trên cùng dữ liệu, khoanh từng thay đổi bằng khung đỏ đánh số trên cả hai ảnh.</span></li>
415
- <li data-kind="new"><span class="kind kind-new">Mới</span><span>Agent chạy check của project và toàn bộ E2E headless, ghi rõ lỗi có từ trước, rồi viết lại thay đổi về business, logic và giao diện.</span></li>
416
- <li data-kind="new"><span class="kind kind-new">Mới</span><span>Tài liệu trên dashboard vẽ <code>reports/fe-change/&lt;slug&gt;/report.json</code> thành báo cáo, có phóng to từng khung, kích thước màn hình khác và toàn màn hình.</span></li>
417
- <li data-kind="new"><span class="kind kind-new">Mới</span><span>Script dùng Playwright có sẵn của project, không thêm dependency, và xuất một file <code>report.html</code> độc lập, không có script, để gửi cho bất kỳ ai.</span></li>
418
- <li data-kind="security"><span class="kind kind-security">Bảo mật</span><span>Báo cáo được vẽ từ dữ liệu, không từ file HTML, và chỉ ảnh PNG của nó được trả về, nên dashboard vẫn không hiển thị markup do model tạo.</span></li>
419
- </ul>
420
- </div>
421
- </article>
422
422
  </section>
423
423
  <section class="section archive">
424
424
  <h2 id="archive">Các bản trước</h2>
425
425
  <p class="section-lead">Bản cũ hơn, gom theo tháng. Mở một tháng để xem từng thay đổi.</p>
426
426
  <details class="release-archive">
427
- <summary><span>Tháng 10/2026</span><span class="archive-count">61</span></summary>
427
+ <summary><span>Tháng 10/2026</span><span class="archive-count">63</span></summary>
428
+ <div class="archive-row" id="v1-42-0">
429
+ <div class="archive-head"><a class="release-version" href="#v1-42-0">v1.42.0</a> <span class="release-date">25/10/2026</span></div>
430
+ <div class="archive-body">
431
+ <p><strong>Dashboard thiết kế lại, tin cũ tải mà không nhảy trang</strong> — Dashboard có một thang thiết kế và mọi theme tô tới mọi khung; tin cũ tự tải khi cuộn lên và tin bạn đang đọc đứng yên.</p>
432
+ <ul class="release-items">
433
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Tin cũ tự tải khi cuộn gần đầu, và tin đang đọc không còn bị đẩy đi khi có trang mới chèn phía trên.</span></li>
434
+ <li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Cả 13 theme giờ tô tới diff, thẻ duyệt, câu hỏi và tài liệu; header, sidebar, ô soạn tin và trang tạo mới được thiết kế lại.</span></li>
435
+ <li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Một lượt đang chạy chỉ có một hiệu ứng chạy: spinner của bước, con trỏ khi chữ đang về, hoặc spinner khi agent đang nghĩ.</span></li>
436
+ <li data-kind="improved"><span class="kind kind-improved">Cải thiện</span><span>Website và README mới, có hướng dẫn Cập nhật và gỡ; ghi chú phát hành giờ từ một file duy nhất, file này sinh luôn <code>CHANGELOG.md</code>.</span></li>
437
+ <li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Lịch sử từng hiện markdown thô rồi co lại; giờ hiện đúng định dạng ngay từ đầu.</span></li>
438
+ <li data-kind="fixed"><span class="kind kind-fixed">Sửa lỗi</span><span>Tải nhiều trang tin cũ từng làm mất tin mới nhất mà không báo; cửa sổ giờ giữ 1.000 mục và có nút về tin mới nhất.</span></li>
439
+ </ul>
440
+ </div>
441
+ </div>
442
+ <div class="archive-row" id="v1-41-0">
443
+ <div class="archive-head"><a class="release-version" href="#v1-41-0">v1.41.0</a> <span class="release-date">24/10/2026</span></div>
444
+ <div class="archive-body">
445
+ <p><strong>Báo cáo thay đổi giao diện với ảnh trước/sau</strong> — Skill mới biến task front-end thành báo cáo có ảnh trước/sau được khoanh khung, thay đổi business và logic, kết quả check, hiện ngay trên dashboard.</p>
446
+ <ul class="release-items">
447
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Skill <code>fe-change-report</code> chụp trang cũ và mới ở chế độ headless trên cùng dữ liệu, khoanh từng thay đổi bằng khung đỏ đánh số trên cả hai ảnh.</span></li>
448
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Agent chạy check của project và toàn bộ E2E headless, ghi rõ lỗi có từ trước, rồi viết lại thay đổi về business, logic và giao diện.</span></li>
449
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Tài liệu trên dashboard vẽ <code>reports/fe-change/&lt;slug&gt;/report.json</code> thành báo cáo, có phóng to từng khung, kích thước màn hình khác và toàn màn hình.</span></li>
450
+ <li data-kind="new"><span class="kind kind-new">Mới</span><span>Script dùng Playwright có sẵn của project, không thêm dependency, và xuất một file <code>report.html</code> độc lập, không có script, để gửi cho bất kỳ ai.</span></li>
451
+ <li data-kind="security"><span class="kind kind-security">Bảo mật</span><span>Báo cáo được vẽ từ dữ liệu, không từ file HTML, và chỉ ảnh PNG của nó được trả về, nên dashboard vẫn không hiển thị markup do model tạo.</span></li>
452
+ </ul>
453
+ </div>
454
+ </div>
428
455
  <div class="archive-row" id="v1-40-0">
429
456
  <div class="archive-head"><a class="release-version" href="#v1-40-0">v1.40.0</a> <span class="release-date">24/10/2026</span></div>
430
457
  <div class="archive-body">
@@ -37,7 +37,7 @@
37
37
  <span class="brand-mark"><img src="/assets/piagent-logo.svg" alt="" width="28" height="28" /></span>
38
38
  <span class="brand-name"><b>Piagent</b> <span>Platform</span></span>
39
39
  </a>
40
- <a class="version-badge" href="/whats-new" data-docs-version="v1.46.4" title="v1.46.4 docs · VI"><span class="dot"></span>v1.46.4</a>
40
+ <a class="version-badge" href="/whats-new" data-docs-version="v1.48.0" title="v1.48.0 docs · VI"><span class="dot"></span>v1.48.0</a>
41
41
  <nav class="top-nav" aria-label="Tài liệu">
42
42
  <a href="/quickstart">Tài liệu</a>
43
43
  <a href="/whats-new">Changelog</a>
@@ -37,7 +37,7 @@
37
37
  <span class="brand-mark"><img src="/assets/piagent-logo.svg" alt="" width="28" height="28" /></span>
38
38
  <span class="brand-name"><b>Piagent</b> <span>Platform</span></span>
39
39
  </a>
40
- <a class="version-badge" href="/whats-new" data-docs-version="v1.46.4" title="v1.46.4 docs · VI"><span class="dot"></span>v1.46.4</a>
40
+ <a class="version-badge" href="/whats-new" data-docs-version="v1.48.0" title="v1.48.0 docs · VI"><span class="dot"></span>v1.48.0</a>
41
41
  <nav class="top-nav" aria-label="Tài liệu">
42
42
  <a href="/quickstart">Tài liệu</a>
43
43
  <a href="/whats-new">Changelog</a>
@@ -256,8 +256,8 @@
256
256
  },
257
257
  {
258
258
  "path": "tests",
259
- "fileCount": 636,
260
- "sha256": "7d8a3af48dd9179f3bf3921cae65d3e6d18d17ff87d3454c29247f8cf1b34b63"
259
+ "fileCount": 641,
260
+ "sha256": "35835601b983facf21131404df71c12f622503a45ddfcdc1869610022e7bb101"
261
261
  }
262
262
  ],
263
263
  "custodianComparisonRules": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/platform",
3
- "version": "1.46.4",
3
+ "version": "1.48.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -141,6 +141,7 @@
141
141
  }
142
142
  },
143
143
  "dependencies": {
144
+ "@aliou/sh": "0.3.4",
144
145
  "@babel/parser": "7.29.8",
145
146
  "ws": "8.21.3"
146
147
  },
@@ -29,6 +29,7 @@ export type ProjectProfile = {
29
29
  memory?: MemorySettings;
30
30
  contextIndex?: ContextIndexSettings;
31
31
  runtimePolicy?: RuntimePolicySettings;
32
+ autoDeny?: AutoDenyRule[];
32
33
  orchestration?: OrchestrationPolicySettings;
33
34
  techStack?: ProjectTechStackReference;
34
35
  };
@@ -322,8 +323,9 @@ export type ResolvedOrchestrationPolicy = {
322
323
  rules: string[];
323
324
  };
324
325
 
325
- // "bypass": personal sessions only; no Piagent gate asks or blocks (as Codex's
326
- // --dangerously-bypass-approvals-and-sandbox). Output redaction and turn checkpoints still run.
326
+ // "bypass": personal sessions only; no Piagent gate asks or blocks the work. The
327
+ // handoff (runtime/policy/handoff-policy.mjs) is still asked; output redaction
328
+ // and turn checkpoints still run.
327
329
  export type PermissionProfileMode = "read-only" | "workspace-write" | "trusted-full-access" | "bypass";
328
330
 
329
331
  export type PermissionProfilesConfig = {
@@ -459,8 +461,14 @@ export type CommandRule = {
459
461
  ignoreSearchArguments?: boolean;
460
462
  value: string | string[];
461
463
  reason: string;
464
+ // A forbid that also ends the agent's turn, so it does not retry.
465
+ terminate?: boolean;
462
466
  };
463
467
 
468
+ // A project's own refusals (profile `autoDeny`): matched like an exec rule,
469
+ // refused in every permission mode, Bypass included.
470
+ export type AutoDenyRule = { pattern: string; match?: "contains" | "regex" | "prefix"; reason: string; terminate?: boolean };
471
+
464
472
  export type ExecPolicyConfig = {
465
473
  defaultMode?: "advisory" | "enforce";
466
474
  bannedPrefixSuggestions?: string[][];
@@ -38,15 +38,8 @@ import {
38
38
  import { detectProfileName } from "./project-shape.js";
39
39
  import { evaluateUpdateCheck, isInstalledPlatform, readUpdateCache, startUpdateProbe } from "./update-check.js";
40
40
  import { dormantInPersonalPi, personalPiAgentDir } from "./agent-home.js";
41
- import {
42
- REPOSITORY_SCOPES,
43
- collectServers,
44
- configPathForScope,
45
- mcpDecisionInputs,
46
- unverifiableMcpConfig
47
- } from "../mcp/mcp-config-layers.js";
48
- import { attributeDirectTool } from "../mcp/mcp-tool-naming.js";
49
- import { approvalState } from "../mcp/mcp-approval-store.js";
41
+ import { collectServers } from "../mcp/mcp-config-layers.js";
42
+ import { evaluateMcpApproval, mcpApprovalCache } from "../runtime/mcp-repository-gate.mjs";
50
43
  import { evaluateServerReadiness, readinessNotice } from "../mcp/mcp-auth-readiness.js";
51
44
  import * as mcpActions from "../mcp/mcp-command-actions.js";
52
45
  import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
@@ -93,8 +86,9 @@ import { currentWorkspaceRevisionDigest } from "./workspace-revision.js";
93
86
  import { appendJsonlBounded } from "./state-retention.js";
94
87
  import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
95
88
  import { hasDurableContextEvidence } from "./context-evidence.js";
96
- import { piApprovalBroker, type ApprovalActionDraft, type ApprovalUnavailableFallback } from "../runtime/inspection/approval-broker.ts";
89
+ import { piApprovalBroker, type ApprovalActionDraft, type ApprovalChoice, type ApprovalUnavailableFallback } from "../runtime/inspection/approval-broker.ts";
97
90
  import { createSourceMutationGuardBindings } from "../runtime/policy/source-mutation-guard-binding.ts";
91
+ import { FULL_ACCESS_RUNS, handoffReason, mcpWriteAction, treeExecFinding } from "../runtime/policy/handoff-policy.mjs";
98
92
  import { createWorkspaceWriteAuthorityBindings } from "../runtime/policy/workspace-write-authority.ts";
99
93
  import { registerWorkspaceMemberEdits } from "../runtime/session/workspace-member-edits.mjs";
100
94
  import {
@@ -213,6 +207,7 @@ import type {
213
207
  ResolvedPermissionProfile,
214
208
  ReviewLens,
215
209
  RuntimePolicySettings,
210
+ CommandRule,
216
211
  TaskContract,
217
212
  TechContextSnapshot,
218
213
  TechOption,
@@ -561,8 +556,6 @@ function verifyProjectCapabilityState(
561
556
  }
562
557
  }
563
558
 
564
- // Exec-policy prompts a full-access session does not show: local, reversible.
565
- const FULL_ACCESS_SKIPPED_PROMPTS = ["prompt-git-add-broad"];
566
559
 
567
560
  function evaluateExecPolicy(command: string, profile: ProjectProfile, policy: BasePolicy): {
568
561
  mode: RuntimePolicySettings["execPolicy"];
@@ -571,7 +564,7 @@ function evaluateExecPolicy(command: string, profile: ProjectProfile, policy: Ba
571
564
  segments: Array<{ command: string; words: string[]; matches: string[]; warnings: string[] }>;
572
565
  } {
573
566
  const runtime = resolveRuntimePolicy(profile);
574
- const execPolicy = execPolicyConfig(policy);
567
+ const execPolicy = { ...execPolicyConfig(policy), rules: [...execPolicyConfig(policy).rules, ...autoDenyRules(profile)] };
575
568
  const mode = runtime.execPolicy === "off" ? "off" : runtime.execPolicy ?? execPolicy.defaultMode;
576
569
  return evaluateExecPolicyCore(command, { policy: { ...policy, execPolicy }, mode }) as {
577
570
  mode: RuntimePolicySettings["execPolicy"];
@@ -930,6 +923,36 @@ function approvalUnavailableFallback(ctx: Pick<ExtensionContext, "ui">): Approva
930
923
  }
931
924
  }
932
925
 
926
+ // A project's own refusals (profile `autoDeny`) as exec-policy forbid rules.
927
+ function autoDenyRules(profile: ProjectProfile): CommandRule[] {
928
+ return (Array.isArray(profile.autoDeny) ? profile.autoDeny : []).filter((rule) => typeof rule?.pattern === "string" && rule.pattern.trim())
929
+ .map((rule, index) => ({ id: `auto-deny-${index + 1}`, action: "forbid" as const, match: rule.match ?? "contains", value: rule.pattern,
930
+ reason: String(rule.reason ?? "Refused by this project's autoDeny rule"), terminate: rule.terminate === true }));
931
+ }
932
+ // The block for a refused command; a rule with terminate also ends the turn.
933
+ function execRefusal(decision: { reasons: string[]; segments: Array<{ matches: string[] }> }): { block: true; reason: string; terminate?: boolean } {
934
+ const terminate = decision.segments.some((segment) => segment.matches.some((match) => match.startsWith("terminate:")));
935
+ return { block: true, reason: decision.reasons.join("; ") + (terminate ? " Do not retry; the turn ends here." : ""), ...(terminate ? { terminate: true } : {}) };
936
+ }
937
+
938
+ // The member's four answers in the terminal (Pi's TUI). Elsewhere the
939
+ // terminal keeps its yes/no question and the dashboard card offers the four.
940
+ const TERMINAL_CHOICES: Record<ApprovalChoice, string> = { once: "Allow once", session: "Allow this exact action for the rest of the session",
941
+ deny: "Deny", stop: "Deny and stop the turn" };
942
+ function terminalChooser(ctx: ExtensionContext, title: string, prompt: string, grantable: boolean): (() => Promise<ApprovalChoice>) | undefined {
943
+ if (ctx.mode !== "tui" || !ctx.hasUI || typeof ctx.ui.select !== "function") return undefined;
944
+ const choices = (["once", ...(grantable ? ["session"] : []), "deny", "stop"] as ApprovalChoice[]);
945
+ return async () => {
946
+ const picked = await ctx.ui.select(`${title}\n\n${prompt}`, choices.map((choice) => TERMINAL_CHOICES[choice]));
947
+ return choices.find((choice) => TERMINAL_CHOICES[choice] === picked) ?? "deny";
948
+ };
949
+ }
950
+
951
+ // Actions the member allowed for the rest of a session: the same tool with
952
+ // the same exact command or input. A handoff is never kept.
953
+ const sessionGrants = new Map<string, Set<string>>();
954
+ const grantKey = (toolName: string, command: string | null, input: unknown) => `${toolName}\u0000${command ?? JSON.stringify(input ?? {})}`;
955
+
933
956
  function evaluatePathLikeToolAccess(
934
957
  cwd: string,
935
958
  toolName: string,
@@ -1131,10 +1154,13 @@ function normalizePermissionProfileMode(value: unknown): PermissionProfileMode |
1131
1154
  }
1132
1155
 
1133
1156
  function runtimeEquivalentForPermissionProfile(mode: PermissionProfileMode): string {
1134
- if (mode === "read-only") return "sandbox_mode=read-only";
1135
- if (mode === "trusted-full-access") return "sandbox_mode=danger-full-access + approval_policy=never, with Piagent protected-path and human-action gates still enforced";
1136
- if (mode === "bypass") return "sandbox_mode=danger-full-access + approval_policy=never; every Piagent gate off (output redaction and turn checkpoints remain)";
1137
- return "sandbox_mode=workspace-write + approval_policy=on-request";
1157
+ // What the mode resembles in common sandbox terms. A personal session runs on this
1158
+ // machine with no OS sandbox (execution-backend.js "host"): Piagent's policy
1159
+ // checks are the boundary.
1160
+ if (mode === "read-only") return "like sandbox_mode=read-only, enforced by Piagent policy (no OS sandbox)";
1161
+ if (mode === "trusted-full-access") return "like sandbox_mode=danger-full-access; protected paths, destructive steps and the handoff (push, PR, publish, deploy, remote tools) are still asked";
1162
+ if (mode === "bypass") return "like sandbox_mode=danger-full-access + approval_policy=never for the work; the handoff (push, PR, publish, deploy, remote tools, MCP writes) is still asked, and output redaction and turn checkpoints remain";
1163
+ return "like sandbox_mode=workspace-write + approval_policy=on-request, enforced by Piagent policy (no OS sandbox)";
1138
1164
  }
1139
1165
 
1140
1166
  function sessionPermissionOverrideKey(ctx: ExtensionContext): string {
@@ -1789,143 +1815,6 @@ function prepareToolInputForPolicy(
1789
1815
  };
1790
1816
  }
1791
1817
 
1792
- // Which MCP server a tool call is addressed to. The adapter exposes one proxy
1793
- // tool named `mcp` that carries the server in `input.server`, and, under
1794
- // directTools, one tool per server tool named `<prefix>_<tool>` where the prefix
1795
- // is derived from the server name. Direct names are matched against the servers
1796
- // this gate is holding rather than against a fixed pattern, because the prefix
1797
- // is the server's own name and there is nothing constant to anchor on.
1798
- function mcpServerFromToolCall(
1799
- toolName: string,
1800
- input: Record<string, unknown>,
1801
- candidates: Iterable<string>
1802
- ): string | undefined {
1803
- if (normalizeActionToken(toolName) === "mcp") {
1804
- return typeof input.server === "string" && input.server.trim() ? input.server.trim() : undefined;
1805
- }
1806
- return attributeDirectTool(toolName, candidates);
1807
- }
1808
-
1809
- // Servers a repository defines are not usable until somebody on this machine has
1810
- // approved them. This is the enforcement point: the adapter owns the connection
1811
- // and this extension cannot prevent one being opened, but no tool call reaches an
1812
- // unapproved server. The residual exposure is the connection itself, which is
1813
- // worth stating plainly rather than describing this as a full block.
1814
- //
1815
- // Recomputed only when one of the files behind the decision changes, because
1816
- // this runs on every tool call.
1817
- interface RepositoryMcpGate {
1818
- blocked: Map<string, { state: string; origin: string }>; serverNames: Set<string>;
1819
- // Conditions under which no tool call can be checked at all, each with the
1820
- // sentence to show. These are not "a server is unapproved" — they are "this
1821
- // repository's config has put the gate in a state where it cannot tell which
1822
- // server a call belongs to", and the only sound answer to that is to stop.
1823
- unverifiable: string[];
1824
- }
1825
-
1826
- const mcpApprovalCache = new Map<string, { signature: string; gate: RepositoryMcpGate }>();
1827
-
1828
- function repositoryMcpGate(cwd: string): RepositoryMcpGate {
1829
- // Repository scopes plus anything an `imports` key drags in from a file the
1830
- // clone carries. Collecting those here is the whole point: a server the gate
1831
- // never enumerates is a server it silently permits.
1832
- const servers = collectServers({ projectPath: cwd, scopes: [...REPOSITORY_SCOPES] });
1833
- const repositoryFiles = [...REPOSITORY_SCOPES].map((scope) => ({
1834
- scope,
1835
- file: configPathForScope(scope, { projectPath: cwd })
1836
- }));
1837
- // Every file the decision reads, listed by the module that reads them rather
1838
- // than restated here. Building this by hand is what went wrong: the scan grew
1839
- // to read merged settings from all four scopes and to stat import targets
1840
- // outside the repository, the hand-written list did not, and an already-loaded
1841
- // guard went on permitting calls after a personal global config had put the
1842
- // session in the state this gate exists to refuse.
1843
- const signature = [
1844
- ...servers.map((server) => `${server.origin}:${server.name}:${fileSignature(server.file)}`),
1845
- ...repositoryFiles.map((layer) => `layer:${layer.scope}:${fileSignature(layer.file)}`),
1846
- ...mcpDecisionInputs({ projectPath: cwd }).map((file) => `input:${file}:${fileSignature(file)}`),
1847
- `store:${fileSignature(path.join(os.homedir(), ".pi", "piagent-mcp-approvals.json"))}`
1848
- ].join("|");
1849
- const cached = mcpApprovalCache.get(cwd);
1850
- if (cached?.signature === signature) return cached.gate;
1851
-
1852
- const blocked = new Map<string, { state: string; origin: string }>();
1853
- for (const server of servers) {
1854
- const state = approvalState({ projectPath: cwd, name: server.name, entry: server.entry });
1855
- if (state.state === "approved") continue;
1856
- // A name defined more than once keeps its blocking entry: the approved copy
1857
- // does not vouch for the one nobody has looked at.
1858
- blocked.set(server.name, { state: state.state, origin: server.origin });
1859
- }
1860
-
1861
- // Two things a repository-carried config can do that leave nothing to check.
1862
- //
1863
- // It can ask for tools with no prefix, so a direct tool arrives under a bare
1864
- // name carrying no evidence of its server. Refusing only the proxy there was
1865
- // not a fix: the proxy is the one form that names its server, so blocking it
1866
- // and allowing the bare names left the hole exactly where it was.
1867
- //
1868
- // It can also import a kind whose config this platform cannot enumerate, and
1869
- // then the set of servers reaching the session is simply unknown. Blocking the
1870
- // servers that could be listed says nothing about the ones that could not.
1871
- //
1872
- // Both stop every tool call. A repository can make a session refuse to run;
1873
- // it must not be able to make one run a server nobody approved.
1874
- // Read from the shared module rather than decided here, so `piagent-mcp
1875
- // doctor` reports exactly what this refuses. The two disagreeing is how an
1876
- // operator ends up reading "PASS" while every tool call is being stopped.
1877
- const unverifiable = unverifiableMcpConfig({ projectPath: cwd }).map((problem) => problem.detail);
1878
-
1879
- const gate = { blocked, serverNames: new Set(servers.map((server) => server.name)), unverifiable };
1880
- mcpApprovalCache.set(cwd, { signature, gate });
1881
- return gate;
1882
- }
1883
-
1884
- function fileSignature(file: string): string {
1885
- try {
1886
- const stat = fs.statSync(file);
1887
- return `${stat.mtimeMs}:${stat.size}`;
1888
- } catch {
1889
- return "absent";
1890
- }
1891
- }
1892
-
1893
- function evaluateMcpApproval(
1894
- cwd: string,
1895
- toolName: string,
1896
- input: Record<string, unknown>
1897
- ): { block: boolean; reason?: string } {
1898
- const gate = repositoryMcpGate(cwd);
1899
-
1900
- // Every tool call, not only the ones that look like MCP. Under either of these
1901
- // conditions a call cannot be traced to a server, so there is no such thing as
1902
- // a call this gate can clear.
1903
- if (gate.unverifiable.length > 0) {
1904
- return { block: true, reason: `Blocked every tool call: ${gate.unverifiable.join(" ")}` };
1905
- }
1906
-
1907
- const server = mcpServerFromToolCall(toolName, input, gate.blocked.keys());
1908
- if (!server) return { block: false };
1909
- const record = gate.blocked.get(server);
1910
- if (!record) return { block: false };
1911
- const explanation = record.state === "rejected"
1912
- ? "it was rejected for this project"
1913
- : record.state === "changed"
1914
- ? "its definition changed since it was approved"
1915
- : record.origin.startsWith("import:")
1916
- ? `this repository imports it from ${record.origin.slice("import:".length)} config and nobody on this machine has approved it here`
1917
- : "this repository defines it and nobody on this machine has approved it";
1918
- return {
1919
- block: true,
1920
- // Named as commands, because this is read inside a session: the reader can
1921
- // run these where they already are, without shelling out.
1922
- reason:
1923
- `Blocked MCP server ${server}: ${explanation}. ` +
1924
- `Review it with \`/piagent-mcp get ${server}\`, then \`/piagent-mcp approve ${server}\` to allow it ` +
1925
- `or \`/piagent-mcp reject ${server}\` to refuse it.`
1926
- };
1927
- }
1928
-
1929
1818
  // A word whose final path segment glues literal text onto an expansion --
1930
1819
  // `.en$(echo v)`, `${D}.env` -- names a file this process cannot know without
1931
1820
  // running the substitution. Matching the literal half against the protected
@@ -3861,6 +3750,38 @@ export default function piagentGuard(pi: ExtensionAPI) {
3861
3750
  // Bypass is for personal sessions only: a company session keeps Studio's rules.
3862
3751
  const bypassActive = (ctx: ExtensionContext) => !isCompanySession(ctx) && ctx.model?.provider !== "agent_watch_managed"
3863
3752
  && resolvePermissionProfile(loadProfileFromContext(ctx), policy, permissionOverrideFromContext(ctx)).mode === "bypass";
3753
+ // Bypass runs the work without asking and stops at the handoff: a push, a
3754
+ // PR or merge, a publish or deploy, a remote or cloud tool, an upload or an
3755
+ // MCP tool that writes outside is asked about, as in every other mode.
3756
+ const bypassHandoff = async (event: { toolName: string; toolCallId?: string; input?: unknown }, ctx: ExtensionContext): Promise<{ block: true; reason: string; terminate?: boolean } | undefined> => {
3757
+ const input = isPlainRecord(event.input) ? event.input : {};
3758
+ const prepared = SHELL_TOOL_NAMES.has(event.toolName) ? null : prepareToolInputForPolicy(event.toolName, input, policy);
3759
+ const shell = extractShellCommandInput(prepared?.proxyShellCarrier && prepared.proxyArgs ? prepared.proxyArgs : SHELL_TOOL_NAMES.has(event.toolName) ? input : {});
3760
+ const command = shell.command ? normalizeShellCommandForPolicy(shell.command) : null;
3761
+ // The project's autoDeny rules hold in Bypass too: they are its own refusals.
3762
+ const projectRules = autoDenyRules(loadProfileFromContext(ctx));
3763
+ if (command && projectRules.length) {
3764
+ const refused = evaluateExecPolicyCore(command, { policy: { execPolicy: { defaultMode: "enforce", bannedPrefixSuggestions: [], rules: projectRules } }, mode: "enforce" });
3765
+ if (refused.decision === "forbid") return execRefusal(refused);
3766
+ }
3767
+ let reason = command ? await handoffReason(command) : null;
3768
+ if (!reason && !command && prepared && classifyExternalAction(event.toolName, prepared.input, policy).decision === "confirm") {
3769
+ const action = await mcpWriteAction(prepared.proxyTool ?? event.toolName);
3770
+ if (action) reason = `${prepared.proxyToolName ?? event.toolName} writes outside this machine (${action})`;
3771
+ }
3772
+ if (!reason) return undefined;
3773
+ const preview = command ?? prepared?.confirmationSummary ?? event.toolName;
3774
+ const approval = await piApprovalBroker.request({ cwd: ctx.cwd, rawSessionId: ctx.sessionManager.getSessionId(), toolCallId: String(event.toolCallId), expectedTask: null,
3775
+ action: { kind: "external-provider-action", preconditionClass: "runtime-only", treePrecondition: null, toolName: event.toolName, rawAction: { toolName: event.toolName, command, input },
3776
+ commandPreview: command, parameterPreview: preview, targetPaths: [], targetSummaries: [reason], provider: command ? "shell-external" : String(prepared?.proxyToolName ?? event.toolName),
3777
+ urlOrigin: null, requestedScope: "one-handoff", reason: `Bypass still asks before a handoff: ${reason}`, riskClass: "high",
3778
+ allowConsequence: "Run this exact step once; the work leaves this machine.", denyConsequence: "The step does not run; the agent is told you declined." },
3779
+ terminalConfirm: () => ctx.ui.confirm(`Bypass still asks before a handoff.\n\n${reason}\n\n${preview}\n\nAllow?`, "Piagent handoff confirmation"),
3780
+ terminalChoose: terminalChooser(ctx, "Piagent handoff confirmation", `Bypass still asks before a handoff.\n\n${reason}\n\n${preview}`, false), unavailableFallback: approvalUnavailableFallback(ctx) });
3781
+ if (!approval.allowed) return approval.choice === "stop" ? { block: true, terminate: true, reason: `User denied the handoff and stopped the turn: ${reason}. Do not retry; wait for the member.` } : { block: true, reason: `User denied the handoff: ${reason}` };
3782
+ if (!approval.consume()) return { block: true, reason: "Approval became stale before tool start; the action was blocked." };
3783
+ return undefined;
3784
+ };
3864
3785
  registerToolCallHook(pi, {
3865
3786
  extractLikelyPath: extractLikelyPathFromInput,
3866
3787
  redactText,
@@ -3922,10 +3843,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
3922
3843
  return undefined;
3923
3844
  },
3924
3845
  authorize: async (event, ctx) => {
3925
- // Bypass: no Piagent gate asks or blocks, as with Codex's
3926
- // --dangerously-bypass-approvals-and-sandbox. Tool output is still
3927
- // redacted and the turn still gets its checkpoint; neither stops a command.
3928
- if (bypassActive(ctx)) return;
3846
+ // Bypass: no Piagent gate asks or blocks the work. The handoff is still
3847
+ // asked (bypassHandoff); tool output is still redacted and the turn still
3848
+ // gets its checkpoint.
3849
+ if (bypassActive(ctx)) return bypassHandoff(event, ctx);
3929
3850
  const preInput = isPlainRecord(event.input) ? event.input : {};
3930
3851
  const authorizationTargetInspection = authorizationMutationTargetInspection(ctx.cwd, event.toolName, preInput);
3931
3852
  if (authorizationTargetInspection.reason) {
@@ -4016,9 +3937,11 @@ export default function piagentGuard(pi: ExtensionAPI) {
4016
3937
  && !candidate.startsWith("../")
4017
3938
  && !candidate.startsWith(".pi/piagent-state/")
4018
3939
  ));
4019
- if (execDecision.mode !== "off" && execDecision.decision === "forbid") {
4020
- return { block: true, reason: execDecision.reasons.join("; ") };
4021
- }
3940
+ if (execDecision.mode !== "off" && execDecision.decision === "forbid") return execRefusal(execDecision);
3941
+ // The shell syntax tree reads what the scanner may miss: a command in a
3942
+ // function, a case arm or an unquoted heredoc's $( … ).
3943
+ const treeFinding = execDecision.mode !== "off" ? treeExecFinding(command) : null;
3944
+ if (treeFinding?.decision === "forbid") return { block: true, reason: treeFinding.reason };
4022
3945
 
4023
3946
  const protectedHit = findProtectedPathInCommand(command, pathPolicy.shellProtectedPaths);
4024
3947
  if (protectedHit) {
@@ -4047,16 +3970,21 @@ export default function piagentGuard(pi: ExtensionAPI) {
4047
3970
 
4048
3971
  // Full access skips the prompts for local, reversible steps; deletes,
4049
3972
  // migrations and anything sent outside this machine are still asked.
4050
- const confirmationReasons = (execDecision.mode !== "off" && execDecision.decision === "prompt" ? [...execDecision.reasons] : [])
3973
+ const confirmationReasons = [...(execDecision.mode !== "off" && execDecision.decision === "prompt" ? execDecision.reasons : []), ...(treeFinding?.decision === "prompt" && !execDecision.reasons.includes(treeFinding.reason) ? [treeFinding.reason] : [])]
4051
3974
  .filter((reason) => permissionProfile.mode !== "trusted-full-access"
4052
- || !FULL_ACCESS_SKIPPED_PROMPTS.some((id) => reason.startsWith(`Prompt required by exec policy ${id}:`)));
4053
- const externalReason = findShellExternalConfirmationReason(execDecision.segments, externalActionPolicyConfig(policy));
4054
- if (externalReason) confirmationReasons.push(externalReason);
3975
+ || ![...FULL_ACCESS_RUNS].some((id) => reason.startsWith(`Prompt required by exec policy ${id}:`)));
3976
+ // The handoff (push, PR, publish, deploy, remote and cloud tools,
3977
+ // uploads) is asked in every mode: runtime/policy/handoff-policy.mjs.
3978
+ const handoff = await handoffReason(command);
3979
+ const shellExternal = findShellExternalConfirmationReason(execDecision.segments, externalActionPolicyConfig(policy));
3980
+ const handoffNote = handoff && !shellExternal && !confirmationReasons.includes(handoff) ? `Handoff requires confirmation: ${handoff}` : undefined;
3981
+ const externalReason = shellExternal ?? (handoff ? handoffNote ?? handoff : undefined);
3982
+ for (const reason of [shellExternal, handoffNote]) if (reason) confirmationReasons.push(reason);
4055
3983
  if (confirmationReasons.length > 0) {
4056
3984
  pendingHumanApproval = { prompt: `Command requires confirmation.\n\n${confirmationReasons.join("\n")}\n\nAllow?`, title: "Piagent exec policy confirmation",
4057
3985
  action: { kind: externalReason ? "external-provider-action" : "workspace-patch", preconditionClass: externalReason ? "runtime-only" : "workspace-tree", toolName: event.toolName, rawAction: { toolName: event.toolName, command, input: preparedInput.input },
4058
3986
  commandPreview: command, parameterPreview: preparedInput.confirmationSummary ?? "Shell command", targetPaths: shellMutationTargets, targetSummaries: confirmationReasons, provider: externalReason ? "shell-external" : null, urlOrigin: null,
4059
- requestedScope: "one-shell-command", reason: confirmationReasons.join("; "), riskClass: shellProjectMutation ? "high" : "medium", allowConsequence: "Run this exact command once, after the guard checks the working tree again.", denyConsequence: "The command does not run; the agent is told you declined." } };
3987
+ requestedScope: handoff ? "one-handoff" : "one-shell-command", reason: confirmationReasons.join("; "), riskClass: shellProjectMutation ? "high" : "medium", allowConsequence: "Run this exact command once, after the guard checks the working tree again.", denyConsequence: "The command does not run; the agent is told you declined." } };
4060
3988
  }
4061
3989
  }
4062
3990
 
@@ -4109,16 +4037,25 @@ export default function piagentGuard(pi: ExtensionAPI) {
4109
4037
  : classifiedExternalAction;
4110
4038
  if (externalAction.decision === "confirm") {
4111
4039
  const inputSummary = preparedInput.confirmationSummary ? `\ninput: ${preparedInput.confirmationSummary}` : "";
4040
+ // A write to the system behind the tool is a handoff: approved one at a time.
4041
+ const writesOut = Boolean(await mcpWriteAction(preparedInput.proxyTool ?? event.toolName));
4112
4042
  pendingHumanApproval = { prompt: `External provider action requires confirmation.\n\nprovider: ${externalAction.provider}\naction: ${externalAction.action}\ntool: ${event.toolName}${inputSummary}\n\nAllow?`, title: "Piagent external action confirmation",
4113
4043
  action: { kind: "external-provider-action", preconditionClass: "runtime-only", toolName: event.toolName, rawAction: { toolName: event.toolName, input: preparedInput.input, provider: externalAction.provider, action: externalAction.action },
4114
4044
  commandPreview: null, parameterPreview: preparedInput.confirmationSummary ?? String(externalAction.action), targetPaths: [], targetSummaries: [], provider: String(externalAction.provider),
4115
- urlOrigin: null, requestedScope: "one-external-action", reason: `External provider action ${String(externalAction.action)} requires confirmation`, riskClass: "high", allowConsequence: "Run this exact action once, after the guard checks its authority again.", denyConsequence: "The action does not run; nothing outside changes." } };
4045
+ urlOrigin: null, requestedScope: writesOut ? "one-handoff" : "one-external-action", reason: `External provider action ${String(externalAction.action)} requires confirmation`, riskClass: "high", allowConsequence: "Run this exact action once, after the guard checks its authority again.", denyConsequence: "The action does not run; nothing outside changes." } };
4116
4046
  }
4117
4047
  }
4118
4048
 
4049
+ const grants = sessionGrants.get(ctx.sessionManager.getSessionId()) ?? new Set<string>();
4050
+ const grantable = Boolean(pendingHumanApproval) && !/handoff/.test(String(pendingHumanApproval!.action.requestedScope));
4051
+ const grant = grantKey(event.toolName, typeof pendingHumanApproval?.action.commandPreview === "string" ? pendingHumanApproval.action.commandPreview : null, preparedInput.input);
4052
+ if (pendingHumanApproval && grantable && grants.has(grant)) pendingHumanApproval = null;
4119
4053
  if (pendingHumanApproval) {
4120
- const approval = await piApprovalBroker.request({ cwd: ctx.cwd, rawSessionId: ctx.sessionManager.getSessionId(), toolCallId: String(event.toolCallId), expectedTask: null, action: { ...pendingHumanApproval.action, preconditionClass: "runtime-only", treePrecondition: null }, terminalConfirm: () => ctx.ui.confirm(pendingHumanApproval!.prompt, pendingHumanApproval!.title), unavailableFallback: approvalUnavailableFallback(ctx) });
4121
- if (!approval.allowed) return { block: true, reason: pendingHumanApproval.action.kind === "external-provider-action" && !SHELL_TOOL_NAMES.has(event.toolName) ? `User denied external provider action: ${event.toolName}` : `User denied command: ${pendingHumanApproval.action.reason}` };
4054
+ const approval = await piApprovalBroker.request({ cwd: ctx.cwd, rawSessionId: ctx.sessionManager.getSessionId(), toolCallId: String(event.toolCallId), expectedTask: null, action: { ...pendingHumanApproval.action, preconditionClass: "runtime-only", treePrecondition: null }, terminalConfirm: () => ctx.ui.confirm(pendingHumanApproval!.prompt, pendingHumanApproval!.title),
4055
+ terminalChoose: terminalChooser(ctx, pendingHumanApproval.title, pendingHumanApproval.prompt, grantable), unavailableFallback: approvalUnavailableFallback(ctx) });
4056
+ if (!approval.allowed) return { block: true, reason: pendingHumanApproval.action.kind === "external-provider-action" && !SHELL_TOOL_NAMES.has(event.toolName) ? `User denied external provider action: ${event.toolName}` : `User denied command: ${pendingHumanApproval.action.reason}`,
4057
+ ...(approval.choice === "stop" ? { terminate: true, reason: `User denied and stopped the turn: ${pendingHumanApproval.action.reason}. Do not retry; wait for the member.` } : {}) };
4058
+ if (approval.choice === "session" && grantable) sessionGrants.set(ctx.sessionManager.getSessionId(), grants.add(grant));
4122
4059
  // A WebUI allow is provisional until the guard consumes it here. Without
4123
4060
  // this the decision request never got its receipt and the approval stayed
4124
4061
  // pending, filling the broker until it stopped accepting WebUI decisions.