@piagent/platform 1.47.0 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +23 -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 +4 -4
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/languages.json +2 -2
  8. package/docs/operator-manual-vietnamese.md +7 -7
  9. package/docs/publishing-for-teams.md +1 -0
  10. package/docs/release-install-policy.md +2 -2
  11. package/docs/security-threat-model.md +3 -1
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs-site/architecture.html +1 -1
  14. package/docs-site/assets/search-en.json +1 -1
  15. package/docs-site/assets/search-vi.json +1 -1
  16. package/docs-site/benchmark.html +1 -1
  17. package/docs-site/commands.html +1 -1
  18. package/docs-site/context-engine.html +1 -1
  19. package/docs-site/ecosystem.html +1 -1
  20. package/docs-site/en/architecture.html +1 -1
  21. package/docs-site/en/benchmark.html +1 -1
  22. package/docs-site/en/commands.html +1 -1
  23. package/docs-site/en/context-engine.html +1 -1
  24. package/docs-site/en/ecosystem.html +1 -1
  25. package/docs-site/en/index.html +9 -9
  26. package/docs-site/en/mcp-approval.html +1 -1
  27. package/docs-site/en/mcp-auth.html +1 -1
  28. package/docs-site/en/mcp-servers.html +1 -1
  29. package/docs-site/en/mcp.html +1 -1
  30. package/docs-site/en/permissions.html +1 -1
  31. package/docs-site/en/profiles.html +1 -1
  32. package/docs-site/en/quickstart.html +1 -1
  33. package/docs-site/en/runtime.html +1 -1
  34. package/docs-site/en/scope.html +1 -1
  35. package/docs-site/en/security.html +1 -1
  36. package/docs-site/en/team.html +1 -1
  37. package/docs-site/en/update.html +3 -3
  38. package/docs-site/en/whats-new.html +33 -18
  39. package/docs-site/en/windows.html +1 -1
  40. package/docs-site/en/workflows.html +1 -1
  41. package/docs-site/index.html +9 -9
  42. package/docs-site/mcp-approval.html +1 -1
  43. package/docs-site/mcp-auth.html +1 -1
  44. package/docs-site/mcp-servers.html +1 -1
  45. package/docs-site/mcp.html +1 -1
  46. package/docs-site/permissions.html +1 -1
  47. package/docs-site/profiles.html +1 -1
  48. package/docs-site/quickstart.html +1 -1
  49. package/docs-site/releases.json +51 -0
  50. package/docs-site/runtime.html +1 -1
  51. package/docs-site/scope.html +1 -1
  52. package/docs-site/security.html +1 -1
  53. package/docs-site/team.html +1 -1
  54. package/docs-site/update.html +3 -3
  55. package/docs-site/whats-new.html +33 -18
  56. package/docs-site/windows.html +1 -1
  57. package/docs-site/workflows.html +1 -1
  58. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  59. package/package.json +2 -1
  60. package/packages/piagent-core/extensions/guard-types.ts +10 -2
  61. package/packages/piagent-core/extensions/piagent-guard.ts +104 -23
  62. package/packages/piagent-core/extensions/policy-core.js +7 -7
  63. package/packages/piagent-core/extensions/shell-ast.js +85 -0
  64. package/packages/piagent-core/extensions/shell-command-words.js +19 -0
  65. package/packages/piagent-core/managed/command-tools.mjs +54 -24
  66. package/packages/piagent-core/managed/fetch-guard.mjs +3 -3
  67. package/packages/piagent-core/managed/host-command.mjs +15 -7
  68. package/packages/piagent-core/managed/member-mcp.mjs +34 -9
  69. package/packages/piagent-core/managed/permission.mjs +19 -12
  70. package/packages/piagent-core/managed/repository-operation.mjs +1 -1
  71. package/packages/piagent-core/managed/session.mjs +2 -2
  72. package/packages/piagent-core/package.json +1 -1
  73. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +1 -1
  74. package/packages/piagent-core/runtime/inspection/approval-broker.ts +33 -16
  75. package/packages/piagent-core/runtime/policy/handoff-policy.mjs +144 -0
  76. package/packages/piagent-core/runtime/registration/permission-commands.ts +2 -2
  77. package/packages/piagent-webui/dist/client/assets/{App-CiwY9VpU.js → App-fZeIRoaD.js} +1 -1
  78. package/packages/piagent-webui/dist/client/assets/{GatewayApp-DrNJW3v_.js → GatewayApp-CpC4lgYK.js} +17 -15
  79. package/packages/piagent-webui/dist/client/assets/{InspectionApp-BBiwSaV0.js → InspectionApp-pD-BqBXm.js} +1 -1
  80. package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DwyPebIX.js → SessionAgentWorkspace-3tM7Q6HH.js} +1 -1
  81. package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CpVzaf_G.js → SessionInspectorDrawer-BJ94aQ6y.js} +2 -2
  82. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BFfqMcuL.js +3 -0
  83. package/packages/piagent-webui/dist/client/assets/SettingsPage-CxpZ7cdM.js +1 -0
  84. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-B8j_r6-y.js → TerminalPanel-CtZyb0tj.js} +1 -1
  85. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-BDTPvq-0.js → WorkspaceCodeEditor-BS_ZrjPN.js} +1 -1
  86. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DVsNU0eu.js → WorkspaceExplorer-CkFBpcM7.js} +3 -3
  87. package/packages/piagent-webui/dist/client/assets/{index-uhJMXxqD.js → index-CNdhIouM.js} +2 -2
  88. package/packages/piagent-webui/dist/client/assets/{index-Dqb2hNan.css → index-CjM70M7D.css} +1 -1
  89. package/packages/piagent-webui/dist/client/assets/transcript-view-model-CpEMY1xb.js +3 -0
  90. package/packages/piagent-webui/dist/client/index.html +2 -2
  91. package/packages/piagent-webui/gateway/gateway-events.ts +5 -1
  92. package/packages/piagent-webui/server/gateway-websocket.ts +5 -1
  93. package/schemas/piagent-webui/approval-v1.schema.json +3 -1
  94. package/schemas/project-profile.schema.json +16 -0
  95. package/scripts/company-terminal.mjs +5 -5
  96. package/scripts/verify-release-identity.mjs +1 -1
  97. package/packages/piagent-core/runtime/policy/bypass-policy.mjs +0 -42
  98. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +0 -3
  99. package/packages/piagent-webui/dist/client/assets/SettingsPage-Ca-qRSZa.js +0 -1
  100. 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.47.0" title="v1.47.0 docs · VI"><span class="dot"></span>v1.47.0</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.47.0" title="v1.47.0 docs · VI"><span class="dot"></span>v1.47.0</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.47.0" title="v1.47.0 docs · VI"><span class="dot"></span>v1.47.0</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.47.0" title="v1.47.0 docs · VI"><span class="dot"></span>v1.47.0</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>
