pi-seatbelt 0.2.0 → 0.4.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/.github/workflows/test.yml +19 -0
- package/README.md +72 -44
- package/assets/logo.svg +33 -0
- package/core.ts +52 -37
- package/index.ts +212 -50
- package/package.json +1 -1
- package/smoke.mjs +57 -34
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- uses: actions/setup-node@v4
|
|
15
|
+
with:
|
|
16
|
+
node-version: 24
|
|
17
|
+
|
|
18
|
+
- name: Smoke test
|
|
19
|
+
run: node smoke.mjs
|
package/README.md
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
# pi-seatbelt
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/logo.svg" width="144" alt="pi-seatbelt logo: a diagonal seatbelt strap with a buckle">
|
|
5
|
+
</p>
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
[](https://www.npmjs.com/package/pi-seatbelt)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
A permissions system for pi that you configure and that learns from your
|
|
11
|
+
decisions.
|
|
12
|
+
|
|
13
|
+
Every gated tool call gets a danger label from a small classifier model:
|
|
14
|
+
`safe`, `caution`, or `DANGEROUS`, with a one-line purpose and, when
|
|
15
|
+
dangerous, the reason. A whole chain of calls is approved in one dialog
|
|
16
|
+
instead of one at a time. In `auto` mode the labeller also sees what you
|
|
17
|
+
approved or rejected earlier in the session, rejection reasons included, so
|
|
18
|
+
its judgment tracks yours.
|
|
11
19
|
|
|
12
20
|

|
|
13
21
|
|
|
14
|
-
Three choices: `Approve all`, `Reject all`, or `Reject with
|
|
15
|
-
message goes
|
|
16
|
-
|
|
22
|
+
Three choices per batch: `Approve all`, `Reject all`, or `Reject with
|
|
23
|
+
message`. The message goes back to the agent as the rejection reason, so it
|
|
24
|
+
can adjust instead of blindly retrying.
|
|
17
25
|
|
|
18
|
-
You
|
|
19
|
-
and what the labeller calls dangerous.
|
|
26
|
+
You pick which tools are gated (default: `bash`), which mode each session
|
|
27
|
+
runs in, and what the labeller calls dangerous.
|
|
20
28
|
|
|
21
29
|
## Install
|
|
22
30
|
|
|
31
|
+
From npm:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pi install npm:pi-seatbelt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or from GitHub:
|
|
38
|
+
|
|
23
39
|
```sh
|
|
24
40
|
pi install git:github.com/mikesposito/pi-seatbelt
|
|
25
41
|
```
|
|
@@ -27,30 +43,36 @@ pi install git:github.com/mikesposito/pi-seatbelt
|
|
|
27
43
|
Try it once without installing:
|
|
28
44
|
|
|
29
45
|
```sh
|
|
30
|
-
pi -e
|
|
46
|
+
pi -e npm:pi-seatbelt
|
|
31
47
|
```
|
|
32
48
|
|
|
33
|
-
|
|
34
|
-
load it per run
|
|
49
|
+
For local development, symlink the checkout into your extensions directory,
|
|
50
|
+
or load it per run:
|
|
35
51
|
|
|
36
52
|
```sh
|
|
37
53
|
ln -s /path/to/pi-seatbelt ~/.pi/agent/extensions/seatbelt
|
|
38
54
|
pi -e /path/to/pi-seatbelt
|
|
39
55
|
```
|
|
40
56
|
|
|
41
|
-
No runtime dependencies. The
|
|
42
|
-
packages as peers.
|
|
57
|
+
No runtime dependencies. The host-provided pi packages are declared as peers.
|
|
43
58
|
|
|
44
|
-
##
|
|
59
|
+
## Modes
|
|
45
60
|
|
|
46
61
|
Press the mode key to cycle: `alt+b` by default, or any key you set (see
|
|
47
|
-
`shortcut` under [Configure](#configure)).
|
|
48
|
-
|
|
62
|
+
`shortcut` under [Configure](#configure)). Or use `/seatbelt:mode`: no
|
|
63
|
+
argument cycles, `/seatbelt:mode auto` sets a mode directly. The status bar
|
|
64
|
+
always shows where you are: `seatbelt:fastened 🔒` (green),
|
|
65
|
+
`seatbelt:auto 🤖` (yellow), `seatbelt:unfastened 💀` (red).
|
|
49
66
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
The mode is per session: switching affects only the current session, and
|
|
68
|
+
each session remembers its mode across restarts. New sessions start in the
|
|
69
|
+
default mode from config; set it with `/seatbelt:default auto` (no argument
|
|
70
|
+
shows the current default).
|
|
71
|
+
|
|
72
|
+
- `fastened`: every chain asks once. The default.
|
|
73
|
+
- `auto`: no dialog unless something is `DANGEROUS`. The labeller sees your
|
|
74
|
+
earlier decisions in the session, so pair it with a good policy file.
|
|
75
|
+
- `unfastened`: no gating, no classifier calls, no cost.
|
|
54
76
|
|
|
55
77
|
Headless modes (print, JSON) have no dialogs: only `safe` calls auto-run,
|
|
56
78
|
everything else is blocked with a reason.
|
|
@@ -58,9 +80,9 @@ everything else is blocked with a reason.
|
|
|
58
80
|
Calls made through nested execution (codemode scripts) never appear in an
|
|
59
81
|
assistant message, so they are approved one at a time as a fallback.
|
|
60
82
|
|
|
61
|
-
##
|
|
83
|
+
## What counts as dangerous is your call
|
|
62
84
|
|
|
63
|
-
The labels come from the classifier model. You steer
|
|
85
|
+
The labels come from the classifier model. You steer it with a plain
|
|
64
86
|
markdown file: `~/.pi/agent/seatbelt/policy.md`.
|
|
65
87
|
|
|
66
88
|
- Write plain sentences. No syntax to learn.
|
|
@@ -80,8 +102,8 @@ Example:
|
|
|
80
102
|
- Treat any command that writes outside /tmp as dangerous.
|
|
81
103
|
```
|
|
82
104
|
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
Policy shapes the model's labels. With no model configured the policy file
|
|
106
|
+
is not consulted; gated calls always require approval.
|
|
85
107
|
|
|
86
108
|
## Configure
|
|
87
109
|
|
|
@@ -91,7 +113,7 @@ built-in regex fallback labels calls and the policy file is not consulted.
|
|
|
91
113
|
{
|
|
92
114
|
"model": "litellm/DeepSeek-V4-Flash",
|
|
93
115
|
"timeoutMs": 20000,
|
|
94
|
-
"mode": "
|
|
116
|
+
"mode": "fastened",
|
|
95
117
|
"shortcut": "alt+b",
|
|
96
118
|
"tools": ["bash", "write", "edit", "mcp__slack__*"]
|
|
97
119
|
}
|
|
@@ -101,20 +123,26 @@ built-in regex fallback labels calls and the policy file is not consulted.
|
|
|
101
123
|
`mcp__github__create_issue`) or prefix patterns (`mcp__slack__*` for a
|
|
102
124
|
whole server, `*` for everything). Non-bash calls are shown to the
|
|
103
125
|
labeller as tool name plus arguments, truncated to 500 characters.
|
|
104
|
-
- `shortcut`: the key that cycles the
|
|
105
|
-
|
|
106
|
-
needs a new session. Anything malformed falls back to
|
|
126
|
+
- `shortcut`: the key that cycles the modes, as a pi key id (`"alt+b"`,
|
|
127
|
+
`"ctrl+shift+p"`, `"f9"`, `"super+b"` for ⌘ on macOS). Read at startup,
|
|
128
|
+
so a change needs a new session. Anything malformed falls back to
|
|
129
|
+
`alt+b`. Some combos need the terminal to report modifiers distinctly:
|
|
130
|
+
through tmux without `set -s extended-keys on`, `ctrl+shift+b` arrives
|
|
131
|
+
as plain `ctrl+b` (cursor-left), while `alt`- and `F`1–`F`12` keys work
|
|
132
|
+
everywhere. `super` (⌘) needs a terminal with Kitty keyboard protocol
|
|
133
|
+
support (Ghostty, kitty, WezTerm) and is usually intercepted by the
|
|
134
|
+
terminal or OS before it reaches pi. If a key combo does not work,
|
|
135
|
+
`/seatbelt:mode` always does.
|
|
136
|
+
- `mode`: the default mode for new sessions. Per-session switching never
|
|
137
|
+
touches it; use `/seatbelt:default` to change it.
|
|
107
138
|
- `model`: the labeller. Pick one with `/seatbelt`, or pass it directly:
|
|
108
139
|
`/seatbelt litellm/gpt-4o-mini`. Fast and cheap works well; the prompt
|
|
109
|
-
is tiny. Without a model, or if the call fails or times out, the
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
```sh
|
|
119
|
-
node smoke.mjs
|
|
120
|
-
```
|
|
140
|
+
is tiny. Without a model, or if the call fails or times out, the
|
|
141
|
+
approval dialog is shown unlabeled (always shown, even in auto mode),
|
|
142
|
+
with a warning naming the cause; without a UI the call is blocked. A
|
|
143
|
+
missing model also raises a recurring toast pointing at `/seatbelt`.
|
|
144
|
+
- `timeoutMs`: give up on the labeller after this long; past it the batch
|
|
145
|
+
is treated as failed (unlabeled approval).
|
|
146
|
+
|
|
147
|
+
Both config files are seeded as templates on first load. Fresh installs get
|
|
148
|
+
a one-time notice to run `/seatbelt`.
|
package/assets/logo.svg
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="pi-seatbelt logo">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#1e293b"/>
|
|
5
|
+
<stop offset="1" stop-color="#0b1220"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="strap" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#f8fafc"/>
|
|
9
|
+
<stop offset="1" stop-color="#cbd5e1"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<linearGradient id="buckle" x1="0" y1="0" x2="0" y2="1">
|
|
12
|
+
<stop offset="0" stop-color="#fbbf24"/>
|
|
13
|
+
<stop offset="1" stop-color="#f59e0b"/>
|
|
14
|
+
</linearGradient>
|
|
15
|
+
<clipPath id="frame">
|
|
16
|
+
<rect x="16" y="16" width="480" height="480" rx="112"/>
|
|
17
|
+
</clipPath>
|
|
18
|
+
</defs>
|
|
19
|
+
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
|
|
20
|
+
<!-- the belt: full diagonal, entering and leaving off-frame -->
|
|
21
|
+
<g clip-path="url(#frame)">
|
|
22
|
+
<g transform="rotate(50 256 256)">
|
|
23
|
+
<rect x="-140" y="220" width="792" height="72" fill="url(#strap)" stroke="#0b1220" stroke-width="8"/>
|
|
24
|
+
<line x1="-120" y1="256" x2="640" y2="256" stroke="#64748b" stroke-width="6" stroke-dasharray="16 14" stroke-linecap="round"/>
|
|
25
|
+
</g>
|
|
26
|
+
</g>
|
|
27
|
+
<!-- buckle riding on the belt, strap continues past it out of frame -->
|
|
28
|
+
<g transform="rotate(50 301 310)">
|
|
29
|
+
<rect x="243" y="259" width="116" height="102" rx="24" fill="url(#buckle)" stroke="#0b1220" stroke-width="8"/>
|
|
30
|
+
<rect x="269" y="297" width="64" height="26" rx="10" fill="#0b1220"/>
|
|
31
|
+
<circle cx="301" cy="310" r="7" fill="#4ade80"/>
|
|
32
|
+
</g>
|
|
33
|
+
</svg>
|
package/core.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type Danger = "safe" | "caution" | "dangerous";
|
|
2
2
|
|
|
3
|
-
export const PERMISSION_MODES = ["
|
|
3
|
+
export const PERMISSION_MODES = ["fastened", "auto", "unfastened"] as const;
|
|
4
4
|
export type PermissionMode = (typeof PERMISSION_MODES)[number];
|
|
5
5
|
|
|
6
6
|
export function isPermissionMode(value: unknown): value is PermissionMode {
|
|
@@ -10,7 +10,7 @@ export function isPermissionMode(value: unknown): value is PermissionMode {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function normalizeMode(value: unknown): PermissionMode {
|
|
13
|
-
return isPermissionMode(value) ? value : "
|
|
13
|
+
return isPermissionMode(value) ? value : "fastened";
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function nextMode(mode: PermissionMode): PermissionMode {
|
|
@@ -66,25 +66,6 @@ export function withPolicy(base: string, policy: string): string {
|
|
|
66
66
|
: base;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const DANGEROUS_PATTERNS = [
|
|
70
|
-
/\brm\s+(-[a-z]*[rf][a-z]*|--recursive|--force)/i,
|
|
71
|
-
/\bsudo\b/i,
|
|
72
|
-
/\b(chmod|chown)\b[^;&|]*\b777\b/i,
|
|
73
|
-
/\bgit\s+push\b[^;&|]*(--force\b|\s-f\b)/i,
|
|
74
|
-
/\bdd\b/i,
|
|
75
|
-
/\bmkfs\b/i,
|
|
76
|
-
/\b(shutdown|reboot|halt)\b/i,
|
|
77
|
-
/\bkill(all)?\s+-9\b/i,
|
|
78
|
-
/(curl|wget)[^;&|]*\|\s*(sudo\s+)?(ba|z|fi)?sh\b/i,
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
const CAUTION_PATTERNS = [
|
|
82
|
-
/\b(git\s+(commit|reset|checkout|rebase|merge)|npm|npx|pnpm|yarn|pip|cargo|brew)\b/i,
|
|
83
|
-
/\b(curl|wget|ssh|scp|rsync)\b/i,
|
|
84
|
-
/\b(mv|cp|mkdir|touch|tee|sed\s+-i|chmod|chown|rm)\b/i,
|
|
85
|
-
/\b(kill|pkill)\b/i,
|
|
86
|
-
];
|
|
87
|
-
|
|
88
69
|
/** Exact tool name, or prefix match with a trailing "*" (e.g. "mcp__slack__*"). */
|
|
89
70
|
export function matchesTool(patterns: readonly string[], name: string): boolean {
|
|
90
71
|
return patterns.some((p) => (p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : p === name));
|
|
@@ -98,27 +79,32 @@ export function describeCall(toolName: string, args: Record<string, unknown>): s
|
|
|
98
79
|
return json === "{}" ? toolName : `${toolName} ${json}`;
|
|
99
80
|
}
|
|
100
81
|
|
|
101
|
-
|
|
82
|
+
/** One user decision from the current session, used as classifier context. */
|
|
83
|
+
export type DecisionRecord = {
|
|
84
|
+
tool: string;
|
|
85
|
+
/** The call as shown in the dialog (describeCall output). */
|
|
86
|
+
display: string;
|
|
87
|
+
allowed: boolean;
|
|
88
|
+
/** The user's stated reason, present only on rejects with a message. */
|
|
89
|
+
reason?: string;
|
|
90
|
+
};
|
|
102
91
|
|
|
103
|
-
/**
|
|
104
|
-
export function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return { danger: "caution", purpose, risk: "" };
|
|
114
|
-
}
|
|
115
|
-
return { danger: "safe", purpose, risk: "" };
|
|
92
|
+
/** Prior user decisions as classifier-prompt context; empty string when there is none. Most recent last, capped. */
|
|
93
|
+
export function formatHistory(records: readonly DecisionRecord[], max = 20): string {
|
|
94
|
+
if (records.length === 0) return "";
|
|
95
|
+
const lines = records
|
|
96
|
+
.slice(-max)
|
|
97
|
+
.map(
|
|
98
|
+
(r) =>
|
|
99
|
+
`- ${r.allowed ? "approved" : "rejected"}: ${r.display}${r.reason ? ` (user said: ${r.reason})` : ""}`,
|
|
100
|
+
);
|
|
101
|
+
return `Earlier user decisions in this session (most recent last):\n${lines.join("\n")}`;
|
|
116
102
|
}
|
|
117
103
|
|
|
118
104
|
/**
|
|
119
105
|
* Parse the classifier's reply. Expects a JSON array of {danger, purpose},
|
|
120
|
-
* exactly `count` items, in input order. Returns undefined on any mismatch
|
|
121
|
-
* the caller
|
|
106
|
+
* exactly `count` items, in input order. Returns undefined on any mismatch
|
|
107
|
+
* so the caller treats classification as failed.
|
|
122
108
|
*/
|
|
123
109
|
export function parseAssessments(raw: string, count: number): Assessment[] | undefined {
|
|
124
110
|
const start = raw.indexOf("[");
|
|
@@ -178,6 +164,35 @@ export function formatBatch(
|
|
|
178
164
|
return lines.join("\n");
|
|
179
165
|
}
|
|
180
166
|
|
|
167
|
+
/** Why the classifier could not label calls: no model configured, or the call itself failed. */
|
|
168
|
+
export type ClassifyFailure = "no-model" | "unavailable";
|
|
169
|
+
|
|
170
|
+
/** Body for the approval dialog when the classifier failed: calls listed without labels. */
|
|
171
|
+
export function formatUnlabeled(
|
|
172
|
+
commands: string[],
|
|
173
|
+
failure: ClassifyFailure,
|
|
174
|
+
style: DialogStyle = plainStyle,
|
|
175
|
+
): string {
|
|
176
|
+
const warning =
|
|
177
|
+
failure === "no-model"
|
|
178
|
+
? "! no classifier model set: these calls could not be labeled (pick one with /seatbelt)"
|
|
179
|
+
: "! classifier unavailable: these calls could not be labeled";
|
|
180
|
+
const lines = [
|
|
181
|
+
commands.length === 1 ? "Approve this tool call?" : `Approve ${commands.length} tool calls?`,
|
|
182
|
+
"",
|
|
183
|
+
style.label("caution", warning),
|
|
184
|
+
"",
|
|
185
|
+
];
|
|
186
|
+
for (let i = 0; i < commands.length; i++) {
|
|
187
|
+
lines.push(` ${String(i + 1).padEnd(2)} ${style.dim("> ")}${style.text(truncate(commands[i], 300))}`);
|
|
188
|
+
lines.push("");
|
|
189
|
+
}
|
|
190
|
+
if (commands.length > 1) {
|
|
191
|
+
lines.push(style.dim("One approval covers every tool call in this batch."));
|
|
192
|
+
}
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
181
196
|
/** Body for the single-call fallback dialog, also embedded by formatBatch. */
|
|
182
197
|
export function formatSingle(command: string, assessment: Assessment, style: DialogStyle = plainStyle): string {
|
|
183
198
|
const lines = [
|
package/index.ts
CHANGED
|
@@ -15,15 +15,18 @@
|
|
|
15
15
|
* /seatbelt. Custom labeling rules go in policy.md next to the config:
|
|
16
16
|
* its whole content is sent to the labeller as user policy that overrides
|
|
17
17
|
* the built-in defaults, and it is re-read on every batch. Without a usable
|
|
18
|
-
* model,
|
|
19
|
-
*
|
|
18
|
+
* model, every gated call requires approval (the policy file only affects
|
|
19
|
+
* the model).
|
|
20
20
|
*
|
|
21
21
|
* Permission modes (cycled with the configured shortcut key, default alt+b,
|
|
22
|
-
* shown in the status bar):
|
|
23
|
-
*
|
|
22
|
+
* shown in the status bar) are per session: switching affects only the
|
|
23
|
+
* current session and is remembered per session id, so resuming a session
|
|
24
|
+
* restores its mode. New sessions start in the config's default "mode",
|
|
25
|
+
* settable with /seatbelt:default.
|
|
26
|
+
* - "fastened" (default): one approval dialog per chain of gated tool calls.
|
|
24
27
|
* - "auto": chains without a dangerous call run without asking; a dangerous
|
|
25
28
|
* call still shows the approval dialog.
|
|
26
|
-
* - "
|
|
29
|
+
* - "unfastened": no approval requests at all.
|
|
27
30
|
* In headless modes (no UI) only "safe" calls are auto-allowed.
|
|
28
31
|
*/
|
|
29
32
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -34,17 +37,21 @@ import type { Model, UserMessage } from "@earendil-works/pi-ai";
|
|
|
34
37
|
import {
|
|
35
38
|
DEFAULT_SHORTCUT,
|
|
36
39
|
describeCall,
|
|
37
|
-
getHeuristicAssessment,
|
|
38
40
|
matchesTool,
|
|
39
41
|
parseAssessments,
|
|
40
42
|
formatBatch,
|
|
41
43
|
formatSingle,
|
|
44
|
+
formatUnlabeled,
|
|
45
|
+
formatHistory,
|
|
42
46
|
normalizeMode,
|
|
43
47
|
normalizeShortcut,
|
|
48
|
+
isPermissionMode,
|
|
44
49
|
nextMode,
|
|
45
50
|
plainStyle,
|
|
46
51
|
withPolicy,
|
|
47
52
|
type Assessment,
|
|
53
|
+
type ClassifyFailure,
|
|
54
|
+
type DecisionRecord,
|
|
48
55
|
type DialogStyle,
|
|
49
56
|
type PermissionMode,
|
|
50
57
|
} from "./core.ts";
|
|
@@ -52,6 +59,7 @@ import {
|
|
|
52
59
|
const CONFIG_DIR = join(homedir(), ".pi", "agent", "seatbelt");
|
|
53
60
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
54
61
|
const POLICY_PATH = join(CONFIG_DIR, "policy.md");
|
|
62
|
+
const SESSION_MODES_PATH = join(CONFIG_DIR, "session-modes.json");
|
|
55
63
|
|
|
56
64
|
const POLICY_TEMPLATE = `# Seatbelt policy
|
|
57
65
|
|
|
@@ -63,7 +71,7 @@ contains only this header, the built-in defaults apply unchanged.
|
|
|
63
71
|
|
|
64
72
|
const CONFIG_TEMPLATE = {
|
|
65
73
|
timeoutMs: 20000,
|
|
66
|
-
mode: "
|
|
74
|
+
mode: "fastened",
|
|
67
75
|
shortcut: DEFAULT_SHORTCUT,
|
|
68
76
|
tools: ["bash"],
|
|
69
77
|
};
|
|
@@ -117,8 +125,46 @@ function readPolicy(): string {
|
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
function getCurrentMode(): PermissionMode {
|
|
121
|
-
return normalizeMode(readConfig().mode);
|
|
128
|
+
function getCurrentMode(ctx: ExtensionContext): PermissionMode {
|
|
129
|
+
return sessionModes.get(ctx.sessionManager.getSessionId()) ?? normalizeMode(readConfig().mode);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Per-session mode overrides, persisted by session id so a resumed session
|
|
133
|
+
// keeps its mode. shortcut: entries are never pruned; revisit if this grows large
|
|
134
|
+
const sessionModes = new Map<string, PermissionMode>();
|
|
135
|
+
|
|
136
|
+
function loadSessionModes(): void {
|
|
137
|
+
try {
|
|
138
|
+
// Safe cast: values are validated with isPermissionMode before use.
|
|
139
|
+
const raw = JSON.parse(readFileSync(SESSION_MODES_PATH, "utf8")) as Record<string, unknown>;
|
|
140
|
+
for (const [id, mode] of Object.entries(raw)) {
|
|
141
|
+
if (isPermissionMode(mode)) sessionModes.set(id, mode);
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function setSessionMode(ctx: ExtensionContext, mode: PermissionMode): void {
|
|
147
|
+
sessionModes.set(ctx.sessionManager.getSessionId(), mode);
|
|
148
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
149
|
+
writeFileSync(SESSION_MODES_PATH, `${JSON.stringify(Object.fromEntries(sessionModes), null, 2)}\n`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// User decisions from the current session, fed to the classifier as context.
|
|
153
|
+
const sessionHistory = new Map<string, DecisionRecord[]>();
|
|
154
|
+
|
|
155
|
+
function recordDecisions(
|
|
156
|
+
ctx: ExtensionContext,
|
|
157
|
+
entries: readonly GatedCall[],
|
|
158
|
+
displays: readonly string[],
|
|
159
|
+
isAllowed: boolean,
|
|
160
|
+
rejectMessage?: string,
|
|
161
|
+
): void {
|
|
162
|
+
const id = ctx.sessionManager.getSessionId();
|
|
163
|
+
const history = sessionHistory.get(id) ?? [];
|
|
164
|
+
for (const [i, entry] of entries.entries()) {
|
|
165
|
+
history.push({ tool: entry.tool, display: displays[i], allowed: isAllowed, reason: isAllowed ? undefined : rejectMessage });
|
|
166
|
+
}
|
|
167
|
+
sessionHistory.set(id, history);
|
|
122
168
|
}
|
|
123
169
|
|
|
124
170
|
function getGatedTools(): string[] {
|
|
@@ -146,10 +192,14 @@ For each call return:
|
|
|
146
192
|
- "purpose": one line, max 12 words, saying what the call does for the task.
|
|
147
193
|
- "risk": one line, max 12 words, saying WHY the call is dangerous; the empty string "" when danger is not "dangerous".
|
|
148
194
|
|
|
195
|
+
When earlier user decisions from this session are given, use them as context:
|
|
196
|
+
a call similar to one the user approved leans safer; a call similar to one the
|
|
197
|
+
user rejected, especially with a stated reason, leans dangerous.
|
|
198
|
+
|
|
149
199
|
Respond with ONLY a JSON array in input order, no markdown fences:
|
|
150
200
|
[{"danger":"safe","purpose":"...","risk":"..."}]`;
|
|
151
201
|
|
|
152
|
-
type Decision = { isAllowed: boolean;
|
|
202
|
+
type Decision = { isAllowed: boolean; rejectMessage?: string };
|
|
153
203
|
|
|
154
204
|
const decisions = new Map<string, Decision>();
|
|
155
205
|
|
|
@@ -159,20 +209,34 @@ type GatedCall = {
|
|
|
159
209
|
args: Record<string, unknown>;
|
|
160
210
|
};
|
|
161
211
|
|
|
212
|
+
/** classifyCalls result: labels on success, the failure reason otherwise. */
|
|
213
|
+
type ClassifyResult = { ok: true; assessments: Assessment[] } | { ok: false; failure: ClassifyFailure };
|
|
214
|
+
|
|
162
215
|
async function classifyCalls(
|
|
163
216
|
calls: GatedCall[],
|
|
164
217
|
taskContext: string,
|
|
165
218
|
ctx: ExtensionContext,
|
|
166
|
-
): Promise<
|
|
219
|
+
): Promise<ClassifyResult> {
|
|
167
220
|
const { model: spec, timeoutMs = 20000 } = readConfig();
|
|
168
221
|
const system = withPolicy(CLASSIFIER_SYSTEM, readPolicy());
|
|
169
222
|
const inputs = calls.map((c) => ({ tool: c.tool, input: describeCall(c.tool, c.args) }));
|
|
170
223
|
const model = findModel(spec, ctx);
|
|
171
|
-
if (!model) return
|
|
224
|
+
if (!model) return { ok: false, failure: "no-model" };
|
|
172
225
|
|
|
173
226
|
const user: UserMessage = {
|
|
174
227
|
role: "user",
|
|
175
|
-
content: [
|
|
228
|
+
content: [
|
|
229
|
+
{
|
|
230
|
+
type: "text",
|
|
231
|
+
text: [
|
|
232
|
+
`Task context:\n${taskContext || "(none)"}`,
|
|
233
|
+
formatHistory(sessionHistory.get(ctx.sessionManager.getSessionId()) ?? []),
|
|
234
|
+
`Tool calls:\n${JSON.stringify(inputs)}`,
|
|
235
|
+
]
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.join("\n\n"),
|
|
238
|
+
},
|
|
239
|
+
],
|
|
176
240
|
timestamp: Date.now(),
|
|
177
241
|
};
|
|
178
242
|
try {
|
|
@@ -186,9 +250,9 @@ async function classifyCalls(
|
|
|
186
250
|
.map((c) => c.text)
|
|
187
251
|
.join("\n");
|
|
188
252
|
const parsed = parseAssessments(text, calls.length);
|
|
189
|
-
if (parsed) return parsed;
|
|
253
|
+
if (parsed) return { ok: true, assessments: parsed };
|
|
190
254
|
} catch {}
|
|
191
|
-
return
|
|
255
|
+
return { ok: false, failure: "unavailable" };
|
|
192
256
|
}
|
|
193
257
|
|
|
194
258
|
const DANGER_COLOR = {
|
|
@@ -209,6 +273,15 @@ function getDialogStyle(ctx: ExtensionContext): DialogStyle {
|
|
|
209
273
|
};
|
|
210
274
|
}
|
|
211
275
|
|
|
276
|
+
function notifyClassifyFailure(failure: ClassifyFailure, ctx: ExtensionContext): void {
|
|
277
|
+
if (failure === "no-model" && ctx.hasUI) {
|
|
278
|
+
ctx.ui.notify(
|
|
279
|
+
"seatbelt: no classifier model set, so every gated call requires approval. Pick one with /seatbelt.",
|
|
280
|
+
"warning",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
212
285
|
// Runs the approval dialog (or headless fallback) and stores one decision per
|
|
213
286
|
// call. Returns the rejection message when the user wrote one.
|
|
214
287
|
async function approveWithDialog(
|
|
@@ -237,33 +310,50 @@ async function approveBatch(
|
|
|
237
310
|
taskContext: string,
|
|
238
311
|
ctx: ExtensionContext,
|
|
239
312
|
): Promise<void> {
|
|
240
|
-
const mode = getCurrentMode();
|
|
241
|
-
if (mode === "
|
|
242
|
-
const
|
|
313
|
+
const mode = getCurrentMode(ctx);
|
|
314
|
+
if (mode === "unfastened") return;
|
|
315
|
+
const result = await classifyCalls(entries, taskContext, ctx);
|
|
243
316
|
const displays = entries.map((e) => describeCall(e.tool, e.args));
|
|
244
317
|
let isAllowed: boolean;
|
|
245
318
|
let rejectMessage: string | undefined;
|
|
246
|
-
if (
|
|
319
|
+
if (!result.ok) {
|
|
320
|
+
// classifier failed: always ask, no matter the mode (fastened/auto); fail-safe without a UI
|
|
321
|
+
notifyClassifyFailure(result.failure, ctx);
|
|
322
|
+
if (ctx.hasUI) {
|
|
323
|
+
const decision = await approveWithDialog(
|
|
324
|
+
formatUnlabeled(displays, result.failure, getDialogStyle(ctx)),
|
|
325
|
+
entries.length === 1,
|
|
326
|
+
ctx,
|
|
327
|
+
);
|
|
328
|
+
isAllowed = decision.isAllowed;
|
|
329
|
+
rejectMessage = decision.rejectMessage;
|
|
330
|
+
recordDecisions(ctx, entries, displays, isAllowed, rejectMessage);
|
|
331
|
+
} else {
|
|
332
|
+
isAllowed = false;
|
|
333
|
+
}
|
|
334
|
+
} else if (mode === "auto" && result.assessments.every((a) => a.danger !== "dangerous")) {
|
|
247
335
|
isAllowed = true;
|
|
248
336
|
} else if (ctx.hasUI) {
|
|
249
337
|
const decision = await approveWithDialog(
|
|
250
|
-
formatBatch(displays, assessments, getDialogStyle(ctx)),
|
|
338
|
+
formatBatch(displays, result.assessments, getDialogStyle(ctx)),
|
|
251
339
|
entries.length === 1,
|
|
252
340
|
ctx,
|
|
253
341
|
);
|
|
254
342
|
isAllowed = decision.isAllowed;
|
|
255
343
|
rejectMessage = decision.rejectMessage;
|
|
344
|
+
recordDecisions(ctx, entries, displays, isAllowed, rejectMessage);
|
|
256
345
|
} else {
|
|
257
346
|
// no UI: fail-safe, only clearly safe calls pass without a human
|
|
258
|
-
isAllowed = assessments.every((a) => a.danger === "safe");
|
|
347
|
+
isAllowed = result.assessments.every((a) => a.danger === "safe");
|
|
259
348
|
}
|
|
260
|
-
for (const
|
|
261
|
-
decisions.set(entry.id, { isAllowed,
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
decisions.set(entry.id, { isAllowed, rejectMessage });
|
|
262
351
|
}
|
|
263
352
|
}
|
|
264
353
|
|
|
265
354
|
export default function (pi: ExtensionAPI) {
|
|
266
355
|
ensureConfigFiles();
|
|
356
|
+
loadSessionModes();
|
|
267
357
|
|
|
268
358
|
// The assistant message is finalized before its tool calls execute, so the
|
|
269
359
|
// whole chain is known here and can be approved in one dialog.
|
|
@@ -277,7 +367,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
277
367
|
)
|
|
278
368
|
.map((c) => ({ id: c.id, tool: c.name, args: c.arguments ?? {} }));
|
|
279
369
|
if (calls.length === 0) return;
|
|
280
|
-
if (getCurrentMode() === "
|
|
370
|
+
if (getCurrentMode(ctx) === "unfastened") return;
|
|
281
371
|
const taskContext = message.content
|
|
282
372
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
283
373
|
.map((c) => c.text)
|
|
@@ -288,27 +378,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
288
378
|
|
|
289
379
|
pi.on("tool_call", async (event, ctx) => {
|
|
290
380
|
if (!matchesTool(getGatedTools(), event.toolName)) return undefined;
|
|
291
|
-
if (getCurrentMode() === "
|
|
381
|
+
if (getCurrentMode(ctx) === "unfastened") return undefined;
|
|
292
382
|
if (!decisions.has(event.toolCallId)) {
|
|
293
383
|
// Nested or otherwise unseen call (for example a codemode script): approve it on its own
|
|
294
384
|
const entry: GatedCall = { id: event.toolCallId, tool: event.toolName, args: getCallArgs(event.input) };
|
|
295
|
-
const
|
|
385
|
+
const result = await classifyCalls([entry], "", ctx);
|
|
296
386
|
let isAllowed: boolean;
|
|
297
387
|
let rejectMessage: string | undefined;
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
388
|
+
if (!result.ok) {
|
|
389
|
+
// classifier failed: always ask, no matter the mode (fastened/auto); fail-safe without a UI
|
|
390
|
+
notifyClassifyFailure(result.failure, ctx);
|
|
391
|
+
if (ctx.hasUI) {
|
|
392
|
+
const decision = await approveWithDialog(
|
|
393
|
+
formatUnlabeled([describeCall(entry.tool, entry.args)], result.failure, getDialogStyle(ctx)),
|
|
394
|
+
true,
|
|
395
|
+
ctx,
|
|
396
|
+
);
|
|
397
|
+
isAllowed = decision.isAllowed;
|
|
398
|
+
rejectMessage = decision.rejectMessage;
|
|
399
|
+
recordDecisions(ctx, [entry], [describeCall(entry.tool, entry.args)], isAllowed, rejectMessage);
|
|
400
|
+
} else {
|
|
401
|
+
isAllowed = false;
|
|
402
|
+
}
|
|
308
403
|
} else {
|
|
309
|
-
|
|
404
|
+
const assessment = result.assessments[0];
|
|
405
|
+
if (getCurrentMode(ctx) === "auto" && assessment.danger !== "dangerous") {
|
|
406
|
+
isAllowed = true;
|
|
407
|
+
} else if (ctx.hasUI) {
|
|
408
|
+
const decision = await approveWithDialog(
|
|
409
|
+
formatSingle(describeCall(entry.tool, entry.args), assessment, getDialogStyle(ctx)),
|
|
410
|
+
true,
|
|
411
|
+
ctx,
|
|
412
|
+
);
|
|
413
|
+
isAllowed = decision.isAllowed;
|
|
414
|
+
rejectMessage = decision.rejectMessage;
|
|
415
|
+
recordDecisions(ctx, [entry], [describeCall(entry.tool, entry.args)], isAllowed, rejectMessage);
|
|
416
|
+
} else {
|
|
417
|
+
isAllowed = assessment.danger === "safe";
|
|
418
|
+
}
|
|
310
419
|
}
|
|
311
|
-
decisions.set(event.toolCallId, { isAllowed,
|
|
420
|
+
decisions.set(event.toolCallId, { isAllowed, rejectMessage });
|
|
312
421
|
}
|
|
313
422
|
const decision = decisions.get(event.toolCallId);
|
|
314
423
|
decisions.delete(event.toolCallId);
|
|
@@ -325,7 +434,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
325
434
|
pi.registerCommand("seatbelt", {
|
|
326
435
|
description: "Show or set the model that labels tool calls for approval",
|
|
327
436
|
handler: async (args, ctx) => {
|
|
328
|
-
const current = readConfig().model ?? "(none,
|
|
437
|
+
const current = readConfig().model ?? "(none, every gated call requires approval)";
|
|
329
438
|
let value = args.trim();
|
|
330
439
|
if (!value) {
|
|
331
440
|
if (!ctx.hasUI) {
|
|
@@ -355,25 +464,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
355
464
|
});
|
|
356
465
|
|
|
357
466
|
const MODE_HINTS = {
|
|
358
|
-
|
|
467
|
+
fastened: "every chain of gated tool calls needs one approval",
|
|
359
468
|
auto: "auto-approve unless a call is dangerous",
|
|
360
|
-
|
|
469
|
+
unfastened: "no approval requests",
|
|
470
|
+
} satisfies Record<PermissionMode, string>;
|
|
471
|
+
|
|
472
|
+
// Status-bar wording: gated (fastened), no gating (unfastened), the
|
|
473
|
+
// labeller decides what runs (auto).
|
|
474
|
+
const MODE_LABELS = {
|
|
475
|
+
fastened: "fastened 🔒",
|
|
476
|
+
auto: "auto 🤖",
|
|
477
|
+
unfastened: "unfastened 💀",
|
|
361
478
|
} satisfies Record<PermissionMode, string>;
|
|
362
479
|
|
|
480
|
+
const MODE_COLOR = {
|
|
481
|
+
fastened: "success",
|
|
482
|
+
auto: "warning",
|
|
483
|
+
unfastened: "error",
|
|
484
|
+
} satisfies Record<PermissionMode, "success" | "warning" | "error">;
|
|
485
|
+
|
|
363
486
|
function updateStatus(ctx: ExtensionContext): void {
|
|
364
487
|
if (!ctx.hasUI) return;
|
|
365
|
-
const mode = getCurrentMode();
|
|
366
|
-
const
|
|
367
|
-
const text =
|
|
368
|
-
ctx.mode === "tui" ? ctx.ui.theme.fg(color, `perm:${mode}`) : `perm:${mode}`;
|
|
488
|
+
const mode = getCurrentMode(ctx);
|
|
489
|
+
const label = `seatbelt:${MODE_LABELS[mode]}`;
|
|
490
|
+
const text = ctx.mode === "tui" ? ctx.ui.theme.fg(MODE_COLOR[mode], label) : label;
|
|
369
491
|
ctx.ui.setStatus("seatbelt", text);
|
|
370
492
|
}
|
|
371
493
|
|
|
494
|
+
pi.registerCommand("seatbelt:mode", {
|
|
495
|
+
description: "Switch seatbelt mode (fastened, auto, unfastened); no argument cycles",
|
|
496
|
+
handler: async (args, ctx) => {
|
|
497
|
+
const wanted = args.trim();
|
|
498
|
+
if (wanted && !isPermissionMode(wanted)) {
|
|
499
|
+
ctx.ui.notify(`Unknown mode: ${wanted}. Use fastened, auto, or unfastened.`, "error");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// Safe cast: wanted is non-empty and passed isPermissionMode above.
|
|
503
|
+
const mode = (wanted || nextMode(getCurrentMode(ctx))) as PermissionMode;
|
|
504
|
+
setSessionMode(ctx, mode);
|
|
505
|
+
updateStatus(ctx);
|
|
506
|
+
ctx.ui.notify(`Seatbelt: ${mode} (${MODE_HINTS[mode]}), this session only`, "info");
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
pi.registerCommand("seatbelt:default", {
|
|
511
|
+
description: "Set the default mode for new sessions (fastened, auto, unfastened); no argument shows it",
|
|
512
|
+
handler: async (args, ctx) => {
|
|
513
|
+
const wanted = args.trim();
|
|
514
|
+
if (!wanted) {
|
|
515
|
+
const current = normalizeMode(readConfig().mode);
|
|
516
|
+
ctx.ui.notify(
|
|
517
|
+
`Default mode for new sessions: ${current}. Usage: /seatbelt:default fastened|auto|unfastened`,
|
|
518
|
+
"info",
|
|
519
|
+
);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (!isPermissionMode(wanted)) {
|
|
523
|
+
ctx.ui.notify(`Unknown mode: ${wanted}. Use fastened, auto, or unfastened.`, "error");
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
writeConfig({ ...readConfig(), mode: wanted });
|
|
527
|
+
ctx.ui.notify(
|
|
528
|
+
`New sessions will start ${wanted} (${MODE_HINTS[wanted]}). This session stays ${getCurrentMode(ctx)}.`,
|
|
529
|
+
"info",
|
|
530
|
+
);
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
372
534
|
pi.on("session_start", (_event, ctx) => {
|
|
373
535
|
updateStatus(ctx);
|
|
374
536
|
if (hasSeededFreshConfig && ctx.hasUI) {
|
|
375
537
|
ctx.ui.notify(
|
|
376
|
-
"seatbelt: no labeller model set,
|
|
538
|
+
"seatbelt: no labeller model set, so every gated call requires approval. Pick one with /seatbelt.",
|
|
377
539
|
"info",
|
|
378
540
|
);
|
|
379
541
|
}
|
|
@@ -385,12 +547,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
385
547
|
ExtensionAPI["registerShortcut"]
|
|
386
548
|
>[0];
|
|
387
549
|
pi.registerShortcut(shortcut, {
|
|
388
|
-
description: "Cycle seatbelt mode (
|
|
550
|
+
description: "Cycle seatbelt mode (fastened, auto, unfastened)",
|
|
389
551
|
handler: (ctx) => {
|
|
390
|
-
const mode = nextMode(getCurrentMode());
|
|
391
|
-
|
|
552
|
+
const mode = nextMode(getCurrentMode(ctx));
|
|
553
|
+
setSessionMode(ctx, mode);
|
|
392
554
|
updateStatus(ctx);
|
|
393
|
-
if (ctx.hasUI) ctx.ui.notify(`Seatbelt: ${mode} (${MODE_HINTS[mode]})`, "info");
|
|
555
|
+
if (ctx.hasUI) ctx.ui.notify(`Seatbelt: ${mode} (${MODE_HINTS[mode]}), this session only`, "info");
|
|
394
556
|
},
|
|
395
557
|
});
|
|
396
558
|
}
|
package/package.json
CHANGED
package/smoke.mjs
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import {
|
|
3
3
|
describeCall,
|
|
4
|
-
getHeuristicAssessment,
|
|
5
4
|
matchesTool,
|
|
6
5
|
parseAssessments,
|
|
7
6
|
formatBatch,
|
|
8
7
|
formatSingle,
|
|
8
|
+
formatUnlabeled,
|
|
9
|
+
formatHistory,
|
|
9
10
|
normalizeMode,
|
|
10
11
|
normalizeShortcut,
|
|
12
|
+
isPermissionMode,
|
|
11
13
|
nextMode,
|
|
12
14
|
withPolicy,
|
|
13
15
|
} from "./core.ts";
|
|
14
16
|
|
|
15
17
|
// permission modes
|
|
16
|
-
assert.equal(normalizeMode(undefined), "
|
|
18
|
+
assert.equal(normalizeMode(undefined), "fastened");
|
|
19
|
+
assert.equal(normalizeMode("fastened"), "fastened");
|
|
17
20
|
assert.equal(normalizeMode("auto"), "auto");
|
|
18
|
-
assert.equal(normalizeMode("
|
|
19
|
-
|
|
20
|
-
assert.equal(
|
|
21
|
-
assert.equal(
|
|
22
|
-
assert.equal(
|
|
21
|
+
assert.equal(normalizeMode("unfastened"), "unfastened");
|
|
22
|
+
// pre-rename values are no longer valid and fall back to the default
|
|
23
|
+
assert.equal(normalizeMode("all"), "fastened");
|
|
24
|
+
assert.equal(normalizeMode("off"), "fastened");
|
|
25
|
+
assert.equal(normalizeMode("nonsense"), "fastened");
|
|
26
|
+
assert.equal(nextMode("fastened"), "auto");
|
|
27
|
+
assert.equal(nextMode("auto"), "unfastened");
|
|
28
|
+
assert.equal(nextMode("unfastened"), "fastened");
|
|
29
|
+
assert.ok(isPermissionMode("fastened"));
|
|
30
|
+
assert.ok(isPermissionMode("auto"));
|
|
31
|
+
assert.ok(isPermissionMode("unfastened"));
|
|
32
|
+
assert.ok(!isPermissionMode("all"));
|
|
33
|
+
assert.ok(!isPermissionMode("off"));
|
|
34
|
+
assert.ok(!isPermissionMode("nonsense"));
|
|
35
|
+
assert.ok(!isPermissionMode(undefined));
|
|
23
36
|
|
|
24
37
|
// shortcut normalization
|
|
25
38
|
assert.equal(normalizeShortcut(undefined), "alt+b");
|
|
@@ -55,28 +68,24 @@ assert.equal(describeCall("read", { path: "a.txt" }), 'read {"path":"a.txt"}');
|
|
|
55
68
|
assert.equal(describeCall("ls", {}), "ls");
|
|
56
69
|
assert.ok(describeCall("write", { path: "a", content: "x".repeat(600) }).includes("…(truncated)"));
|
|
57
70
|
|
|
58
|
-
//
|
|
59
|
-
assert.equal(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
assert.
|
|
66
|
-
assert.
|
|
67
|
-
assert.
|
|
68
|
-
assert.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
assert.
|
|
74
|
-
assert.
|
|
75
|
-
assert.
|
|
76
|
-
assert.equal(getHeuristicAssessment("find", "find .").danger, "safe");
|
|
77
|
-
assert.equal(getHeuristicAssessment("write", 'write {"path":"a"}').danger, "caution");
|
|
78
|
-
assert.equal(getHeuristicAssessment("edit", "edit diff").danger, "caution");
|
|
79
|
-
assert.equal(getHeuristicAssessment("mcp__slack__send_message", "send").danger, "caution");
|
|
71
|
+
// session decision history formatting
|
|
72
|
+
assert.equal(formatHistory([]), "");
|
|
73
|
+
const history = formatHistory([
|
|
74
|
+
{ tool: "bash", display: "npm install", allowed: true },
|
|
75
|
+
{ tool: "bash", display: "rm -rf build", allowed: false, reason: "keep build artifacts" },
|
|
76
|
+
{ tool: "bash", display: "ls -la", allowed: false },
|
|
77
|
+
]);
|
|
78
|
+
assert.match(history, /Earlier user decisions in this session \(most recent last\):/);
|
|
79
|
+
assert.match(history, /- approved: npm install$/m);
|
|
80
|
+
assert.match(history, /- rejected: rm -rf build \(user said: keep build artifacts\)$/m);
|
|
81
|
+
assert.match(history, /- rejected: ls -la$/m);
|
|
82
|
+
// only the last 20 records make it into the prompt
|
|
83
|
+
const many = formatHistory(
|
|
84
|
+
Array.from({ length: 30 }, (_, i) => ({ tool: "bash", display: `cmd ${i}`, allowed: true })),
|
|
85
|
+
);
|
|
86
|
+
assert.doesNotMatch(many, /cmd 9$/m);
|
|
87
|
+
assert.match(many, /- approved: cmd 10$/m);
|
|
88
|
+
assert.match(many, /- approved: cmd 29$/m);
|
|
80
89
|
|
|
81
90
|
// parser
|
|
82
91
|
assert.deepEqual(
|
|
@@ -102,11 +111,6 @@ assert.deepEqual(
|
|
|
102
111
|
["safe", "dangerous"],
|
|
103
112
|
);
|
|
104
113
|
|
|
105
|
-
// heuristics carry a risk line only for dangerous calls
|
|
106
|
-
assert.ok(getHeuristicAssessment("bash", "rm -rf /tmp/x").risk.length > 0);
|
|
107
|
-
assert.equal(getHeuristicAssessment("bash", "ls").risk, "");
|
|
108
|
-
assert.equal(getHeuristicAssessment("read", "read").risk, "");
|
|
109
|
-
|
|
110
114
|
// formatting
|
|
111
115
|
const batch = formatBatch(
|
|
112
116
|
["ls -la", "rm -rf /tmp/batch-demo"],
|
|
@@ -137,4 +141,23 @@ assert.match(
|
|
|
137
141
|
// plain style passes fragments through untouched
|
|
138
142
|
assert.doesNotMatch(formatBatch(["ls"], [{ danger: "safe", purpose: "x", risk: "" }]), /\u001b\[/);
|
|
139
143
|
|
|
144
|
+
// unlabeled dialog when the classifier fails
|
|
145
|
+
const unlabeledSingle = formatUnlabeled(["ls -la"], "unavailable");
|
|
146
|
+
assert.match(unlabeledSingle, /Approve this tool call\?/);
|
|
147
|
+
assert.match(unlabeledSingle, /! classifier unavailable: these calls could not be labeled/);
|
|
148
|
+
assert.match(unlabeledSingle, /> ls -la/);
|
|
149
|
+
assert.ok(!unlabeledSingle.includes("One approval covers"));
|
|
150
|
+
assert.ok(!unlabeledSingle.includes("What it does:"));
|
|
151
|
+
const unlabeledBatch = formatUnlabeled(["ls -la", "rm -rf /tmp/batch-demo"], "unavailable");
|
|
152
|
+
assert.match(unlabeledBatch, /Approve 2 tool calls\?/);
|
|
153
|
+
assert.match(unlabeledBatch, /> ls -la/);
|
|
154
|
+
assert.match(unlabeledBatch, /> rm -rf \/tmp\/batch-demo/);
|
|
155
|
+
assert.match(unlabeledBatch, /One approval covers every tool call/);
|
|
156
|
+
assert.ok(!unlabeledBatch.includes("What it does:"));
|
|
157
|
+
assert.ok(!unlabeledBatch.includes("DANGEROUS"));
|
|
158
|
+
// the no-model failure gets a more specific warning
|
|
159
|
+
const noModelSingle = formatUnlabeled(["ls -la"], "no-model");
|
|
160
|
+
assert.match(noModelSingle, /! no classifier model set: these calls could not be labeled \(pick one with \/seatbelt\)/);
|
|
161
|
+
assert.ok(!noModelSingle.includes("classifier unavailable"));
|
|
162
|
+
|
|
140
163
|
console.log("pi-seatbelt core: all checks passed");
|