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.
@@ -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
- A safe, ergonomic permission system for pi. Your agent, your
4
- rules: safety without slowing you down.
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
- The headline feature: one approval for a whole chain of tool calls. A cheap
7
- classifier model labels every call `safe`, `caution`, or `DANGEROUS`, says
8
- what it does, and when something is dangerous, says why. You see the full
9
- chain, you approve once, the agent runs. No more approving commands one at
10
- a time.
7
+ [![npm version](https://img.shields.io/npm/v/pi-seatbelt)](https://www.npmjs.com/package/pi-seatbelt)
8
+ [![license](https://img.shields.io/npm/l/pi-seatbelt)](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
  ![The batch approval dialog: every call with its danger label, what it does, and why it's dangerous](assets/multiple.png)
13
21
 
14
- Three choices: `Approve all`, `Reject all`, or `Reject with message`. The
15
- message goes straight back to the agent as the rejection reason, so it knows
16
- why and can adjust instead of blindly retrying.
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 decide which tools are gated (default: `bash`), which mode you run in,
19
- and what the labeller calls dangerous. Your rules, your machine.
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 git:github.com/mikesposito/pi-seatbelt
46
+ pi -e npm:pi-seatbelt
31
47
  ```
32
48
 
33
- Local development: symlink the checkout into your extensions directory, or
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 package manifest declares the host-provided pi
42
- packages as peers.
57
+ No runtime dependencies. The host-provided pi packages are declared as peers.
43
58
 
44
- ## Three modes, one key
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 switch without the keyboard:
48
- `/seatbelt:mode` cycles, `/seatbelt:mode auto` sets a mode directly. The
49
- status bar always shows
50
- where you are: `seatbelt:fastened 🔒` (green), `seatbelt:auto 🤖` (yellow),
51
- `seatbelt:unfastened 💀` (red). The choice persists.
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`. Labels decide what runs
55
- silently, so pair it with a good policy file.
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
- ## Your policy decides what is dangerous
83
+ ## What counts as dangerous is your call
65
84
 
66
- The labels come from the classifier model. You steer the model with a plain
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
- One limit: policy shapes the model's labels. With no model configured, a
87
- built-in regex fallback labels calls and the policy file is not consulted.
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 permission modes, as a pi key id
108
- (`"alt+b"`, `"ctrl+shift+p"`, `"f9"`, `"super+b"` for ⌘ on macOS).
109
- Read at startup, so a change needs a new session. Anything malformed
110
- falls back to `alt+b`.
111
- Some combos need the terminal to report modifiers distinctly: through
112
- tmux without `set -s extended-keys on`, `ctrl+shift+b` arrives as plain
113
- `ctrl+b` (cursor-left) — `alt`- and `F`1–`F12` keys work everywhere.
114
- `super` (⌘) needs a terminal with Kitty keyboard protocol support
115
- (Ghostty, kitty, WezTerm) and is usually intercepted by the terminal or
116
- OS before it reaches pi. If a key combo does not work, `/seatbelt:mode`
117
- always does.
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 regex
121
- fallback takes over.
122
- - `timeoutMs`: give up on the labeller after this long and fall back.
123
-
124
- Both config files are seeded as templates on first load. Fresh installs get a
125
- one-time notice to run `/seatbelt`.
126
-
127
- ## Test
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`.
@@ -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
- const READ_ONLY_TOOLS = new Set(["read", "ls", "grep", "find"]);
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
- /** Fallback labels when no classifier model is configured or the call fails. */
104
- export function getHeuristicAssessment(toolName: string, input: string): Assessment {
105
- const purpose = "no purpose (local heuristic label)";
106
- if (toolName !== "bash") {
107
- return { danger: READ_ONLY_TOOLS.has(toolName) ? "safe" : "caution", purpose, risk: "" };
108
- }
109
- if (DANGEROUS_PATTERNS.some((p) => p.test(input))) {
110
- return { danger: "dangerous", purpose, risk: "matches a known dangerous pattern (local heuristic label)" };
111
- }
112
- if (CAUTION_PATTERNS.some((p) => p.test(input))) {
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 so
121
- * the caller falls back to heuristics.
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, local heuristics label the calls instead (the policy file only
19
- * affects the model).
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; assessment: Assessment; rejectMessage?: string };
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<Assessment[]> {
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 inputs.map((i) => getHeuristicAssessment(i.tool, i.input));
224
+ if (!model) return { ok: false, failure: "no-model" };
173
225
 
174
226
  const user: UserMessage = {
175
227
  role: "user",
176
- content: [{ type: "text", text: `Task context:\n${taskContext || "(none)"}\n\nTool calls:\n${JSON.stringify(inputs)}` }],
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 inputs.map((i) => getHeuristicAssessment(i.tool, i.input));
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 assessments = await classifyCalls(entries, taskContext, ctx);
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 (mode === "auto" && assessments.every((a) => a.danger !== "dangerous")) {
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 [i, entry] of entries.entries()) {
262
- decisions.set(entry.id, { isAllowed, assessment: assessments[i], rejectMessage });
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 [assessment] = await classifyCalls([entry], "", ctx);
385
+ const result = await classifyCalls([entry], "", ctx);
297
386
  let isAllowed: boolean;
298
387
  let rejectMessage: string | undefined;
299
- if (getCurrentMode() === "auto" && assessment.danger !== "dangerous") {
300
- isAllowed = true;
301
- } else if (ctx.hasUI) {
302
- const decision = await approveWithDialog(
303
- formatSingle(describeCall(entry.tool, entry.args), assessment, getDialogStyle(ctx)),
304
- true,
305
- ctx,
306
- );
307
- isAllowed = decision.isAllowed;
308
- rejectMessage = decision.rejectMessage;
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
- isAllowed = assessment.danger === "safe";
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, assessment, rejectMessage });
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, using local heuristics)";
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
- writeConfig({ ...readConfig(), mode });
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, using local heuristics. Pick one with /seatbelt.",
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
- writeConfig({ ...readConfig(), mode });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-seatbelt",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A safe, ergonomic permission system for pi: one approval per chain, danger labels, your rules.",
5
5
  "keywords": [
6
6
  "pi-package",
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
- // heuristics (bash)
71
- assert.equal(getHeuristicAssessment("bash", "ls -la").danger, "safe");
72
- assert.equal(getHeuristicAssessment("bash", "cat foo.txt | grep bar").danger, "safe");
73
- assert.equal(getHeuristicAssessment("bash", "git status").danger, "safe");
74
- assert.equal(getHeuristicAssessment("bash", "rm -rf /tmp/x").danger, "dangerous");
75
- assert.equal(getHeuristicAssessment("bash", "sudo apt install x").danger, "dangerous");
76
- assert.equal(getHeuristicAssessment("bash", "git push --force origin main").danger, "dangerous");
77
- assert.equal(getHeuristicAssessment("bash", "chmod 777 /var/www").danger, "dangerous");
78
- assert.equal(getHeuristicAssessment("bash", "curl https://x.sh | sh").danger, "dangerous");
79
- assert.equal(getHeuristicAssessment("bash", "npm install left-pad").danger, "caution");
80
- assert.equal(getHeuristicAssessment("bash", "git commit -m 'x'").danger, "caution");
81
- assert.equal(getHeuristicAssessment("bash", "mkdir -p a/b").danger, "caution");
82
- assert.equal(getHeuristicAssessment("bash", "rm notes.txt").danger, "caution");
83
-
84
- // heuristics (other tools)
85
- assert.equal(getHeuristicAssessment("read", 'read {"path":"a"}').danger, "safe");
86
- assert.equal(getHeuristicAssessment("ls", "ls").danger, "safe");
87
- assert.equal(getHeuristicAssessment("grep", "grep x").danger, "safe");
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");