pi-seatbelt 0.3.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 +69 -52
- package/assets/logo.svg +33 -0
- package/core.ts +50 -35
- package/index.ts +175 -43
- package/package.json +1 -1
- package/smoke.mjs +39 -28
|
@@ -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,32 +43,35 @@ 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)). Or
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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).
|
|
66
|
+
|
|
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).
|
|
52
71
|
|
|
53
72
|
- `fastened`: every chain asks once. The default.
|
|
54
|
-
- `auto`: no dialog unless something is `DANGEROUS`.
|
|
55
|
-
|
|
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.
|
|
56
75
|
- `unfastened`: no gating, no classifier calls, no cost.
|
|
57
76
|
|
|
58
77
|
Headless modes (print, JSON) have no dialogs: only `safe` calls auto-run,
|
|
@@ -61,9 +80,9 @@ everything else is blocked with a reason.
|
|
|
61
80
|
Calls made through nested execution (codemode scripts) never appear in an
|
|
62
81
|
assistant message, so they are approved one at a time as a fallback.
|
|
63
82
|
|
|
64
|
-
##
|
|
83
|
+
## What counts as dangerous is your call
|
|
65
84
|
|
|
66
|
-
The labels come from the classifier model. You steer
|
|
85
|
+
The labels come from the classifier model. You steer it with a plain
|
|
67
86
|
markdown file: `~/.pi/agent/seatbelt/policy.md`.
|
|
68
87
|
|
|
69
88
|
- Write plain sentences. No syntax to learn.
|
|
@@ -83,8 +102,8 @@ Example:
|
|
|
83
102
|
- Treat any command that writes outside /tmp as dangerous.
|
|
84
103
|
```
|
|
85
104
|
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
Policy shapes the model's labels. With no model configured the policy file
|
|
106
|
+
is not consulted; gated calls always require approval.
|
|
88
107
|
|
|
89
108
|
## Configure
|
|
90
109
|
|
|
@@ -104,28 +123,26 @@ built-in regex fallback labels calls and the policy file is not consulted.
|
|
|
104
123
|
`mcp__github__create_issue`) or prefix patterns (`mcp__slack__*` for a
|
|
105
124
|
whole server, `*` for everything). Non-bash calls are shown to the
|
|
106
125
|
labeller as tool name plus arguments, truncated to 500 characters.
|
|
107
|
-
- `shortcut`: the key that cycles the
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
`
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
118
138
|
- `model`: the labeller. Pick one with `/seatbelt`, or pass it directly:
|
|
119
139
|
`/seatbelt litellm/gpt-4o-mini`. Fast and cheap works well; the prompt
|
|
120
|
-
is tiny. Without a model, or if the call fails or times out, the
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
```sh
|
|
130
|
-
node smoke.mjs
|
|
131
|
-
```
|
|
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
|
@@ -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,11 +15,14 @@
|
|
|
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):
|
|
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.
|
|
23
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.
|
|
@@ -34,11 +37,12 @@ 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,
|
|
44
48
|
isPermissionMode,
|
|
@@ -46,6 +50,8 @@ import {
|
|
|
46
50
|
plainStyle,
|
|
47
51
|
withPolicy,
|
|
48
52
|
type Assessment,
|
|
53
|
+
type ClassifyFailure,
|
|
54
|
+
type DecisionRecord,
|
|
49
55
|
type DialogStyle,
|
|
50
56
|
type PermissionMode,
|
|
51
57
|
} from "./core.ts";
|
|
@@ -53,6 +59,7 @@ import {
|
|
|
53
59
|
const CONFIG_DIR = join(homedir(), ".pi", "agent", "seatbelt");
|
|
54
60
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
55
61
|
const POLICY_PATH = join(CONFIG_DIR, "policy.md");
|
|
62
|
+
const SESSION_MODES_PATH = join(CONFIG_DIR, "session-modes.json");
|
|
56
63
|
|
|
57
64
|
const POLICY_TEMPLATE = `# Seatbelt policy
|
|
58
65
|
|
|
@@ -118,8 +125,46 @@ function readPolicy(): string {
|
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
function getCurrentMode(): PermissionMode {
|
|
122
|
-
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);
|
|
123
168
|
}
|
|
124
169
|
|
|
125
170
|
function getGatedTools(): string[] {
|
|
@@ -147,10 +192,14 @@ For each call return:
|
|
|
147
192
|
- "purpose": one line, max 12 words, saying what the call does for the task.
|
|
148
193
|
- "risk": one line, max 12 words, saying WHY the call is dangerous; the empty string "" when danger is not "dangerous".
|
|
149
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
|
+
|
|
150
199
|
Respond with ONLY a JSON array in input order, no markdown fences:
|
|
151
200
|
[{"danger":"safe","purpose":"...","risk":"..."}]`;
|
|
152
201
|
|
|
153
|
-
type Decision = { isAllowed: boolean;
|
|
202
|
+
type Decision = { isAllowed: boolean; rejectMessage?: string };
|
|
154
203
|
|
|
155
204
|
const decisions = new Map<string, Decision>();
|
|
156
205
|
|
|
@@ -160,20 +209,34 @@ type GatedCall = {
|
|
|
160
209
|
args: Record<string, unknown>;
|
|
161
210
|
};
|
|
162
211
|
|
|
212
|
+
/** classifyCalls result: labels on success, the failure reason otherwise. */
|
|
213
|
+
type ClassifyResult = { ok: true; assessments: Assessment[] } | { ok: false; failure: ClassifyFailure };
|
|
214
|
+
|
|
163
215
|
async function classifyCalls(
|
|
164
216
|
calls: GatedCall[],
|
|
165
217
|
taskContext: string,
|
|
166
218
|
ctx: ExtensionContext,
|
|
167
|
-
): Promise<
|
|
219
|
+
): Promise<ClassifyResult> {
|
|
168
220
|
const { model: spec, timeoutMs = 20000 } = readConfig();
|
|
169
221
|
const system = withPolicy(CLASSIFIER_SYSTEM, readPolicy());
|
|
170
222
|
const inputs = calls.map((c) => ({ tool: c.tool, input: describeCall(c.tool, c.args) }));
|
|
171
223
|
const model = findModel(spec, ctx);
|
|
172
|
-
if (!model) return
|
|
224
|
+
if (!model) return { ok: false, failure: "no-model" };
|
|
173
225
|
|
|
174
226
|
const user: UserMessage = {
|
|
175
227
|
role: "user",
|
|
176
|
-
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
|
+
],
|
|
177
240
|
timestamp: Date.now(),
|
|
178
241
|
};
|
|
179
242
|
try {
|
|
@@ -187,9 +250,9 @@ async function classifyCalls(
|
|
|
187
250
|
.map((c) => c.text)
|
|
188
251
|
.join("\n");
|
|
189
252
|
const parsed = parseAssessments(text, calls.length);
|
|
190
|
-
if (parsed) return parsed;
|
|
253
|
+
if (parsed) return { ok: true, assessments: parsed };
|
|
191
254
|
} catch {}
|
|
192
|
-
return
|
|
255
|
+
return { ok: false, failure: "unavailable" };
|
|
193
256
|
}
|
|
194
257
|
|
|
195
258
|
const DANGER_COLOR = {
|
|
@@ -210,6 +273,15 @@ function getDialogStyle(ctx: ExtensionContext): DialogStyle {
|
|
|
210
273
|
};
|
|
211
274
|
}
|
|
212
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
|
+
|
|
213
285
|
// Runs the approval dialog (or headless fallback) and stores one decision per
|
|
214
286
|
// call. Returns the rejection message when the user wrote one.
|
|
215
287
|
async function approveWithDialog(
|
|
@@ -238,33 +310,50 @@ async function approveBatch(
|
|
|
238
310
|
taskContext: string,
|
|
239
311
|
ctx: ExtensionContext,
|
|
240
312
|
): Promise<void> {
|
|
241
|
-
const mode = getCurrentMode();
|
|
313
|
+
const mode = getCurrentMode(ctx);
|
|
242
314
|
if (mode === "unfastened") return;
|
|
243
|
-
const
|
|
315
|
+
const result = await classifyCalls(entries, taskContext, ctx);
|
|
244
316
|
const displays = entries.map((e) => describeCall(e.tool, e.args));
|
|
245
317
|
let isAllowed: boolean;
|
|
246
318
|
let rejectMessage: string | undefined;
|
|
247
|
-
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")) {
|
|
248
335
|
isAllowed = true;
|
|
249
336
|
} else if (ctx.hasUI) {
|
|
250
337
|
const decision = await approveWithDialog(
|
|
251
|
-
formatBatch(displays, assessments, getDialogStyle(ctx)),
|
|
338
|
+
formatBatch(displays, result.assessments, getDialogStyle(ctx)),
|
|
252
339
|
entries.length === 1,
|
|
253
340
|
ctx,
|
|
254
341
|
);
|
|
255
342
|
isAllowed = decision.isAllowed;
|
|
256
343
|
rejectMessage = decision.rejectMessage;
|
|
344
|
+
recordDecisions(ctx, entries, displays, isAllowed, rejectMessage);
|
|
257
345
|
} else {
|
|
258
346
|
// no UI: fail-safe, only clearly safe calls pass without a human
|
|
259
|
-
isAllowed = assessments.every((a) => a.danger === "safe");
|
|
347
|
+
isAllowed = result.assessments.every((a) => a.danger === "safe");
|
|
260
348
|
}
|
|
261
|
-
for (const
|
|
262
|
-
decisions.set(entry.id, { isAllowed,
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
decisions.set(entry.id, { isAllowed, rejectMessage });
|
|
263
351
|
}
|
|
264
352
|
}
|
|
265
353
|
|
|
266
354
|
export default function (pi: ExtensionAPI) {
|
|
267
355
|
ensureConfigFiles();
|
|
356
|
+
loadSessionModes();
|
|
268
357
|
|
|
269
358
|
// The assistant message is finalized before its tool calls execute, so the
|
|
270
359
|
// whole chain is known here and can be approved in one dialog.
|
|
@@ -278,7 +367,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
278
367
|
)
|
|
279
368
|
.map((c) => ({ id: c.id, tool: c.name, args: c.arguments ?? {} }));
|
|
280
369
|
if (calls.length === 0) return;
|
|
281
|
-
if (getCurrentMode() === "unfastened") return;
|
|
370
|
+
if (getCurrentMode(ctx) === "unfastened") return;
|
|
282
371
|
const taskContext = message.content
|
|
283
372
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
284
373
|
.map((c) => c.text)
|
|
@@ -289,27 +378,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
289
378
|
|
|
290
379
|
pi.on("tool_call", async (event, ctx) => {
|
|
291
380
|
if (!matchesTool(getGatedTools(), event.toolName)) return undefined;
|
|
292
|
-
if (getCurrentMode() === "unfastened") return undefined;
|
|
381
|
+
if (getCurrentMode(ctx) === "unfastened") return undefined;
|
|
293
382
|
if (!decisions.has(event.toolCallId)) {
|
|
294
383
|
// Nested or otherwise unseen call (for example a codemode script): approve it on its own
|
|
295
384
|
const entry: GatedCall = { id: event.toolCallId, tool: event.toolName, args: getCallArgs(event.input) };
|
|
296
|
-
const
|
|
385
|
+
const result = await classifyCalls([entry], "", ctx);
|
|
297
386
|
let isAllowed: boolean;
|
|
298
387
|
let rejectMessage: string | undefined;
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
}
|
|
309
403
|
} else {
|
|
310
|
-
|
|
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
|
+
}
|
|
311
419
|
}
|
|
312
|
-
decisions.set(event.toolCallId, { isAllowed,
|
|
420
|
+
decisions.set(event.toolCallId, { isAllowed, rejectMessage });
|
|
313
421
|
}
|
|
314
422
|
const decision = decisions.get(event.toolCallId);
|
|
315
423
|
decisions.delete(event.toolCallId);
|
|
@@ -326,7 +434,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
326
434
|
pi.registerCommand("seatbelt", {
|
|
327
435
|
description: "Show or set the model that labels tool calls for approval",
|
|
328
436
|
handler: async (args, ctx) => {
|
|
329
|
-
const current = readConfig().model ?? "(none,
|
|
437
|
+
const current = readConfig().model ?? "(none, every gated call requires approval)";
|
|
330
438
|
let value = args.trim();
|
|
331
439
|
if (!value) {
|
|
332
440
|
if (!ctx.hasUI) {
|
|
@@ -377,7 +485,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
377
485
|
|
|
378
486
|
function updateStatus(ctx: ExtensionContext): void {
|
|
379
487
|
if (!ctx.hasUI) return;
|
|
380
|
-
const mode = getCurrentMode();
|
|
488
|
+
const mode = getCurrentMode(ctx);
|
|
381
489
|
const label = `seatbelt:${MODE_LABELS[mode]}`;
|
|
382
490
|
const text = ctx.mode === "tui" ? ctx.ui.theme.fg(MODE_COLOR[mode], label) : label;
|
|
383
491
|
ctx.ui.setStatus("seatbelt", text);
|
|
@@ -392,10 +500,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
392
500
|
return;
|
|
393
501
|
}
|
|
394
502
|
// Safe cast: wanted is non-empty and passed isPermissionMode above.
|
|
395
|
-
const mode = (wanted || nextMode(getCurrentMode())) as PermissionMode;
|
|
396
|
-
|
|
503
|
+
const mode = (wanted || nextMode(getCurrentMode(ctx))) as PermissionMode;
|
|
504
|
+
setSessionMode(ctx, mode);
|
|
397
505
|
updateStatus(ctx);
|
|
398
|
-
ctx.ui.notify(`Seatbelt: ${mode} (${MODE_HINTS[mode]})`, "info");
|
|
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
|
+
);
|
|
399
531
|
},
|
|
400
532
|
});
|
|
401
533
|
|
|
@@ -403,7 +535,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
403
535
|
updateStatus(ctx);
|
|
404
536
|
if (hasSeededFreshConfig && ctx.hasUI) {
|
|
405
537
|
ctx.ui.notify(
|
|
406
|
-
"seatbelt: no labeller model set,
|
|
538
|
+
"seatbelt: no labeller model set, so every gated call requires approval. Pick one with /seatbelt.",
|
|
407
539
|
"info",
|
|
408
540
|
);
|
|
409
541
|
}
|
|
@@ -417,10 +549,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
417
549
|
pi.registerShortcut(shortcut, {
|
|
418
550
|
description: "Cycle seatbelt mode (fastened, auto, unfastened)",
|
|
419
551
|
handler: (ctx) => {
|
|
420
|
-
const mode = nextMode(getCurrentMode());
|
|
421
|
-
|
|
552
|
+
const mode = nextMode(getCurrentMode(ctx));
|
|
553
|
+
setSessionMode(ctx, mode);
|
|
422
554
|
updateStatus(ctx);
|
|
423
|
-
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");
|
|
424
556
|
},
|
|
425
557
|
});
|
|
426
558
|
}
|
package/package.json
CHANGED
package/smoke.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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,
|
|
11
12
|
isPermissionMode,
|
|
@@ -67,28 +68,24 @@ assert.equal(describeCall("read", { path: "a.txt" }), 'read {"path":"a.txt"}');
|
|
|
67
68
|
assert.equal(describeCall("ls", {}), "ls");
|
|
68
69
|
assert.ok(describeCall("write", { path: "a", content: "x".repeat(600) }).includes("…(truncated)"));
|
|
69
70
|
|
|
70
|
-
//
|
|
71
|
-
assert.equal(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
assert.
|
|
78
|
-
assert.
|
|
79
|
-
assert.
|
|
80
|
-
assert.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
assert.
|
|
86
|
-
assert.
|
|
87
|
-
assert.
|
|
88
|
-
assert.equal(getHeuristicAssessment("find", "find .").danger, "safe");
|
|
89
|
-
assert.equal(getHeuristicAssessment("write", 'write {"path":"a"}').danger, "caution");
|
|
90
|
-
assert.equal(getHeuristicAssessment("edit", "edit diff").danger, "caution");
|
|
91
|
-
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);
|
|
92
89
|
|
|
93
90
|
// parser
|
|
94
91
|
assert.deepEqual(
|
|
@@ -114,11 +111,6 @@ assert.deepEqual(
|
|
|
114
111
|
["safe", "dangerous"],
|
|
115
112
|
);
|
|
116
113
|
|
|
117
|
-
// heuristics carry a risk line only for dangerous calls
|
|
118
|
-
assert.ok(getHeuristicAssessment("bash", "rm -rf /tmp/x").risk.length > 0);
|
|
119
|
-
assert.equal(getHeuristicAssessment("bash", "ls").risk, "");
|
|
120
|
-
assert.equal(getHeuristicAssessment("read", "read").risk, "");
|
|
121
|
-
|
|
122
114
|
// formatting
|
|
123
115
|
const batch = formatBatch(
|
|
124
116
|
["ls -la", "rm -rf /tmp/batch-demo"],
|
|
@@ -149,4 +141,23 @@ assert.match(
|
|
|
149
141
|
// plain style passes fragments through untouched
|
|
150
142
|
assert.doesNotMatch(formatBatch(["ls"], [{ danger: "safe", purpose: "x", risk: "" }]), /\u001b\[/);
|
|
151
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
|
+
|
|
152
163
|
console.log("pi-seatbelt core: all checks passed");
|