@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.
- package/CHANGELOG.md +23 -0
- package/README.md +16 -3
- package/README.vi.md +16 -3
- package/docs/command-reference-vietnamese.md +4 -4
- package/docs/company-dev-commands.md +4 -4
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +1 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/security-threat-model.md +3 -1
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +9 -9
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/update.html +3 -3
- package/docs-site/en/whats-new.html +33 -18
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +9 -9
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/releases.json +51 -0
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/update.html +3 -3
- package/docs-site/whats-new.html +33 -18
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +2 -1
- package/packages/piagent-core/extensions/guard-types.ts +10 -2
- package/packages/piagent-core/extensions/piagent-guard.ts +104 -23
- package/packages/piagent-core/extensions/policy-core.js +7 -7
- package/packages/piagent-core/extensions/shell-ast.js +85 -0
- package/packages/piagent-core/extensions/shell-command-words.js +19 -0
- package/packages/piagent-core/managed/command-tools.mjs +54 -24
- package/packages/piagent-core/managed/fetch-guard.mjs +3 -3
- package/packages/piagent-core/managed/host-command.mjs +15 -7
- package/packages/piagent-core/managed/member-mcp.mjs +34 -9
- package/packages/piagent-core/managed/permission.mjs +19 -12
- package/packages/piagent-core/managed/repository-operation.mjs +1 -1
- package/packages/piagent-core/managed/session.mjs +2 -2
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +1 -1
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +33 -16
- package/packages/piagent-core/runtime/policy/handoff-policy.mjs +144 -0
- package/packages/piagent-core/runtime/registration/permission-commands.ts +2 -2
- package/packages/piagent-webui/dist/client/assets/{App-CiwY9VpU.js → App-fZeIRoaD.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{GatewayApp-DrNJW3v_.js → GatewayApp-CpC4lgYK.js} +17 -15
- package/packages/piagent-webui/dist/client/assets/{InspectionApp-BBiwSaV0.js → InspectionApp-pD-BqBXm.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DwyPebIX.js → SessionAgentWorkspace-3tM7Q6HH.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CpVzaf_G.js → SessionInspectorDrawer-BJ94aQ6y.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BFfqMcuL.js +3 -0
- package/packages/piagent-webui/dist/client/assets/SettingsPage-CxpZ7cdM.js +1 -0
- package/packages/piagent-webui/dist/client/assets/{TerminalPanel-B8j_r6-y.js → TerminalPanel-CtZyb0tj.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-BDTPvq-0.js → WorkspaceCodeEditor-BS_ZrjPN.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DVsNU0eu.js → WorkspaceExplorer-CkFBpcM7.js} +3 -3
- package/packages/piagent-webui/dist/client/assets/{index-uhJMXxqD.js → index-CNdhIouM.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/{index-Dqb2hNan.css → index-CjM70M7D.css} +1 -1
- package/packages/piagent-webui/dist/client/assets/transcript-view-model-CpEMY1xb.js +3 -0
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/gateway/gateway-events.ts +5 -1
- package/packages/piagent-webui/server/gateway-websocket.ts +5 -1
- package/schemas/piagent-webui/approval-v1.schema.json +3 -1
- package/schemas/project-profile.schema.json +16 -0
- package/scripts/company-terminal.mjs +5 -5
- package/scripts/verify-release-identity.mjs +1 -1
- package/packages/piagent-core/runtime/policy/bypass-policy.mjs +0 -42
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +0 -3
- package/packages/piagent-webui/dist/client/assets/SettingsPage-Ca-qRSZa.js +0 -1
- package/packages/piagent-webui/dist/client/assets/transcript-view-model-Bq0nkxyu.js +0 -3
package/docs-site/scope.html
CHANGED
|
@@ -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.
|
|
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>
|
package/docs-site/security.html
CHANGED
|
@@ -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.
|
|
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>
|
package/docs-site/team.html
CHANGED
|
@@ -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.
|
|
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>
|
package/docs-site/update.html
CHANGED
|
@@ -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.
|
|
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 npm install -g --ignore-scripts @piagent/platform@1.
|
|
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 npm install -g --ignore-scripts @piagent/platform@1.48.0 piagent-install --stable --dry-run 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.
|
|
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>
|
package/docs-site/whats-new.html
CHANGED
|
@@ -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.
|
|
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-
|
|
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">
|
|
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">
|
package/docs-site/windows.html
CHANGED
|
@@ -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.
|
|
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>
|
package/docs-site/workflows.html
CHANGED
|
@@ -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.
|
|
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":
|
|
260
|
-
"sha256": "
|
|
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.
|
|
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
|
|
326
|
-
//
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
return "sandbox_mode=
|
|
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
|
|
3782
|
-
//
|
|
3783
|
-
//
|
|
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
|
-
|
|
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
|
-
|| !
|
|
3909
|
-
|
|
3910
|
-
|
|
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),
|
|
3977
|
-
|
|
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
|
-
|
|
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
|
+
}
|