@@ -145,9 +145,9 @@
145
145
  <h2 id="pin-cho-team">Pin một bản cho cả team</h2>
146
146
  <p>Khi rollout cho team, cài đúng bản Pi host và đúng bản platform, rồi chạy installer ở chế độ stable. Bước <code>--dry-run</code> in ra những gì sẽ đổi.</p>
147
147
  <div class="command-card">
148
- <header><span>Cài pin theo bản</span><button class="clip-btn" data-clip="npm install -g --ignore-scripts @earendil-works/pi-coding-agent@1.3.0&#10;npm install -g --ignore-scripts @piagent/platform@1.47.0&#10;piagent-install --stable --dry-run&#10;piagent-install --stable">Lấy lệnh</button></header>
148
+ <header><span>Cài pin theo bản</span><button class="clip-btn" data-clip="npm install -g --ignore-scripts @earendil-works/pi-coding-agent@1.3.0&#10;npm install -g --ignore-scripts @piagent/platform@1.48.0&#10;piagent-install --stable --dry-run&#10;piagent-install --stable">Lấy lệnh</button></header>
149
149
  <pre><code>npm install -g --ignore-scripts @earendil-works/pi-coding-agent@1.3.0
150
- npm install -g --ignore-scripts @piagent/platform@1.47.0
150
+ npm install -g --ignore-scripts @piagent/platform@1.48.0
151
151
  piagent-install --stable --dry-run
152
152
  piagent-install --stable</code></pre>
153
153
  </div>
@@ -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.47.0" title="v1.47.0 docs · VI"><span class="dot"></span>v1.47.0</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,23 @@
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-47-0" 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>
112
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>
113
129
  <div class="release-body">
114
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>
@@ -403,27 +419,26 @@
403
419
  </ul>
404
420
  </div>
405
421
  </article>
406
- <article class="release" id="v1-42-0" data-release>
407
- <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>
408
- <div class="release-body">
409
- <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>
410
- <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>
411
- <ul class="release-items">
412
- <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>
413
- <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>
414
- <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>
415
- <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>
416
- <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>
417
- <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>
418
- </ul>
419
- </div>
420
- </article>
421
422
  </section>
