wispy-cli 0.9.0 → 1.1.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,248 @@
1
+ /**
2
+ * core/permissions.mjs — Permission & Approval System for Wispy
3
+ *
4
+ * Policy levels:
5
+ * "auto" — execute silently
6
+ * "notify" — execute but log to audit trail
7
+ * "approve" — pause and ask user before executing
8
+ */
9
+
10
+ import path from "node:path";
11
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
12
+ import { createInterface } from "node:readline";
13
+
14
+ import { WISPY_DIR } from "./config.mjs";
15
+
16
+ // ── Default policies ──────────────────────────────────────────────────────────
17
+
18
+ export const DEFAULT_POLICIES = {
19
+ // Auto: safe read-only or low-impact
20
+ read_file: "auto",
21
+ list_directory: "auto",
22
+ web_search: "auto",
23
+ web_fetch: "auto",
24
+ memory_search: "auto",
25
+ memory_list: "auto",
26
+ memory_get: "auto",
27
+ kill_subagent: "auto",
28
+ list_agents: "auto",
29
+ list_subagents: "auto",
30
+ get_agent_result: "auto",
31
+ get_subagent_result: "auto",
32
+
33
+ // Notify: medium-risk writes
34
+ write_file: "notify",
35
+ file_edit: "notify",
36
+ memory_save: "notify",
37
+ memory_append: "notify",
38
+ memory_delete: "notify",
39
+ clipboard: "notify",
40
+ spawn_subagent: "notify",
41
+ spawn_agent: "notify",
42
+ spawn_async_agent: "notify",
43
+
44
+ // Approve: high-risk operations
45
+ run_command: "approve",
46
+ git: "approve",
47
+ keychain: "approve",
48
+ delete_file: "approve",
49
+ pipeline: "notify",
50
+ };
51
+
52
+ // ── Scope definitions ─────────────────────────────────────────────────────────
53
+
54
+ export const BUILT_IN_SCOPES = {
55
+ "files:read": ["read_file", "list_directory"],
56
+ "files:write": ["write_file", "file_edit", "delete_file"],
57
+ "web": ["web_search", "web_fetch"],
58
+ "memory:read": ["memory_search", "memory_list", "memory_get"],
59
+ "memory:write": ["memory_save", "memory_append", "memory_delete"],
60
+ "commands:execute": ["run_command"],
61
+ "agents": ["spawn_subagent", "spawn_agent", "spawn_async_agent", "list_subagents", "list_agents"],
62
+ "system": ["git", "keychain", "clipboard"],
63
+ };
64
+
65
+ const PERMISSIONS_FILE = path.join(WISPY_DIR, "permissions.json");
66
+
67
+ export class PermissionManager {
68
+ constructor(config = {}) {
69
+ this.config = config;
70
+ this._policies = { ...DEFAULT_POLICIES };
71
+ this._scopes = new Set();
72
+ this._loaded = false;
73
+
74
+ // For REPL approval prompts — set by REPL
75
+ this._approvalHandler = null;
76
+ }
77
+
78
+ /**
79
+ * Load persisted policy overrides from disk.
80
+ */
81
+ async load() {
82
+ if (this._loaded) return;
83
+ try {
84
+ const raw = await readFile(PERMISSIONS_FILE, "utf8");
85
+ const data = JSON.parse(raw);
86
+ if (data.policies) Object.assign(this._policies, data.policies);
87
+ if (data.scopes) this._scopes = new Set(data.scopes);
88
+ } catch {
89
+ // First run — no file yet
90
+ }
91
+ this._loaded = true;
92
+ }
93
+
94
+ /**
95
+ * Persist current policy overrides to disk.
96
+ */
97
+ async save() {
98
+ await mkdir(WISPY_DIR, { recursive: true });
99
+ const overrides = {};
100
+ for (const [k, v] of Object.entries(this._policies)) {
101
+ if (DEFAULT_POLICIES[k] !== v) overrides[k] = v;
102
+ }
103
+ await writeFile(PERMISSIONS_FILE, JSON.stringify({
104
+ policies: overrides,
105
+ scopes: [...this._scopes],
106
+ }, null, 2) + "\n", "utf8");
107
+ }
108
+
109
+ // ── Policy accessors ────────────────────────────────────────────────────────
110
+
111
+ getPolicy(toolName) {
112
+ return this._policies[toolName] ?? "auto";
113
+ }
114
+
115
+ setPolicy(toolName, level) {
116
+ if (!["auto", "notify", "approve"].includes(level)) {
117
+ throw new Error(`Invalid policy level: ${level}. Must be auto, notify, or approve.`);
118
+ }
119
+ this._policies[toolName] = level;
120
+ }
121
+
122
+ listPolicies() {
123
+ return { ...this._policies };
124
+ }
125
+
126
+ // ── Scope management ────────────────────────────────────────────────────────
127
+
128
+ addScope(scope) {
129
+ this._scopes.add(scope);
130
+ // Apply the scope's tools
131
+ if (BUILT_IN_SCOPES[scope]) {
132
+ for (const tool of BUILT_IN_SCOPES[scope]) {
133
+ this._policies[tool] = "auto";
134
+ }
135
+ }
136
+ }
137
+
138
+ removeScope(scope) {
139
+ this._scopes.delete(scope);
140
+ }
141
+
142
+ listScopes() {
143
+ return [...this._scopes];
144
+ }
145
+
146
+ // ── Check ───────────────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Check if a tool call is allowed.
150
+ * @returns {{ allowed: boolean, level: string, needsApproval?: boolean, reason?: string }}
151
+ */
152
+ async check(toolName, args, context = {}) {
153
+ await this.load();
154
+ const level = this.getPolicy(toolName);
155
+
156
+ if (level === "auto") {
157
+ return { allowed: true, level: "auto" };
158
+ }
159
+
160
+ if (level === "notify") {
161
+ // Allowed, but should be logged
162
+ return { allowed: true, level: "notify" };
163
+ }
164
+
165
+ if (level === "approve") {
166
+ // Need explicit approval
167
+ const approved = await this.requestApproval({
168
+ toolName,
169
+ args,
170
+ context,
171
+ label: this._formatAction(toolName, args),
172
+ });
173
+ return {
174
+ allowed: approved,
175
+ level: "approve",
176
+ needsApproval: true,
177
+ approved,
178
+ reason: approved ? "User approved" : "User denied",
179
+ };
180
+ }
181
+
182
+ return { allowed: true, level };
183
+ }
184
+
185
+ /**
186
+ * Request user approval for an action.
187
+ * @param {{ toolName, args, label, context }} action
188
+ * @returns {boolean} approved
189
+ */
190
+ async requestApproval(action) {
191
+ if (this._approvalHandler) {
192
+ return this._approvalHandler(action);
193
+ }
194
+
195
+ // Default: CLI readline prompt
196
+ return new Promise((resolve) => {
197
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
198
+ const prompt = `\n⚠️ ${action.label ?? `${action.toolName}()`} — Allow? [y/N] `;
199
+ rl.question(prompt, (answer) => {
200
+ rl.close();
201
+ resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
202
+ });
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Set a custom approval handler (for channels, testing, etc.)
208
+ * @param {Function} fn — async (action) => boolean
209
+ */
210
+ setApprovalHandler(fn) {
211
+ this._approvalHandler = fn;
212
+ }
213
+
214
+ // ── Helpers ─────────────────────────────────────────────────────────────────
215
+
216
+ _formatAction(toolName, args) {
217
+ let argsStr = "";
218
+ try {
219
+ argsStr = JSON.stringify(args);
220
+ if (argsStr.length > 80) argsStr = argsStr.slice(0, 77) + "...";
221
+ } catch {}
222
+ return `${toolName}(${argsStr})`;
223
+ }
224
+
225
+ /**
226
+ * Format policy table for display.
227
+ */
228
+ formatTable() {
229
+ const lines = [];
230
+ const all = { ...DEFAULT_POLICIES, ...this._policies };
231
+ const grouped = { approve: [], notify: [], auto: [] };
232
+ for (const [tool, level] of Object.entries(all)) {
233
+ grouped[level]?.push(tool);
234
+ }
235
+
236
+ lines.push("Permission Policies:\n");
237
+ const icons = { approve: "🔐", notify: "📋", auto: "✅" };
238
+ for (const level of ["approve", "notify", "auto"]) {
239
+ if (grouped[level].length > 0) {
240
+ lines.push(` ${icons[level]} ${level.toUpperCase()}:`);
241
+ lines.push(` ${grouped[level].join(", ")}`);
242
+ lines.push("");
243
+ }
244
+ }
245
+
246
+ return lines.join("\n");
247
+ }
248
+ }