wispy-cli 1.1.2 → 1.2.1

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,531 @@
1
+ /**
2
+ * core/harness.mjs — Execution Harness for Wispy
3
+ *
4
+ * Mediates ALL tool execution with:
5
+ * - Permission checks (auto/notify/approve)
6
+ * - Dry-run simulation
7
+ * - Pre/post snapshots + unified diffs
8
+ * - Execution receipts
9
+ * - Audit logging
10
+ * - Event emission for TUI/channel approval UX
11
+ *
12
+ * v1.2.0
13
+ */
14
+
15
+ import { EventEmitter } from "node:events";
16
+ import { readFile } from "node:fs/promises";
17
+ import path from "node:path";
18
+ import os from "node:os";
19
+
20
+ import { EVENT_TYPES } from "./audit.mjs";
21
+
22
+ // ── Receipt ────────────────────────────────────────────────────────────────────
23
+
24
+ export class Receipt {
25
+ constructor() {
26
+ this.id = generateId();
27
+ this.timestamp = new Date().toISOString();
28
+ this.sessionId = null;
29
+ this.toolName = null;
30
+ this.args = {};
31
+ this.permissionLevel = "auto";
32
+ this.approved = null; // null = not needed, true/false = approval result
33
+ this.dryRun = false;
34
+ this.duration = 0;
35
+ this.success = false;
36
+ this.result = null;
37
+ this.diff = null; // { before, after, unified } for file ops
38
+ this.error = null;
39
+ }
40
+
41
+ toMarkdown() {
42
+ const icon = this.success ? "✅" : "❌";
43
+ const dryTag = this.dryRun ? " [DRY RUN]" : "";
44
+ const lines = [
45
+ `${icon} **${this.toolName}**${dryTag}`,
46
+ `- ID: \`${this.id}\``,
47
+ `- Time: ${new Date(this.timestamp).toLocaleTimeString()}`,
48
+ `- Duration: ${this.duration}ms`,
49
+ `- Permission: ${this.permissionLevel}`,
50
+ ];
51
+
52
+ if (this.approved !== null) {
53
+ lines.push(`- Approved: ${this.approved ? "✅ yes" : "❌ no"}`);
54
+ }
55
+
56
+ if (this.error) {
57
+ lines.push(`- Error: ${this.error}`);
58
+ }
59
+
60
+ if (this.diff?.unified) {
61
+ const stats = diffStats(this.diff.unified);
62
+ lines.push(`- Changes: +${stats.added} lines, -${stats.removed} lines`);
63
+ lines.push("", "```diff", this.diff.unified.slice(0, 2000), "```");
64
+ }
65
+
66
+ return lines.join("\n");
67
+ }
68
+
69
+ toJSON() {
70
+ return {
71
+ id: this.id,
72
+ timestamp: this.timestamp,
73
+ sessionId: this.sessionId,
74
+ toolName: this.toolName,
75
+ args: this.args,
76
+ permissionLevel: this.permissionLevel,
77
+ approved: this.approved,
78
+ dryRun: this.dryRun,
79
+ duration: this.duration,
80
+ success: this.success,
81
+ result: this.result,
82
+ diff: this.diff,
83
+ error: this.error,
84
+ };
85
+ }
86
+ }
87
+
88
+ // ── HarnessResult ──────────────────────────────────────────────────────────────
89
+
90
+ export class HarnessResult {
91
+ constructor({ result, receipt, denied = false, dryRun = false }) {
92
+ this.result = result;
93
+ this.receipt = receipt;
94
+ this.denied = denied;
95
+ this.dryRun = dryRun;
96
+ this.success = receipt?.success ?? (!denied);
97
+ }
98
+ }
99
+
100
+ // ── Sandbox modes ─────────────────────────────────────────────────────────────
101
+
102
+ // Which tools get file snapshots (pre/post diff)
103
+ const FILE_SNAPSHOT_TOOLS = new Set(["write_file", "file_edit"]);
104
+
105
+ // File-path arg for each tool (to know what to snapshot)
106
+ function getFilePath(toolName, args) {
107
+ if (toolName === "write_file" || toolName === "file_edit" || toolName === "read_file") {
108
+ return args.path;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ // Resolve a file path the same way tools.mjs does
114
+ function resolvePath(p) {
115
+ if (!p) return null;
116
+ let resolved = p.replace(/^~/, os.homedir());
117
+ if (!path.isAbsolute(resolved)) resolved = path.resolve(process.cwd(), resolved);
118
+ return resolved;
119
+ }
120
+
121
+ // ── Unified diff (no external deps) ──────────────────────────────────────────
122
+
123
+ /**
124
+ * Compute a simple unified diff between two strings.
125
+ * Returns unified diff string.
126
+ */
127
+ export function computeUnifiedDiff(before, after, filePath = "file") {
128
+ if (before === after) return "";
129
+
130
+ const beforeLines = before ? before.split("\n") : [];
131
+ const afterLines = after ? after.split("\n") : [];
132
+
133
+ // LCS-based diff — simple Myers-like algorithm
134
+ const hunks = computeHunks(beforeLines, afterLines, 3);
135
+
136
+ if (hunks.length === 0) return "";
137
+
138
+ const lines = [
139
+ `--- a/${filePath}`,
140
+ `+++ b/${filePath}`,
141
+ ];
142
+
143
+ for (const hunk of hunks) {
144
+ lines.push(hunk.header);
145
+ lines.push(...hunk.lines);
146
+ }
147
+
148
+ return lines.join("\n");
149
+ }
150
+
151
+ function computeHunks(oldLines, newLines, context = 3) {
152
+ // Build edit script using simple LCS
153
+ const edits = shortestEditScript(oldLines, newLines);
154
+
155
+ if (edits.length === 0) return [];
156
+
157
+ // Group edits into hunks with context
158
+ const hunks = [];
159
+ let i = 0;
160
+
161
+ while (i < edits.length) {
162
+ if (edits[i].type === "equal") { i++; continue; }
163
+
164
+ // Found a change — build a hunk
165
+ const hunkStart = i;
166
+ const hunkEdits = [edits[i]];
167
+ i++;
168
+
169
+ // Extend hunk while changes are within 2*context of each other
170
+ while (i < edits.length) {
171
+ if (edits[i].type !== "equal") {
172
+ hunkEdits.push(edits[i]);
173
+ i++;
174
+ } else {
175
+ // Count consecutive equal lines
176
+ let equalCount = 0;
177
+ let j = i;
178
+ while (j < edits.length && edits[j].type === "equal") { equalCount++; j++; }
179
+ if (equalCount <= 2 * context && j < edits.length && edits[j].type !== "equal") {
180
+ // Merge into current hunk
181
+ hunkEdits.push(...edits.slice(i, j));
182
+ i = j;
183
+ } else {
184
+ break;
185
+ }
186
+ }
187
+ }
188
+
189
+ // Compute old/new line ranges
190
+ let oldStart = null; let oldCount = 0;
191
+ let newStart = null; let newCount = 0;
192
+ const hunkLines = [];
193
+
194
+ // Add leading context
195
+ const firstEdit = hunkEdits[0];
196
+ const ctxStart = Math.max(0, firstEdit.oldIdx - context);
197
+ oldStart = ctxStart + 1; // 1-indexed
198
+ newStart = firstEdit.newIdx - (firstEdit.oldIdx - ctxStart) + 1; // 1-indexed
199
+ for (let k = ctxStart; k < firstEdit.oldIdx; k++) {
200
+ hunkLines.push(` ${oldLines[k]}`);
201
+ oldCount++; newCount++;
202
+ }
203
+
204
+ // Add the hunk edits
205
+ for (const edit of hunkEdits) {
206
+ if (edit.type === "equal") {
207
+ hunkLines.push(` ${oldLines[edit.oldIdx]}`);
208
+ oldCount++; newCount++;
209
+ } else if (edit.type === "delete") {
210
+ hunkLines.push(`-${oldLines[edit.oldIdx]}`);
211
+ oldCount++;
212
+ } else if (edit.type === "insert") {
213
+ hunkLines.push(`+${newLines[edit.newIdx]}`);
214
+ newCount++;
215
+ }
216
+ }
217
+
218
+ // Add trailing context
219
+ const lastEdit = hunkEdits[hunkEdits.length - 1];
220
+ const lastOldIdx = lastEdit.type === "insert" ? lastEdit.oldIdx : lastEdit.oldIdx + 1;
221
+ const ctxEnd = Math.min(oldLines.length, lastOldIdx + context);
222
+ for (let k = lastOldIdx; k < ctxEnd; k++) {
223
+ hunkLines.push(` ${oldLines[k]}`);
224
+ oldCount++; newCount++;
225
+ }
226
+
227
+ hunks.push({
228
+ header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
229
+ lines: hunkLines,
230
+ });
231
+ }
232
+
233
+ return hunks;
234
+ }
235
+
236
+ function shortestEditScript(oldLines, newLines) {
237
+ // Simple O(nd) diff using dynamic programming LCS
238
+ const m = oldLines.length;
239
+ const n = newLines.length;
240
+
241
+ // Build LCS table
242
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
243
+ for (let i = m - 1; i >= 0; i--) {
244
+ for (let j = n - 1; j >= 0; j--) {
245
+ if (oldLines[i] === newLines[j]) {
246
+ dp[i][j] = dp[i + 1][j + 1] + 1;
247
+ } else {
248
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
249
+ }
250
+ }
251
+ }
252
+
253
+ // Trace back
254
+ const edits = [];
255
+ let i = 0; let j = 0;
256
+ while (i < m || j < n) {
257
+ if (i < m && j < n && oldLines[i] === newLines[j]) {
258
+ edits.push({ type: "equal", oldIdx: i, newIdx: j });
259
+ i++; j++;
260
+ } else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) {
261
+ edits.push({ type: "insert", oldIdx: i, newIdx: j });
262
+ j++;
263
+ } else {
264
+ edits.push({ type: "delete", oldIdx: i, newIdx: j });
265
+ i++;
266
+ }
267
+ }
268
+
269
+ return edits;
270
+ }
271
+
272
+ function diffStats(unifiedDiff) {
273
+ let added = 0; let removed = 0;
274
+ for (const line of unifiedDiff.split("\n")) {
275
+ if (line.startsWith("+") && !line.startsWith("+++")) added++;
276
+ else if (line.startsWith("-") && !line.startsWith("---")) removed++;
277
+ }
278
+ return { added, removed };
279
+ }
280
+
281
+ // ── ID generation (no uuid dep needed) ───────────────────────────────────────
282
+
283
+ function generateId() {
284
+ const ts = Date.now().toString(36);
285
+ const rand = Math.random().toString(36).slice(2, 8);
286
+ return `rcpt-${ts}-${rand}`;
287
+ }
288
+
289
+ // ── Dry-run simulation ────────────────────────────────────────────────────────
290
+
291
+ function simulateDryRun(toolName, args) {
292
+ switch (toolName) {
293
+ case "write_file":
294
+ return {
295
+ success: true,
296
+ dryRun: true,
297
+ preview: `Would write ${(args.content ?? "").length} chars to: ${args.path}`,
298
+ content: args.content,
299
+ path: args.path,
300
+ };
301
+
302
+ case "file_edit":
303
+ return {
304
+ success: true,
305
+ dryRun: true,
306
+ preview: `Would replace text in: ${args.path}`,
307
+ old_text: args.old_text,
308
+ new_text: args.new_text,
309
+ path: args.path,
310
+ };
311
+
312
+ case "run_command":
313
+ return {
314
+ success: true,
315
+ dryRun: true,
316
+ preview: `Would execute: ${args.command}`,
317
+ command: args.command,
318
+ };
319
+
320
+ case "git":
321
+ return {
322
+ success: true,
323
+ dryRun: true,
324
+ preview: `Would run: git ${args.command}`,
325
+ command: args.command,
326
+ };
327
+
328
+ default:
329
+ return {
330
+ success: true,
331
+ dryRun: true,
332
+ preview: `Would call ${toolName} with ${JSON.stringify(args).slice(0, 100)}`,
333
+ };
334
+ }
335
+ }
336
+
337
+ // ── Harness class ──────────────────────────────────────────────────────────────
338
+
339
+ export class Harness extends EventEmitter {
340
+ /**
341
+ * @param {import('./tools.mjs').ToolRegistry} toolRegistry
342
+ * @param {import('./permissions.mjs').PermissionManager} permissions
343
+ * @param {import('./audit.mjs').AuditLog} audit
344
+ * @param {object} config
345
+ */
346
+ constructor(toolRegistry, permissions, audit, config = {}) {
347
+ super();
348
+ this.tools = toolRegistry;
349
+ this.permissions = permissions;
350
+ this.audit = audit;
351
+ this.config = config;
352
+
353
+ // Sandbox config per-tool: "preview" | "diff" | null
354
+ this._sandboxModes = {
355
+ run_command: "preview",
356
+ write_file: "diff",
357
+ file_edit: "diff",
358
+ git: "preview",
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Main entry point — replaces direct tool.execute() calls.
364
+ *
365
+ * @param {string} toolName
366
+ * @param {object} args
367
+ * @param {object} context - { sessionId, userId, channel, dryRun?, executeToolFn }
368
+ * @returns {HarnessResult}
369
+ */
370
+ async execute(toolName, args, context = {}) {
371
+ const receipt = new Receipt();
372
+ receipt.toolName = toolName;
373
+ receipt.args = args;
374
+ receipt.sessionId = context.sessionId ?? null;
375
+ receipt.dryRun = context.dryRun ?? false;
376
+
377
+ const callStart = Date.now();
378
+
379
+ // ── 1. Permission check ──────────────────────────────────────────────────
380
+ const permResult = await this.permissions.check(toolName, args, context);
381
+ receipt.permissionLevel = permResult.level ?? "auto";
382
+
383
+ if (!permResult.allowed) {
384
+ receipt.approved = false;
385
+ receipt.success = false;
386
+ receipt.error = permResult.reason ?? "Permission denied";
387
+ receipt.duration = Date.now() - callStart;
388
+
389
+ this.audit.log({
390
+ type: EVENT_TYPES.APPROVAL_DENIED,
391
+ sessionId: context.sessionId,
392
+ tool: toolName,
393
+ args,
394
+ }).catch(() => {});
395
+
396
+ this.emit("tool:denied", { toolName, args, receipt, context });
397
+
398
+ return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
399
+ }
400
+
401
+ if (permResult.needsApproval) {
402
+ receipt.approved = permResult.approved;
403
+ }
404
+
405
+ // ── 2. Dry-run mode ──────────────────────────────────────────────────────
406
+ if (receipt.dryRun) {
407
+ const preview = simulateDryRun(toolName, args);
408
+
409
+ // For file edits in dry-run, compute the diff
410
+ if (FILE_SNAPSHOT_TOOLS.has(toolName)) {
411
+ const filePath = getFilePath(toolName, args);
412
+ if (filePath) {
413
+ const resolved = resolvePath(filePath);
414
+ let before = "";
415
+ try { before = await readFile(resolved, "utf8"); } catch {}
416
+
417
+ let after = before;
418
+ if (toolName === "write_file") {
419
+ after = args.content ?? "";
420
+ } else if (toolName === "file_edit") {
421
+ after = before.replace(args.old_text ?? "", args.new_text ?? "");
422
+ }
423
+
424
+ const unified = computeUnifiedDiff(before, after, filePath);
425
+ receipt.diff = { before, after, unified };
426
+ preview.diff = receipt.diff;
427
+ }
428
+ }
429
+
430
+ receipt.success = true;
431
+ receipt.result = preview;
432
+ receipt.duration = Date.now() - callStart;
433
+
434
+ this.emit("tool:dryrun", { toolName, args, preview, receipt, context });
435
+
436
+ return new HarnessResult({ result: preview, receipt, dryRun: true });
437
+ }
438
+
439
+ // ── 3. Pre-snapshot ──────────────────────────────────────────────────────
440
+ let beforeContent = null;
441
+ if (FILE_SNAPSHOT_TOOLS.has(toolName)) {
442
+ const filePath = getFilePath(toolName, args);
443
+ if (filePath) {
444
+ const resolved = resolvePath(filePath);
445
+ try { beforeContent = await readFile(resolved, "utf8"); } catch { beforeContent = ""; }
446
+ }
447
+ }
448
+
449
+ // ── 4. Emit tool:start ───────────────────────────────────────────────────
450
+ this.emit("tool:start", { toolName, args, context });
451
+
452
+ this.audit.log({
453
+ type: EVENT_TYPES.TOOL_CALL,
454
+ sessionId: context.sessionId,
455
+ tool: toolName,
456
+ args,
457
+ permissionLevel: receipt.permissionLevel,
458
+ }).catch(() => {});
459
+
460
+ // ── 5. Execute ───────────────────────────────────────────────────────────
461
+ let result;
462
+ try {
463
+ if (context.executeToolFn) {
464
+ result = await context.executeToolFn(toolName, args);
465
+ } else {
466
+ result = await this.tools.execute(toolName, args);
467
+ }
468
+ receipt.success = result?.success !== false;
469
+ receipt.result = result;
470
+ } catch (err) {
471
+ receipt.success = false;
472
+ receipt.error = err.message;
473
+ receipt.duration = Date.now() - callStart;
474
+
475
+ this.audit.log({
476
+ type: EVENT_TYPES.ERROR,
477
+ sessionId: context.sessionId,
478
+ tool: toolName,
479
+ message: err.message,
480
+ duration: receipt.duration,
481
+ }).catch(() => {});
482
+
483
+ this.emit("tool:error", { toolName, args, error: err, receipt, context });
484
+
485
+ return new HarnessResult({ result: { success: false, error: err.message }, receipt });
486
+ }
487
+
488
+ // ── 6. Post-snapshot ─────────────────────────────────────────────────────
489
+ if (FILE_SNAPSHOT_TOOLS.has(toolName) && receipt.success) {
490
+ const filePath = getFilePath(toolName, args);
491
+ if (filePath) {
492
+ const resolved = resolvePath(filePath);
493
+ let afterContent = "";
494
+ try { afterContent = await readFile(resolved, "utf8"); } catch {}
495
+
496
+ const unified = computeUnifiedDiff(beforeContent ?? "", afterContent, filePath);
497
+ receipt.diff = { before: beforeContent ?? "", after: afterContent, unified };
498
+ }
499
+ }
500
+
501
+ // ── 7. Duration ──────────────────────────────────────────────────────────
502
+ receipt.duration = Date.now() - callStart;
503
+
504
+ // ── 8. Audit ─────────────────────────────────────────────────────────────
505
+ this.audit.log({
506
+ type: EVENT_TYPES.TOOL_RESULT,
507
+ sessionId: context.sessionId,
508
+ tool: toolName,
509
+ result: JSON.stringify(result).slice(0, 500),
510
+ duration: receipt.duration,
511
+ }).catch(() => {});
512
+
513
+ // ── 9. Emit tool:complete ────────────────────────────────────────────────
514
+ this.emit("tool:complete", { toolName, args, result, receipt, context });
515
+
516
+ return new HarnessResult({ result, receipt });
517
+ }
518
+
519
+ /**
520
+ * Set sandbox mode for a tool.
521
+ * @param {string} toolName
522
+ * @param {"preview"|"diff"|null} mode
523
+ */
524
+ setSandboxMode(toolName, mode) {
525
+ this._sandboxModes[toolName] = mode;
526
+ }
527
+
528
+ getSandboxMode(toolName) {
529
+ return this._sandboxModes[toolName] ?? null;
530
+ }
531
+ }
package/core/index.mjs CHANGED
@@ -17,3 +17,5 @@ export { PermissionManager, DEFAULT_POLICIES, BUILT_IN_SCOPES } from "./permissi
17
17
  export { AuditLog, EVENT_TYPES, getAuditLog } from "./audit.mjs";
18
18
  export { WispyServer } from "./server.mjs";
19
19
  export { NodeManager, CAPABILITIES } from "./nodes.mjs";
20
+ export { Harness, Receipt, HarnessResult, computeUnifiedDiff } from "./harness.mjs";
21
+ export { DeployManager } from "./deploy.mjs";