422
423
  <section class="section archive">
423
424
  <h2 id="archive">Các bản trước</h2>
424
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>
425
426
  <details class="release-archive">
426
- <summary><span>Tháng 10/2026</span><span class="archive-count">62</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>
427
442
  <div class="archive-row" id="v1-41-0">
428
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>
429
444
  <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.47.0" title="v1.47.0 docs · VI"><span class="dot"></span>v1.47.0</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.47.0" title="v1.47.0 docs · VI"><span class="dot"></span>v1.47.0</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": 638,
260
- "sha256": "74109d76ee46eeb519070b53699a7e582a3705c74030e9ca067242566afbb5df"
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.47.0",
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[][];
@@ -86,8 +86,9 @@ import { currentWorkspaceRevisionDigest } from "./workspace-revision.js";
86
86
  import { appendJsonlBounded } from "./state-retention.js";
87
87
  import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
88
88
  import { hasDurableContextEvidence } from "./context-evidence.js";
89
- 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";
90
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";
91
92
  import { createWorkspaceWriteAuthorityBindings } from "../runtime/policy/workspace-write-authority.ts";
92
93
  import { registerWorkspaceMemberEdits } from "../runtime/session/workspace-member-edits.mjs";
93
94
  import {
@@ -206,6 +207,7 @@ import type {
206
207
  ResolvedPermissionProfile,
207
208
  ReviewLens,
208
209
  RuntimePolicySettings,
210
+ CommandRule,
209
211
  TaskContract,
210
212
  TechContextSnapshot,
211
213
  TechOption,
@@ -554,8 +556,6 @@ function verifyProjectCapabilityState(
554
556
  }
555
557
  }
556
558
 
557
- // Exec-policy prompts a full-access session does not show: local, reversible.
558
- const FULL_ACCESS_SKIPPED_PROMPTS = ["prompt-git-add-broad"];
559
559
 
560
560
  function evaluateExecPolicy(command: string, profile: ProjectProfile, policy: BasePolicy): {
561
561
  mode: RuntimePolicySettings["execPolicy"];
@@ -564,7 +564,7 @@ function evaluateExecPolicy(command: string, profile: ProjectProfile, policy: Ba
564
564
  segments: Array<{ command: string; words: string[]; matches: string[]; warnings: string[] }>;
565
565
  } {
566
566
  const runtime = resolveRuntimePolicy(profile);
567
- const execPolicy = execPolicyConfig(policy);
567
+ const execPolicy = { ...execPolicyConfig(policy), rules: [...execPolicyConfig(policy).rules, ...autoDenyRules(profile)] };
568
568
  const mode = runtime.execPolicy === "off" ? "off" : runtime.execPolicy ?? execPolicy.defaultMode;
569
569
  return evaluateExecPolicyCore(command, { policy: { ...policy, execPolicy }, mode }) as {
570
570
  mode: RuntimePolicySettings["execPolicy"];
@@ -923,6 +923,36 @@ function approvalUnavailableFallback(ctx: Pick<ExtensionContext, "ui">): Approva
923
923
  }
924
924
  }
925
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
+
926
956
  function evaluatePathLikeToolAccess(
927
957
  cwd: string,
928
958
  toolName: string,
@@ -1124,10 +1154,13 @@ function normalizePermissionProfileMode(value: unknown): PermissionProfileMode |
1124
1154
  }
1125
1155
 
1126
1156
  function runtimeEquivalentForPermissionProfile(mode: PermissionProfileMode): string {
1127
- if (mode === "read-only") return "sandbox_mode=read-only";
1128
- if (mode === "trusted-full-access") return "sandbox_mode=danger-full-access + approval_policy=never, with Piagent protected-path and human-action gates still enforced";
1129
- if (mode === "bypass") return "sandbox_mode=danger-full-access + approval_policy=never; every Piagent gate off (output redaction and turn checkpoints remain)";
1130
- 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)";
1131
1164
  }
1132
1165
 
1133
1166
  function sessionPermissionOverrideKey(ctx: ExtensionContext): string {
@@ -3717,6 +3750,38 @@ export default function piagentGuard(pi: ExtensionAPI) {
3717
3750
  // Bypass is for personal sessions only: a company session keeps Studio's rules.
3718
3751
  const bypassActive = (ctx: ExtensionContext) => !isCompanySession(ctx) && ctx.model?.provider !== "agent_watch_managed"
3719
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
+ };
3720
3785
  registerToolCallHook(pi, {
3721
3786
  extractLikelyPath: extractLikelyPathFromInput,
3722
3787
  redactText,
@@ -3778,10 +3843,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
3778
3843
  return undefined;
3779
3844
  },
3780
3845
  authorize: async (event, ctx) => {
3781
- // Bypass: no Piagent gate asks or blocks, as with Codex's
3782
- // --dangerously-bypass-approvals-and-sandbox. Tool output is still
3783
- // redacted and the turn still gets its checkpoint; neither stops a command.
3784
- 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);
3785
3850
  const preInput = isPlainRecord(event.input) ? event.input : {};
3786
3851
  const authorizationTargetInspection = authorizationMutationTargetInspection(ctx.cwd, event.toolName, preInput);
3787
3852
  if (authorizationTargetInspection.reason) {
@@ -3872,9 +3937,11 @@ export default function piagentGuard(pi: ExtensionAPI) {
3872
3937
  && !candidate.startsWith("../")
3873
3938
  && !candidate.startsWith(".pi/piagent-state/")
3874
3939
  ));
3875
- if (execDecision.mode !== "off" && execDecision.decision === "forbid") {
3876
- return { block: true, reason: execDecision.reasons.join("; ") };
3877
- }
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 };
3878
3945
 
3879
3946
  const protectedHit = findProtectedPathInCommand(command, pathPolicy.shellProtectedPaths);
3880
3947
  if (protectedHit) {
@@ -3903,16 +3970,21 @@ export default function piagentGuard(pi: ExtensionAPI) {
3903
3970
 
3904
3971
  // Full access skips the prompts for local, reversible steps; deletes,
3905
3972
  // migrations and anything sent outside this machine are still asked.
3906
- 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] : [])]
3907
3974
  .filter((reason) => permissionProfile.mode !== "trusted-full-access"
3908
- || !FULL_ACCESS_SKIPPED_PROMPTS.some((id) => reason.startsWith(`Prompt required by exec policy ${id}:`)));
3909
- const externalReason = findShellExternalConfirmationReason(execDecision.segments, externalActionPolicyConfig(policy));
3910
- 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);
3911
3983
  if (confirmationReasons.length > 0) {
3912
3984
  pendingHumanApproval = { prompt: `Command requires confirmation.\n\n${confirmationReasons.join("\n")}\n\nAllow?`, title: "Piagent exec policy confirmation",
3913
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 },
3914
3986
  commandPreview: command, parameterPreview: preparedInput.confirmationSummary ?? "Shell command", targetPaths: shellMutationTargets, targetSummaries: confirmationReasons, provider: externalReason ? "shell-external" : null, urlOrigin: null,
3915
- 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." } };
3916
3988
  }
