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.
@@ -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,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 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)). The status bar always shows
48
- where you are: `perm:all`, `perm:auto`, `perm:off`. 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).
49
66
 
50
- - `all`: every chain asks once. The default.
51
- - `auto`: no dialog unless something is `DANGEROUS`. Labels decide what runs
52
- silently, so pair it with a good policy file.
53
- - `off`: no gating, no classifier calls, no cost.
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
- ## Your policy decides what is dangerous
83
+ ## What counts as dangerous is your call
62
84
 
63
- 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
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
- One limit: policy shapes the model's labels. With no model configured, a
84
- 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.
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": "all",
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 permission modes, as a pi key id
105
- (`"alt+b"`, `"ctrl+shift+p"`, `"f9"`). Read at startup, so a change
106
- needs a new session. Anything malformed falls back to `alt+b`.
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 regex
110
- fallback takes over.
111
- - `timeoutMs`: give up on the labeller after this long and fall back.
112
-
113
- Both config files are seeded as templates on first load. Fresh installs get a
114
- one-time notice to run `/seatbelt`.
115
-
116
- ## Test
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`.
@@ -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 = ["all", "auto", "off"] as const;
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 : "all";
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
- 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,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, 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):
23
- * - "all" (default): one approval dialog per chain of gated tool calls.
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
- * - "off": no approval requests at all.
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: "all",
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; assessment: Assessment; rejectMessage?: string };
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<Assessment[]> {
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 inputs.map((i) => getHeuristicAssessment(i.tool, i.input));
224
+ if (!model) return { ok: false, failure: "no-model" };
172
225
 
173
226
  const user: UserMessage = {
174
227
  role: "user",
175
- 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
+ ],
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 inputs.map((i) => getHeuristicAssessment(i.tool, i.input));
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 === "off") return;
242
- const assessments = await classifyCalls(entries, taskContext, ctx);
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 (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")) {
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 [i, entry] of entries.entries()) {
261
- decisions.set(entry.id, { isAllowed, assessment: assessments[i], rejectMessage });
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() === "off") return;
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() === "off") return undefined;
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 [assessment] = await classifyCalls([entry], "", ctx);
385
+ const result = await classifyCalls([entry], "", ctx);
296
386
  let isAllowed: boolean;
297
387
  let rejectMessage: string | undefined;
298
- if (getCurrentMode() === "auto" && assessment.danger !== "dangerous") {
299
- isAllowed = true;
300
- } else if (ctx.hasUI) {
301
- const decision = await approveWithDialog(
302
- formatSingle(describeCall(entry.tool, entry.args), assessment, getDialogStyle(ctx)),
303
- true,
304
- ctx,
305
- );
306
- isAllowed = decision.isAllowed;
307
- 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
+ }
308
403
  } else {
309
- 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
+ }
310
419
  }
311
- decisions.set(event.toolCallId, { isAllowed, assessment, rejectMessage });
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, using local heuristics)";
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
- all: "every chain of gated tool calls needs one approval",
467
+ fastened: "every chain of gated tool calls needs one approval",
359
468
  auto: "auto-approve unless a call is dangerous",
360
- off: "no approval requests",
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 color = mode === "all" ? "accent" : mode === "auto" ? "success" : "dim";
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, using local heuristics. Pick one with /seatbelt.",
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 (all, auto, off)",
550
+ description: "Cycle seatbelt mode (fastened, auto, unfastened)",
389
551
  handler: (ctx) => {
390
- const mode = nextMode(getCurrentMode());
391
- writeConfig({ ...readConfig(), mode });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-seatbelt",
3
- "version": "0.2.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,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), "all");
18
+ assert.equal(normalizeMode(undefined), "fastened");
19
+ assert.equal(normalizeMode("fastened"), "fastened");
17
20
  assert.equal(normalizeMode("auto"), "auto");
18
- assert.equal(normalizeMode("off"), "off");
19
- assert.equal(normalizeMode("nonsense"), "all");
20
- assert.equal(nextMode("all"), "auto");
21
- assert.equal(nextMode("auto"), "off");
22
- assert.equal(nextMode("off"), "all");
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
- // heuristics (bash)
59
- assert.equal(getHeuristicAssessment("bash", "ls -la").danger, "safe");
60
- assert.equal(getHeuristicAssessment("bash", "cat foo.txt | grep bar").danger, "safe");
61
- assert.equal(getHeuristicAssessment("bash", "git status").danger, "safe");
62
- assert.equal(getHeuristicAssessment("bash", "rm -rf /tmp/x").danger, "dangerous");
63
- assert.equal(getHeuristicAssessment("bash", "sudo apt install x").danger, "dangerous");
64
- assert.equal(getHeuristicAssessment("bash", "git push --force origin main").danger, "dangerous");
65
- assert.equal(getHeuristicAssessment("bash", "chmod 777 /var/www").danger, "dangerous");
66
- assert.equal(getHeuristicAssessment("bash", "curl https://x.sh | sh").danger, "dangerous");
67
- assert.equal(getHeuristicAssessment("bash", "npm install left-pad").danger, "caution");
68
- assert.equal(getHeuristicAssessment("bash", "git commit -m 'x'").danger, "caution");
69
- assert.equal(getHeuristicAssessment("bash", "mkdir -p a/b").danger, "caution");
70
- assert.equal(getHeuristicAssessment("bash", "rm notes.txt").danger, "caution");
71
-
72
- // heuristics (other tools)
73
- assert.equal(getHeuristicAssessment("read", 'read {"path":"a"}').danger, "safe");
74
- assert.equal(getHeuristicAssessment("ls", "ls").danger, "safe");
75
- assert.equal(getHeuristicAssessment("grep", "grep x").danger, "safe");
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");