@evoclock/pi-agentic-driver 0.6.0 → 0.8.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/README.md +94 -10
- package/extensions/attended-authority-guard.ts +7 -0
- package/extensions/task-board.ts +31 -0
- package/package.json +6 -2
- package/scripts/aidr_writing_review.js +19 -0
- package/scripts/enforcement/attended_authority_guard.js +223 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +209 -44
- package/scripts/enforcement/herdr_communication_pi.js +113 -15
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +8 -0
- package/scripts/enforcement/task_board_core_pi.js +1183 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# pi-agentic-driver v0.
|
|
1
|
+
# pi-agentic-driver v0.8.0
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<img src="assets/Yamagane-origami.png" alt="pi-agentic-driver, Yamagane origami mark" width="140"/>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
|
|
10
|
-
<img src="https://img.shields.io/badge/version-0.
|
|
10
|
+
<img src="https://img.shields.io/badge/version-0.8.0-blue?style=flat" alt="Version 0.8.0"/>
|
|
11
11
|
<img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
|
|
12
12
|
<img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
|
|
13
13
|
<img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
|
|
@@ -49,6 +49,8 @@ proofs for agentic workflows.
|
|
|
49
49
|
| `agentic_aidr` | A remedy for AI;DR. Reviews writing for clarity, simplicity, brevity, and humanity. | shipped |
|
|
50
50
|
| `agentic_linux_microvm_cutover` | Runs one job in a throwaway QEMU/KVM virtual machine on a Linux host, with a severity-tiered killswitch that stops escape attempts. | user-enabled, native confirmation |
|
|
51
51
|
| `agentic_worker_dispatch` | Runs controlled worker journeys and observes worker liveness. | shipped |
|
|
52
|
+
| `agentic_kanban_board` | Shows the workspace task board: lanes, flags, priorities, dependencies, and which cards can run. | shipped |
|
|
53
|
+
| `agentic_kanban_board_write` | Adds cards to the board through the trusted writer, which records who authorized the work. | shipped |
|
|
52
54
|
|
|
53
55
|
**Status: active development and testing.** Each extension ships only after
|
|
54
56
|
it passes fixture-based acceptance, native tests, live-session checks, and
|
|
@@ -187,6 +189,34 @@ silently, and return results as untrusted evidence.
|
|
|
187
189
|
</details>
|
|
188
190
|
|
|
189
191
|
|
|
192
|
+
<details>
|
|
193
|
+
<summary><strong>task board, planned work you can see</strong> <em>(released, 0.8.0)</em></summary>
|
|
194
|
+
|
|
195
|
+
Keep planned work on a Kanban board. You read and edit the board in
|
|
196
|
+
Obsidian or in the Vogelkop Task Board pane (our upcoming Scientific and
|
|
197
|
+
Research Workbench). Agents read the same board and add cards to it.
|
|
198
|
+
|
|
199
|
+
The board appears only when a `board.md` or `TASKS.md` file exists in the
|
|
200
|
+
workspace. With no board file, the tools do not appear and nothing changes.
|
|
201
|
+
|
|
202
|
+
When an agent adds a card, a trusted writer does the bookkeeping: it
|
|
203
|
+
assigns the card ID, computes the integrity hash, checks the card, and
|
|
204
|
+
records who authorized the work. The record comes from you: your
|
|
205
|
+
instruction, or your approval of an agent's proposal. A card without that
|
|
206
|
+
record cannot be dispatched. A hand-edited card cannot fake the record, and
|
|
207
|
+
a tampered card refuses to run.
|
|
208
|
+
|
|
209
|
+
Only you complete a card. Move it to done in your board UI, or tell an
|
|
210
|
+
agent to close it. An agent report that says the work is finished is
|
|
211
|
+
evidence for your review. It is never the completion itself.
|
|
212
|
+
|
|
213
|
+
The card format is shared. The same board renders in Obsidian, with
|
|
214
|
+
optional Tasks-plugin emoji, and in the Vogelkop Task Board pane. The
|
|
215
|
+
machine-readable fields are the single source of truth, so there is no
|
|
216
|
+
second copy to keep in sync.
|
|
217
|
+
|
|
218
|
+
</details>
|
|
219
|
+
|
|
190
220
|
- **project status and state review.** Read-only projections of workspace Git
|
|
191
221
|
state, formal records, and task-state health.
|
|
192
222
|
- **role-lane routing and warm sessions.** Separate lanes handle
|
|
@@ -345,18 +375,72 @@ Small models on your own desk are worth taking seriously as reviewers.
|
|
|
345
375
|
|
|
346
376
|
</details>
|
|
347
377
|
|
|
378
|
+
<details>
|
|
379
|
+
<summary><strong>autonomous journeys, overnight operation</strong> <em>(released, 0.7.0)</em></summary>
|
|
380
|
+
|
|
381
|
+
Dispatch a worker journey that runs end to end while you are away. The
|
|
382
|
+
journey proceeds through steps without pausing for confirmation, replaces
|
|
383
|
+
agents that get stuck, and records everything in one report you read when
|
|
384
|
+
you come back.
|
|
385
|
+
|
|
386
|
+
Autonomy is entered by your words alone. Say "run these overnight end to
|
|
387
|
+
end" or "don't wait for me" and the journey runs in autonomous mode. Say
|
|
388
|
+
"do these two things" and it runs in the normal mode, pausing for you
|
|
389
|
+
between steps. The mode is recorded in the report either way.
|
|
390
|
+
|
|
391
|
+
The journeys run work you dispatch in natural language: work that is real
|
|
392
|
+
and authorized but not yet formally placed on a kanban board. A task board
|
|
393
|
+
integration is planned, and once it ships, planned journeys will execute
|
|
394
|
+
board-managed work with the same containment and safety guarantees.
|
|
395
|
+
|
|
396
|
+
### The cast
|
|
397
|
+
|
|
398
|
+
You name the agents for the journey, and that list becomes the standing
|
|
399
|
+
permission. If the implementer gets stuck at 3am, a replacement spawns
|
|
400
|
+
automatically: same role, same model, both in the list you authorized.
|
|
401
|
+
If a task needs an agent you didn't name, the attempt is recorded and that
|
|
402
|
+
thread stops. Nothing outside your list can appear while you sleep.
|
|
403
|
+
|
|
404
|
+
The cast is frozen at dispatch and never grows. It can only shrink in
|
|
405
|
+
practice (roles finishing their work), never widen.
|
|
406
|
+
|
|
407
|
+
### When agents get stuck
|
|
408
|
+
|
|
409
|
+
A stuck agent doesn't block the journey. The journey detects the stall,
|
|
410
|
+
spawns a replacement from the cast, and the replacement starts with a
|
|
411
|
+
mandatory gap analysis: read the task spec, inspect the repository, consult
|
|
412
|
+
the journey history, and state what remains before resuming. The replacement
|
|
413
|
+
must show that the remaining work is smaller than what its predecessor left.
|
|
414
|
+
If it can't, the journey stops that role and continues with the others.
|
|
415
|
+
|
|
416
|
+
### The morning report
|
|
417
|
+
|
|
418
|
+
One block tells you what happened: the terminal state, each step's outcome,
|
|
419
|
+
every replacement with its gap analysis and progress judgment, and any
|
|
420
|
+
denials. Every claim is tied to a receipt or a report excerpt. No prose
|
|
421
|
+
narrative to reconstruct; you read the report and know exactly where things
|
|
422
|
+
stand.
|
|
423
|
+
|
|
424
|
+
</details>
|
|
425
|
+
|
|
426
|
+
<details>
|
|
427
|
+
<summary><strong>attended-authority guard</strong> <em>(released, 0.7.0)</em></summary>
|
|
428
|
+
|
|
429
|
+
The safety net between an agent and your shell. When a model tries to
|
|
430
|
+
delete, overwrite, or push, the guard stops it and asks you. Safe commands
|
|
431
|
+
pass through untouched. If you deny, you get a clear reason and the session
|
|
432
|
+
continues, and the agent does not retry behind your back. In headless runs
|
|
433
|
+
where no human can confirm, destructive commands are refused rather than
|
|
434
|
+
silently allowed.
|
|
435
|
+
|
|
436
|
+
</details>
|
|
437
|
+
|
|
348
438
|
**Under development in this theme:**
|
|
349
439
|
|
|
350
440
|
- **native macOS container proof.** The native Apple Container runtime has
|
|
351
441
|
passed a fixed local isolation qualification: read-only repository mount,
|
|
352
442
|
no network, automatic removal. The native Pi adapter is not yet part of the
|
|
353
443
|
released package.
|
|
354
|
-
- **attended-authority guard.** The safety net between an agent and your
|
|
355
|
-
shell. When a model tries to delete, overwrite, or push, the guard stops it
|
|
356
|
-
and asks you. Safe commands pass through untouched. If you deny, you get a
|
|
357
|
-
clear reason and the session continues, and the agent does not retry behind
|
|
358
|
-
your back. In headless runs where no human can confirm, destructive
|
|
359
|
-
commands are refused rather than silently allowed.
|
|
360
444
|
|
|
361
445
|
## Session continuity
|
|
362
446
|
|
|
@@ -436,7 +520,7 @@ pi install npm:@evoclock/pi-agentic-driver
|
|
|
436
520
|
Or from Git at a pinned tag:
|
|
437
521
|
|
|
438
522
|
```sh
|
|
439
|
-
pi install git:github.com/evoclock/pi-agentic-driver@v0.
|
|
523
|
+
pi install git:github.com/evoclock/pi-agentic-driver@v0.8.0
|
|
440
524
|
```
|
|
441
525
|
|
|
442
526
|
Released extensions load standalone; neither requires the other.
|
|
@@ -450,7 +534,7 @@ extensions you want with the object form in your Pi settings:
|
|
|
450
534
|
{
|
|
451
535
|
"packages": [
|
|
452
536
|
{
|
|
453
|
-
"source": "npm:@evoclock/pi-agentic-driver@0.
|
|
537
|
+
"source": "npm:@evoclock/pi-agentic-driver@0.8.0",
|
|
454
538
|
"extensions": [
|
|
455
539
|
"extensions/aidr.ts",
|
|
456
540
|
"extensions/code-phage.js"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
export default async function attendedAuthorityGuard(pi) {
|
|
5
|
+
const module = await import(new URL("../scripts/enforcement/attended_authority_guard.js", import.meta.url).href);
|
|
6
|
+
return module.registerAttendedAuthorityGuard(pi);
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// BOARD-1 provider extension (§5 reversibility): the board surface registers
|
|
5
|
+
// only when a real board file is observed in the workspace. No board file,
|
|
6
|
+
// no behavior change and no new tool.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
// Documented board locations (design §2): workspace-level Obsidian Kanban
|
|
12
|
+
// Markdown, or vogelkop's simpler TASKS.md shape.
|
|
13
|
+
const BOARD_FILENAMES = ["board.md", "TASKS.md"];
|
|
14
|
+
|
|
15
|
+
export function resolveBoardPath(cwd) {
|
|
16
|
+
if (typeof cwd !== "string" || cwd === "") return null;
|
|
17
|
+
for (const name of BOARD_FILENAMES) {
|
|
18
|
+
const candidate = join(cwd, name);
|
|
19
|
+
if (existsSync(candidate)) return candidate;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default async function taskBoardPi(pi) {
|
|
25
|
+
const module = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
|
|
26
|
+
const boardPath = resolveBoardPath(pi?.ctx?.cwd);
|
|
27
|
+
if (boardPath === null) {
|
|
28
|
+
return { registered: [], observation: { present: false, boardPath: null } };
|
|
29
|
+
}
|
|
30
|
+
return module.registerKanbanBoardTools(pi, { boardPath });
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
"governance"
|
|
35
35
|
],
|
|
36
36
|
"files": [
|
|
37
|
+
"extensions/attended-authority-guard.ts",
|
|
38
|
+
"scripts/enforcement/attended_authority_guard.js",
|
|
37
39
|
"extensions/code-phage.js",
|
|
38
40
|
"extensions/herdr-communication.ts",
|
|
39
41
|
"extensions/herdr-dispatch.ts",
|
|
42
|
+
"extensions/task-board.ts",
|
|
40
43
|
"lib/adapters/diff-scope.mjs",
|
|
41
44
|
"lib/adapters/evidence.mjs",
|
|
42
45
|
"lib/adapters/narrative.mjs",
|
|
@@ -62,7 +65,8 @@
|
|
|
62
65
|
"PROVENANCE.md",
|
|
63
66
|
"extensions/aidr.ts",
|
|
64
67
|
"scripts/aidr_writing_review.js",
|
|
65
|
-
"templates/AGENTS.md"
|
|
68
|
+
"templates/AGENTS.md",
|
|
69
|
+
"scripts/enforcement/task_board_core_pi.js"
|
|
66
70
|
],
|
|
67
71
|
"pi": {
|
|
68
72
|
"extensions": [
|
|
@@ -40,6 +40,18 @@ const STE_PHRASAL_GUIDANCE = Object.freeze({
|
|
|
40
40
|
"look at": "examine",
|
|
41
41
|
"set up": "configure or install",
|
|
42
42
|
});
|
|
43
|
+
// Project terminology rules (established in session guidance, encoded here so
|
|
44
|
+
// AI;DR enforces them advisorially):
|
|
45
|
+
// - "limited" for numeric limits; "controlled" or "authorised" for authority
|
|
46
|
+
// and behavior; "approved" or "authorised" for tasks.
|
|
47
|
+
// - "fundamental" or "crucial" instead of the metaphor "load-bearing".
|
|
48
|
+
// - Avoid "bounded" in prose entirely; use "limited" for numeric limits.
|
|
49
|
+
// (The boundedText identifier is a code name, not prose, and is flagged
|
|
50
|
+
// separately for a future rename.)
|
|
51
|
+
const TERMINOLOGY_GUIDANCE = Object.freeze({
|
|
52
|
+
bounded: "use limited for numeric limits; avoid bounded in prose",
|
|
53
|
+
"load-bearing": "use fundamental or crucial",
|
|
54
|
+
});
|
|
43
55
|
const STE_MODAL_GUIDANCE = Object.freeze({
|
|
44
56
|
should: "use must for a requirement, or state the recommendation directly",
|
|
45
57
|
may: "use can for ability or must have permission language when needed",
|
|
@@ -154,6 +166,13 @@ function steFindings(prose, sentences, documentType) {
|
|
|
154
166
|
message: "State requirement, ability, permission, or condition precisely; do not leave the modal meaning implicit.",
|
|
155
167
|
examples: modalExamples,
|
|
156
168
|
});
|
|
169
|
+
const terminologyExamples = steTermExamples(prose, TERMINOLOGY_GUIDANCE);
|
|
170
|
+
if (terminologyExamples.length) findings.push({
|
|
171
|
+
rule: "STE-T1",
|
|
172
|
+
kind: "ste-project-terminology",
|
|
173
|
+
message: "Use the project terminology: limited for numeric limits, controlled or authorised for authority and behavior, approved or authorised for tasks, fundamental or crucial instead of load-bearing, and avoid bounded in prose.",
|
|
174
|
+
examples: terminologyExamples,
|
|
175
|
+
});
|
|
157
176
|
if (/\band\/or\b/i.test(prose)) findings.push({
|
|
158
177
|
rule: "STE-C1",
|
|
159
178
|
kind: "ste-conjunction",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// Attended-authority destructive-command guard for Pi.
|
|
5
|
+
//
|
|
6
|
+
// Ported observable behavior (private reference: scripts/enforcement/
|
|
7
|
+
// pi_attended_authority.js + cc_destructive_command_guard_hook.py):
|
|
8
|
+
// - Intercept tool calls before execution via the `tool_call` event.
|
|
9
|
+
// - Destructive shell/Git operations require native confirmation.
|
|
10
|
+
// - Safe reads/builds/tests pass through untouched.
|
|
11
|
+
// - An explicit denial returns a clear reason and the session continues;
|
|
12
|
+
// no hidden retry and no automatic re-ask.
|
|
13
|
+
// - Headless / no-confirmation contexts refuse destructive operations
|
|
14
|
+
// fail-closed.
|
|
15
|
+
// - No bypasses, no model-supplied authority, no silent escalation. The
|
|
16
|
+
// model cannot mark a call safe; classification is structural only.
|
|
17
|
+
|
|
18
|
+
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
19
|
+
|
|
20
|
+
export const GUARD_SCHEMA = "pi-agentic-driver.attended-authority.v1";
|
|
21
|
+
|
|
22
|
+
// Tool names that can mutate the workspace.
|
|
23
|
+
const MUTATING_TOOLS = new Set(["bash", "write", "edit"]);
|
|
24
|
+
|
|
25
|
+
// Read-only / build / test commands that always pass through.
|
|
26
|
+
const SAFE_COMMAND_PREFIXES = [
|
|
27
|
+
"cat", "ls", "head", "tail", "grep", "rg", "find", "sed -n", "awk",
|
|
28
|
+
"wc", "file", "stat", "which", "echo", "pwd", "date", "env",
|
|
29
|
+
"node --check", "node --test", "npm test", "npm run",
|
|
30
|
+
"python3 -m pytest", "pytest", "make", "cmake",
|
|
31
|
+
"git status", "git log", "git diff", "git show", "git branch",
|
|
32
|
+
"git remote", "git rev-parse", "git blame", "git describe",
|
|
33
|
+
"git config --get", "git ls-files",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Arbitrary-code interpreter execution: any code payload can perform a
|
|
37
|
+
// destructive operation, so these forms are classified fail-closed.
|
|
38
|
+
const DESTRUCTIVE_SHELL_PATTERNS = [
|
|
39
|
+
{ pattern: /\brm\b[^|;&]*\s(-[a-z]*[rf][a-z]*\s|--recursive|--force)/, kind: "recursive or forced file deletion" },
|
|
40
|
+
{ pattern: /\bsudo\s+rm\b/, kind: "privileged file deletion" },
|
|
41
|
+
{ pattern: /\brmdir\b|\bunlink\b/, kind: "file or directory deletion" },
|
|
42
|
+
{ pattern: /\bmkfs\b|\bshred\b|\bdd\b\s+if=/, kind: "irreversible disk or file operation" },
|
|
43
|
+
{ pattern: /\btruncate\s+-s\s*0\b/, kind: "file truncation" },
|
|
44
|
+
{ pattern: /\bkill\b\s+-9\b|\bpkill\b/, kind: "forced process termination" },
|
|
45
|
+
{ pattern: /\bchmod\s+-R\b|\bchown\s+-R\b/, kind: "recursive permission change" },
|
|
46
|
+
{ pattern: /\bnode\s+(-e|--eval)\b/, kind: "arbitrary JavaScript execution via node -e" },
|
|
47
|
+
{ pattern: /\bpython3?\s+-c\b/, kind: "arbitrary Python execution via python -c" },
|
|
48
|
+
{ pattern: /\bnpx\b/, kind: "arbitrary package execution via npx" },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Scope note: plain `rm file` (no -r/-f) is intentionally NOT classified
|
|
52
|
+
// destructive here; the guard targets recursive/forced deletion and
|
|
53
|
+
// irreversible operations. Any `git push`, forced or not, is treated
|
|
54
|
+
// conservatively as destructive because pushes publish history to remotes.
|
|
55
|
+
|
|
56
|
+
// Destructive Git operations.
|
|
57
|
+
const DESTRUCTIVE_GIT_PATTERNS = [
|
|
58
|
+
{ pattern: /\bgit\s+push\b[^|;&]*(--force|-f\b)/, kind: "forced Git push" },
|
|
59
|
+
{ pattern: /\bgit\s+push\b/, kind: "Git push" },
|
|
60
|
+
{ pattern: /\bgit\s+reset\s+--hard\b/, kind: "hard Git reset" },
|
|
61
|
+
{ pattern: /\bgit\s+reset\b/, kind: "Git reset" },
|
|
62
|
+
{ pattern: /\bgit\s+clean\b/, kind: "Git clean" },
|
|
63
|
+
{ pattern: /\bgit\s+checkout\s+--\s/, kind: "Git working-tree discard" },
|
|
64
|
+
{ pattern: /\bgit\s+restore\b/, kind: "Git working-tree discard" },
|
|
65
|
+
{ pattern: /\bgit\s+stash\s+(drop|clear|pop)\b/, kind: "Git stash mutation" },
|
|
66
|
+
{ pattern: /\bgit\s+stash\b/, kind: "Git stash mutation" },
|
|
67
|
+
{ pattern: /\bgit\s+rebase\b/, kind: "Git history rewrite" },
|
|
68
|
+
{ pattern: /\bgit\s+filter-(branch|repo)\b/, kind: "Git history rewrite" },
|
|
69
|
+
{ pattern: /\bgit\s+commit\b[^|;&]*--amend\b/, kind: "Git history rewrite" },
|
|
70
|
+
{ pattern: /\bgit\s+branch\s+(-D|-d)\b/, kind: "protected branch deletion" },
|
|
71
|
+
{ pattern: /\bgit\s+tag\s+-d\b/, kind: "protected tag deletion" },
|
|
72
|
+
{ pattern: /\bgit\s+cherry-pick\b|\bgit\s+revert\b/, kind: "Git history mutation" },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Paths whose deletion or overwrite is always treated as destructive.
|
|
76
|
+
const PROTECTED_PATHS = [
|
|
77
|
+
".env", ".ssh", ".gnupg", "node_modules", ".git",
|
|
78
|
+
"package-lock.json", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Quote-aware tokenization: normalizes shell quoting so `rm '-rf'`,
|
|
82
|
+
// `git "push"`, and split/embedded quoting such as `r'm' '-rf'` still
|
|
83
|
+
// expose their destructive tokens to the patterns. Escapes are also
|
|
84
|
+
// stripped (`\rm` -> `rm`) so an escaped destructive token cannot dodge
|
|
85
|
+
// classification. Normalization is only used for the destructive
|
|
86
|
+
// patterns, never to widen the safe-prefix list.
|
|
87
|
+
function unquoteTokens(text) {
|
|
88
|
+
return String(text ?? "")
|
|
89
|
+
.split(/[\s\n]+/)
|
|
90
|
+
.map((token) => token
|
|
91
|
+
.replace(/^"/, "").replace(/"$/, "")
|
|
92
|
+
.replace(/^'/, "").replace(/'$/, "")
|
|
93
|
+
.replace(/["']/g, "")
|
|
94
|
+
.replace(/\\(.)/g, "$1"))
|
|
95
|
+
.join(" ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function classifyBashCommand(command) {
|
|
99
|
+
const text = String(command ?? "").trim();
|
|
100
|
+
if (!text) return { destructive: false };
|
|
101
|
+
// A safe prefix wins only when the whole command is that single simple
|
|
102
|
+
// command (no chaining, redirection, or command substitution), so a
|
|
103
|
+
// destructive payload cannot hide behind a safe-looking prefix.
|
|
104
|
+
const compound = /[;&|>`]|\$\(|\n/.test(text);
|
|
105
|
+
const redirection = /(^|\s)(>{1,2}|<)/.test(text);
|
|
106
|
+
// Structural destructive verdicts win over safe-looking prefixes, so a
|
|
107
|
+
// destructive form such as `git branch -D` cannot hide behind a safe
|
|
108
|
+
// prefix such as `git branch`.
|
|
109
|
+
for (const { pattern, kind } of DESTRUCTIVE_SHELL_PATTERNS) {
|
|
110
|
+
if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
|
|
111
|
+
}
|
|
112
|
+
for (const { pattern, kind } of DESTRUCTIVE_GIT_PATTERNS) {
|
|
113
|
+
if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
|
|
114
|
+
}
|
|
115
|
+
if (!compound && !redirection) {
|
|
116
|
+
for (const prefix of SAFE_COMMAND_PREFIXES) {
|
|
117
|
+
if (text === prefix || text.startsWith(`${prefix} `) || text.startsWith(`${prefix}\t`)) {
|
|
118
|
+
return { destructive: false };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Output redirection overwrites an existing file in place.
|
|
123
|
+
const redirect = text.match(/(?:^|\s)>{1,2}\s*([^\s;&|]+)\s*$/);
|
|
124
|
+
if (redirect) {
|
|
125
|
+
const target = redirect[1].replace(/^["']|["']$/g, "");
|
|
126
|
+
return {
|
|
127
|
+
destructive: true,
|
|
128
|
+
kind: `file overwrite via redirection to ${target}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Unrecognized non-safe commands are not destructive by default; the
|
|
132
|
+
// guard only intercepts structurally destructive operations.
|
|
133
|
+
return { destructive: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function touchesProtectedPath(path) {
|
|
137
|
+
const normalized = String(path ?? "").replace(/\\/g, "/");
|
|
138
|
+
return PROTECTED_PATHS.some((entry) =>
|
|
139
|
+
normalized === entry
|
|
140
|
+
|| normalized.endsWith(`/${entry}`)
|
|
141
|
+
|| normalized.includes(`/${entry}/`)
|
|
142
|
+
|| normalized.startsWith(`./${entry}`),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Existing-file overwrite detection is injected so tests stay filesystem-free.
|
|
147
|
+
export function classifyWriteCall(toolName, input, existsSync) {
|
|
148
|
+
if (toolName !== "write" && toolName !== "edit") {
|
|
149
|
+
return { destructive: false };
|
|
150
|
+
}
|
|
151
|
+
const path = input?.path ?? input?.file_path ?? "";
|
|
152
|
+
if (touchesProtectedPath(path)) {
|
|
153
|
+
return { destructive: true, kind: `protected path write to ${path}` };
|
|
154
|
+
}
|
|
155
|
+
if (toolName === "write" && typeof existsSync === "function" && path && existsSync(path)) {
|
|
156
|
+
return { destructive: true, kind: `overwrite of existing file ${path}` };
|
|
157
|
+
}
|
|
158
|
+
return { destructive: false };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function confirmationBody(toolName, input, kind) {
|
|
162
|
+
return [
|
|
163
|
+
`Attended-authority guard: ${kind}.`,
|
|
164
|
+
`Tool: ${toolName}`,
|
|
165
|
+
toolName === "bash" ? `Command: ${input?.command}` : `Path: ${input?.path ?? input?.file_path ?? ""}`,
|
|
166
|
+
"",
|
|
167
|
+
"Allow this destructive operation?",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function denialReason(toolName, kind, context) {
|
|
172
|
+
if (!isNativeTuiContext(context)) {
|
|
173
|
+
return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) refused fail-closed: no native confirmation surface in this headless context`;
|
|
174
|
+
}
|
|
175
|
+
return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) denied by user; the session continues and this call is not retried`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Core guard for one tool call. Returns undefined to allow the call, or
|
|
180
|
+
* `{ block: true, reason }` to refuse it. Confirmation is native-only:
|
|
181
|
+
* `ctx.ui.confirm` in an interactive TUI. Headless contexts never confirm.
|
|
182
|
+
* Model-supplied fields on the event can never grant authority.
|
|
183
|
+
*/
|
|
184
|
+
export async function guardToolCall(event, ctx, options = {}) {
|
|
185
|
+
const toolName = String(event?.toolName ?? "").toLowerCase();
|
|
186
|
+
if (!MUTATING_TOOLS.has(toolName)) return undefined;
|
|
187
|
+
const input = event?.input && typeof event.input === "object" ? event.input : {};
|
|
188
|
+
|
|
189
|
+
const bash = toolName === "bash"
|
|
190
|
+
? classifyBashCommand(input.command)
|
|
191
|
+
: { destructive: false };
|
|
192
|
+
const write = toolName === "bash"
|
|
193
|
+
? { destructive: false }
|
|
194
|
+
: classifyWriteCall(toolName, input, options.existsSync);
|
|
195
|
+
const verdict = bash.destructive ? bash : write;
|
|
196
|
+
if (!verdict.destructive) return undefined;
|
|
197
|
+
|
|
198
|
+
const context = options.context ?? ctx;
|
|
199
|
+
if (!isNativeTuiContext(context)) {
|
|
200
|
+
return { block: true, reason: denialReason(toolName, verdict.kind, context) };
|
|
201
|
+
}
|
|
202
|
+
const confirmed = await context.ui.confirm(
|
|
203
|
+
"Destructive operation",
|
|
204
|
+
confirmationBody(toolName, input, verdict.kind),
|
|
205
|
+
);
|
|
206
|
+
if (confirmed === true) return undefined;
|
|
207
|
+
return { block: true, reason: denialReason(toolName, verdict.kind, context) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Register the guard on a Pi host instance. Duplicate registration on the
|
|
212
|
+
* same instance is ignored.
|
|
213
|
+
*/
|
|
214
|
+
const REGISTERED = new WeakSet();
|
|
215
|
+
|
|
216
|
+
export function registerAttendedAuthorityGuard(pi, options = {}) {
|
|
217
|
+
if (!pi || REGISTERED.has(pi)) return undefined;
|
|
218
|
+
REGISTERED.add(pi);
|
|
219
|
+
pi.on?.("tool_call", (event, ctx) => guardToolCall(event, ctx, options));
|
|
220
|
+
return { registered: true, schema: GUARD_SCHEMA };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export default guardToolCall;
|