3917
3989
  }
3918
3990
 
@@ -3965,16 +4037,25 @@ export default function piagentGuard(pi: ExtensionAPI) {
3965
4037
  : classifiedExternalAction;
3966
4038
  if (externalAction.decision === "confirm") {
3967
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));
3968
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",
3969
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 },
3970
4044
  commandPreview: null, parameterPreview: preparedInput.confirmationSummary ?? String(externalAction.action), targetPaths: [], targetSummaries: [], provider: String(externalAction.provider),
3971
- 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." } };
3972
4046
  }
3973
4047
  }
3974
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;
3975
4053
  if (pendingHumanApproval) {
3976
- 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) });
3977
- 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));
3978
4059
  // A WebUI allow is provisional until the guard consumes it here. Without
3979
4060
  // this the decision request never got its receipt and the approval stayed
3980
4061
  // pending, filling the broker until it stopped accepting WebUI decisions.
@@ -1,5 +1,5 @@
1
- import { excludedFindSelectorValue, findSelectorOption, SEARCH_COMMANDS, SEARCH_FILE_OPTIONS, SEARCH_GLOB_OPTIONS,
2
- SEARCH_PATTERN_OPTIONS, searchSelectorIsExclusion, searchSelectorWithinToken } from "./shell-selector-policy.js";
1
+ import { excludedFindSelectorValue, findSelectorOption, SEARCH_COMMANDS, SEARCH_FILE_OPTIONS, SEARCH_GLOB_OPTIONS, SEARCH_PATTERN_OPTIONS, searchSelectorIsExclusion, searchSelectorWithinToken } from "./shell-selector-policy.js";
2
+ import { CONTROL_PREFIXES, gitSubcommandWords } from "./shell-command-words.js";
3
3
 
4
4
  const PATH_REDIRECT_OPERATORS = new Set(["<", ">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>"]);
5
5
  const DATA_ONLY_COMMANDS = new Set(["echo", "printf"]);
@@ -35,7 +35,7 @@ const INLINE_CODE_FLAGS = new Map([
35
35
  ["perl", new Set(["-e", "-E"])],
36
36
  ["php", new Set(["-r"])]
37
37
  ]);
38
- const SIMPLE_WRAPPERS = new Set(["sudo", "nohup", "time", "nice", "ionice", "command"]);
38
+ const SIMPLE_WRAPPERS = new Set(["sudo", "doas", "nohup", "time", "nice", "ionice", "command", "exec"]);
39
39
 
40
40
  function escapeRegex(value) {
41
41
  return value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
@@ -703,13 +703,13 @@ function isLeadingRedirection(word) {
703
703
  return /^[0-9]*(?:>>?\|?|<>?|>&|<&)/.test(word);
704
704
  }
705
705
 
706
- function stripWrapper(words) {
706
+ export function stripWrapper(words) {
707
707
  let current = [...words];
708
708
  let changed = true;
709
709
 
710
710
  while (changed && current.length > 0) {
711
711
  changed = false;
712
- while (current.length > 0 && isAssignment(current[0])) {
712
+ while (current.length > 0 && (isAssignment(current[0]) || CONTROL_PREFIXES.has(current[0]))) {
713
713
  current = current.slice(1);
714
714
  changed = true;
715
715
  }
@@ -1186,7 +1186,7 @@ export function evaluateExecPolicyCore(command, options) {
1186
1186
 
1187
1187
  for (const rule of execPolicy.rules) {
1188
1188
  if (!commandRuleMatches(rule, text, words)) continue;
1189
- matches.push(`${rule.action}:${rule.id}`);
1189
+ matches.push(`${rule.action}:${rule.id}`, ...(rule.action === "forbid" && rule.terminate ? [`terminate:${rule.id}`] : []));
1190
1190
  if (rule.action === "forbid") reasons.push(`Forbidden by exec policy ${rule.id}: ${rule.reason}`);
1191
1191
  if (rule.action === "prompt") reasons.push(`Prompt required by exec policy ${rule.id}: ${rule.reason}`);
1192
1192
  }
@@ -1229,7 +1229,7 @@ export function evaluateExecPolicyCore(command, options) {
1229
1229
  const confirmationPatternMatches = (pattern, words) => {
1230
1230
  const expected = shellWords(String(pattern ?? ""));
1231
1231
  if (expected.length === 0) return false;
1232
- const executableWords = stripWrapper(words);
1232
+ const executableWords = gitSubcommandWords(stripWrapper(words));
1233
1233
  if (arrayStartsWith(executableWords, expected)) return true;
1234
1234
  if (expected.length !== 1) return false;
1235
1235
  const action = expected[0];
@@ -0,0 +1,85 @@
1
+ // The simple commands a shell line runs, read from a real shell syntax tree.
2
+ //
3
+ // policy-core.js splits a line into segments with its own scanner; this reads
4
+ // the same line through a POSIX/Bash parser and walks every place a command
5
+ // can sit: pipelines, && and ||, if/while/for/case bodies, functions, ( … ),
6
+ // { … }, $( … ), backticks and <( … ). `bash -c '…'`, `sh -c` and `eval` are
7
+ // parsed again, so the text they run is read as commands too. A check that
8
+ // reads both lists sees a command whichever reader finds it.
9
+ //
10
+ // Words come back as text. A word built from an expansion the shell decides
11
+ // at run time ($VAR, $(…), `…`) keeps it as written and is flagged dynamic;
12
+ // nothing is evaluated here. A line the parser rejects gives null: callers
13
+ // keep the scanner's answer, which is never weakened by this module.
14
+ import { parse } from "@aliou/sh";
15
+
16
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "mksh"]);
17
+ const MAX_DEPTH = 8;
18
+
19
+ function wordText(word) {
20
+ let text = "", dynamic = false;
21
+ const visit = (parts) => {
22
+ for (const part of parts ?? []) {
23
+ if (part.type === "Literal" || part.type === "SglQuoted") text += part.value;
24
+ else if (part.type === "DblQuoted") visit(part.parts);
25
+ else if (part.type === "ParamExp") { text += `$${part.param?.value ?? ""}`; dynamic = true; }
26
+ else { text += "$(…)"; dynamic = true; }
27
+ }
28
+ };
29
+ visit(word?.parts);
30
+ return { text, dynamic };
31
+ }
32
+
33
+ // The $( … ) and ` … ` an unquoted heredoc body runs: the parser keeps the
34
+ // body as text, and the shell expands it.
35
+ function substitutions(text) {
36
+ const found = [];
37
+ for (let index = 0; index < text.length; index += 1) {
38
+ if (text[index] === "`") {
39
+ const close = text.indexOf("`", index + 1);
40
+ if (close < 0) break;
41
+ found.push(text.slice(index + 1, close)); index = close;
42
+ } else if (text.startsWith("$(", index) && text[index + 2] !== "(") {
43
+ let depth = 0, close = -1;
44
+ for (let at = index + 1; at < text.length; at += 1) {
45
+ if (text[at] === "(") depth += 1;
46
+ else if (text[at] === ")" && --depth === 0) { close = at; break; }
47
+ }
48
+ if (close < 0) break;
49
+ found.push(text.slice(index + 2, close)); index = close;
50
+ }
51
+ }
52
+ return found;
53
+ }
54
+
55
+ function collect(node, found, depth) {
56
+ if (!node || typeof node !== "object") return;
57
+ if (Array.isArray(node)) { for (const item of node) collect(item, found, depth); return; }
58
+ if (node.type === "Redirect" && node.heredoc && node.target?.parts?.every((part) => part.type === "Literal") && depth < MAX_DEPTH) {
59
+ for (const source of substitutions(wordText(node.heredoc).text)) collectSource(source, found, depth + 1);
60
+ }
61
+ if (node.type === "SimpleCommand" && node.words?.length) {
62
+ const words = node.words.map(wordText);
63
+ found.push({ words: words.map((word) => word.text), dynamic: words.some((word) => word.dynamic) });
64
+ const name = String(words[0]?.text ?? "").split("/").pop();
65
+ const inline = SHELLS.has(name) ? words.findIndex((word, index) => index > 0 && /^-[a-z]*c[a-z]*$/.test(word.text)) + 1 : name === "eval" ? 1 : 0;
66
+ if (inline > 0 && depth < MAX_DEPTH) {
67
+ const source = name === "eval" ? words.slice(1).map((word) => word.text).join(" ") : words[inline]?.text;
68
+ if (source) collectSource(source, found, depth + 1);
69
+ }
70
+ }
71
+ for (const [key, value] of Object.entries(node)) {
72
+ if (key !== "pos" && key !== "end" && value && typeof value === "object") collect(value, found, depth);
73
+ }
74
+ }
75
+
76
+ function collectSource(source, found, depth) {
77
+ try { collect(parse(String(source), { dialect: "bash" }).ast, found, depth); } catch { /* the scanner still reads it */ }
78
+ }
79
+
80
+ // [{ words, dynamic }] for every simple command, or null when the line does not parse.
81
+ export function astCommands(command) {
82
+ const found = [];
83
+ try { collect(parse(String(command), { dialect: "bash" }).ast, found, 0); } catch { return null; }
84
+ return found;
85
+ }
@@ -0,0 +1,19 @@
1
+ // How a simple command's words name what runs, shared by policy-core.js and
2
+ // the handoff policy (runtime/policy/handoff-policy.mjs).
3
+
4
+ // Shell words that open a compound statement: the command follows them.
5
+ // `if true; then rm -rf /; fi` splits into `then rm -rf /`, which runs rm.
6
+ export const CONTROL_PREFIXES = new Set(["!", "{", "(", "if", "then", "do", "elif", "else", "while", "until"]);
7
+
8
+ // Git's global options come before the subcommand: `git -C api push` and
9
+ // `git -c core.hooksPath=x --no-pager push` push as `git push` does. Without
10
+ // this a rule written as "git push" matched only the plain spelling.
11
+ const GIT_OPTIONS_WITH_VALUE = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--exec-path", "--list-cmds"]);
12
+ export function gitSubcommandWords(words) {
13
+ if (String(words[0] ?? "").split("/").pop() !== "git") return words;
14
+ let index = 1;
15
+ while (index < words.length && words[index].startsWith("-")) {
16
+ index += GIT_OPTIONS_WITH_VALUE.has(words[index]) ? 2 : 1;
17
+ }
18
+ return ["git", ...words.slice(index)];
19
+ }