@instaclaw/dispatch 0.6.2 → 0.7.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.
@@ -15,9 +15,69 @@ export function connect(opts) {
15
15
  const url = buildAuthUrl(opts.vmAddress, opts.port, opts.gatewayToken);
16
16
  console.log(` Connecting to ${opts.vmAddress}:${opts.port}...`);
17
17
  ws = new WebSocket(url, {
18
- rejectUnauthorized: false, // Accept self-signed certs (TOFU model)
18
+ // rejectUnauthorized stays false ON PURPOSE and is NOT the defect.
19
+ //
20
+ // The VM generates its own certificate with CN=dispatch-server (see
21
+ // dispatch-server.js), so ordinary hostname verification could never pass:
22
+ // we connect by IP address, and no CA has ever vouched for that name. Turning
23
+ // this to true without shipping something else in its place would simply break
24
+ // every connection.
25
+ //
26
+ // The real defect was that the TOFU half was designed and never built:
27
+ // certFingerprint and onCertFingerprint were declared in this file, threaded
28
+ // through from index.ts:180 and :206, and then never computed, never invoked,
29
+ // and never compared. So the transport accepted ANY certificate, forever,
30
+ // including an attacker's. That is what the block below closes.
31
+ rejectUnauthorized: false,
19
32
  handshakeTimeout: 10000,
20
33
  });
34
+ // ── Trust On First Use, the half that was missing ──────────────────────────
35
+ // On the first connection we record the peer's certificate fingerprint. On every
36
+ // later connection we compare. A changed fingerprint means the machine answering
37
+ // on this address is not the machine we paired with, which is exactly the
38
+ // dangling-DNS and address-reassignment case this fleet has already been shown to
39
+ // produce.
40
+ //
41
+ // SHIPS UNARMED: mismatch WARNS by default and only refuses when
42
+ // INSTACLAW_DISPATCH_PIN_ENFORCE is set. Enforcement is a separate word, because
43
+ // turning it on before every paired client has recorded a fingerprint would lock
44
+ // people out of their own machines.
45
+ ws.on("upgrade", (res) => {
46
+ try {
47
+ const sock = res.socket;
48
+ if (typeof sock.getPeerCertificate !== "function")
49
+ return;
50
+ const peer = sock.getPeerCertificate();
51
+ const fp = peer && peer.fingerprint256;
52
+ if (!fp)
53
+ return;
54
+ if (!opts.certFingerprint) {
55
+ // First use: adopt and persist via the callback index.ts already supplies.
56
+ opts.onCertFingerprint?.(fp);
57
+ return;
58
+ }
59
+ if (opts.certFingerprint === fp)
60
+ return;
61
+ const msg = `certificate fingerprint CHANGED for ${opts.vmAddress}.\n` +
62
+ ` expected ${opts.certFingerprint}\n` +
63
+ ` received ${fp}\n` +
64
+ ` The machine answering on this address is not the one you paired with.`;
65
+ if (process.env.INSTACLAW_DISPATCH_PIN_ENFORCE) {
66
+ console.error(` REFUSING: ${msg}`);
67
+ try {
68
+ ws?.close(1008, "cert fingerprint mismatch");
69
+ }
70
+ catch { /* closing anyway */ }
71
+ }
72
+ else {
73
+ console.warn(` WARNING: ${msg}\n Set INSTACLAW_DISPATCH_PIN_ENFORCE=1 to refuse instead of warn.`);
74
+ }
75
+ }
76
+ catch {
77
+ // Never let the pin check break a working connection. It is a check, not a
78
+ // dependency.
79
+ }
80
+ });
21
81
  ws.on("open", () => {
22
82
  reconnectDelay = 1000; // Reset backoff
23
83
  console.log(` Connected to agent on ${opts.vmAddress}`);
@@ -1,4 +1,6 @@
1
1
  import type { DispatchCommand } from "./types.js";
2
+ /** Call once at start, before any command. Throws (the client must not start) on a start refusal. */
3
+ export declare function initScope(): Promise<boolean>;
2
4
  export interface ExecutionResult {
3
5
  success: boolean;
4
6
  data?: Record<string, unknown>;
package/dist/executor.js CHANGED
@@ -4,6 +4,30 @@
4
4
  */
5
5
  import { execSync, exec as execCb } from "child_process";
6
6
  import { captureScreenshot } from "./screenshot.js";
7
+ import { commandNamesOwnPage, OWN_PAGE_REFUSAL } from "./own-pages.js";
8
+ import { Scope, loadScopeConfig } from "./scope.js";
9
+ // PASS 43 SCOPED MODE (scope.ts, PRD 43.11): null unless INSTACLAW_DISPATCH_SCOPE_FILE is set.
10
+ let scope = null;
11
+ async function liveWindows() { return (await (await getUC()).windowList()); }
12
+ /** Call once at start, before any command. Throws (the client must not start) on a start refusal. */
13
+ export async function initScope() {
14
+ const cfg = loadScopeConfig();
15
+ if (!cfg)
16
+ return false;
17
+ const base = await liveWindows();
18
+ const why = Scope.startRefusal(cfg, base);
19
+ if (why)
20
+ throw new Error(`scoped mode refused to start: ${why}`);
21
+ scope = new Scope(cfg, base);
22
+ return true;
23
+ }
24
+ /** The kill point: the refusal goes back to the agent, then the client exits (no retry in another form). */
25
+ function scopeRefused(v, what) {
26
+ const msg = scope.refuse(v, what);
27
+ console.log(` SCOPE REFUSED: ${v.why}; stopping (would have touched: ${v.wouldTouch})`);
28
+ setTimeout(() => process.exit(3), 500);
29
+ return { success: false, error: msg };
30
+ }
7
31
  // Dynamic import for usecomputer (ESM)
8
32
  let uc = null;
9
33
  // Track the most recent coordMap from screenshots for click mapping
@@ -31,10 +55,22 @@ export async function executeCommand(command) {
31
55
  case "drag":
32
56
  return await doDrag(params);
33
57
  case "windows":
58
+ if (scope)
59
+ return { success: true, data: { windows: scope.windows(await liveWindows()) } };
34
60
  return await listWindows();
35
61
  case "batch":
36
62
  return await executeBatch(params);
37
63
  case "exec":
64
+ // THE OWN-PAGES REFUSAL (own-pages.ts). Closes the exec vector only; read that file for
65
+ // what it deliberately does NOT close.
66
+ if (scope) {
67
+ const v = scope.exec(String(params?.command ?? command.command ?? ""));
68
+ if (!v.ok)
69
+ return scopeRefused(v, "exec");
70
+ }
71
+ if (commandNamesOwnPage(String(command.command ?? ""))) {
72
+ throw new Error(OWN_PAGE_REFUSAL);
73
+ }
38
74
  return await doExec(params);
39
75
  default:
40
76
  return { success: false, error: `Unknown command type: ${type}` };
@@ -121,7 +157,15 @@ async function executeBatch(params) {
121
157
  return batchResult;
122
158
  }
123
159
  async function takeScreenshot(params) {
160
+ let windowId;
161
+ if (scope) {
162
+ const v = scope.screenshot(await liveWindows());
163
+ if (!v.ok)
164
+ return scopeRefused(v, "screenshot");
165
+ windowId = v.windowId;
166
+ }
124
167
  const capture = await captureScreenshot({
168
+ windowId,
125
169
  format: params.format || "webp",
126
170
  quality: Number(params.quality) || 55,
127
171
  maxWidth: Number(params.maxWidth) || 1280,
@@ -158,12 +202,22 @@ async function doClick(params) {
158
202
  // If mapping fails, fall back to raw coords
159
203
  }
160
204
  }
205
+ if (scope) {
206
+ const v = scope.point(await liveWindows(), point, "a click");
207
+ if (!v.ok)
208
+ return scopeRefused(v, "click");
209
+ }
161
210
  await usecomputer.click({ point, button: "left", count: 1 });
162
211
  return { success: true, data: { action: "click", x, y, mappedX: point.x, mappedY: point.y, coordMap: coordMap || "none" } };
163
212
  }
164
213
  async function doType(params) {
165
214
  const usecomputer = await getUC();
166
215
  const text = String(params.text);
216
+ if (scope) {
217
+ const v = scope.keys(await liveWindows(), "typing");
218
+ if (!v.ok)
219
+ return scopeRefused(v, "type");
220
+ }
167
221
  try {
168
222
  await usecomputer.typeText({ text, delayMs: null });
169
223
  }
@@ -181,6 +235,11 @@ async function doType(params) {
181
235
  async function doPress(params) {
182
236
  const usecomputer = await getUC();
183
237
  const key = String(params.key);
238
+ if (scope) {
239
+ const v = scope.keys(await liveWindows(), "a key press");
240
+ if (!v.ok)
241
+ return scopeRefused(v, "press");
242
+ }
184
243
  await usecomputer.press({ key, count: null, delayMs: null });
185
244
  return { success: true, data: { action: "press", key } };
186
245
  }
@@ -188,6 +247,11 @@ async function doScroll(params) {
188
247
  const usecomputer = await getUC();
189
248
  const direction = String(params.direction);
190
249
  const amount = Number(params.amount || 3);
250
+ if (scope) {
251
+ const v = scope.point(await liveWindows(), await usecomputer.mousePosition(), "a scroll");
252
+ if (!v.ok)
253
+ return scopeRefused(v, "scroll");
254
+ }
191
255
  await usecomputer.scroll({ direction, amount, at: null });
192
256
  return { success: true, data: { action: "scroll", direction, amount } };
193
257
  }
@@ -204,6 +268,14 @@ async function doDrag(params) {
204
268
  }
205
269
  catch { }
206
270
  }
271
+ if (scope) {
272
+ const w = await liveWindows();
273
+ for (const [p, n] of [[from, "a drag start"], [to, "a drag end"]]) {
274
+ const v = scope.point(w, p, n);
275
+ if (!v.ok)
276
+ return scopeRefused(v, "drag");
277
+ }
278
+ }
207
279
  await usecomputer.drag({ from, to, durationMs: null, button: null });
208
280
  return { success: true, data: { action: "drag" } };
209
281
  }
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { loadConfig, saveConfig, getConfigPath } from "./config.js";
11
11
  import { checkPermissions, runPermissionTest } from "./permissions.js";
12
12
  import { connect, disconnect } from "./connection.js";
13
13
  import { closeSupervisor, setMode } from "./supervisor.js";
14
+ import { initScope } from "./executor.js";
14
15
  import chalk from "chalk";
15
16
  import readline from "readline";
16
17
  import os from "os";
@@ -20,12 +21,19 @@ const API_BASE = "https://instaclaw.io";
20
21
  // Temp file to persist pairing code + args across Terminal restarts
21
22
  const RETRY_FILE = path.join(os.tmpdir(), "instaclaw-dispatch-retry.json");
22
23
  const RETRY_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
24
+ /** The client's own version, read from its package.json (one directory above dist/). */
25
+ const CLIENT_VERSION = (() => { try {
26
+ return JSON.parse(fs.readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf8")).version;
27
+ }
28
+ catch {
29
+ return "unknown";
30
+ } })();
23
31
  async function main() {
32
+ const args = parseArgs(); // first: --help and unknown flags exit here, before any banner, check or capture
24
33
  console.log(`
25
- ${chalk.bold("InstaClaw Dispatch")} ${chalk.dim("— Remote Computer Control")}
34
+ ${chalk.bold("InstaClaw Dispatch")} ${chalk.dim("Remote Computer Control")}
26
35
  ${chalk.dim("─────────────────────────────────────────────")}
27
36
  `);
28
- const args = parseArgs();
29
37
  // ── Auto-retry: check if we're resuming after a Terminal restart ──
30
38
  const retry = loadRetryState();
31
39
  if (!args.pair && !args.token && retry) {
@@ -53,7 +61,7 @@ async function main() {
53
61
  console.log(` → Enable "${terminal}"`);
54
62
  console.log("");
55
63
  console.log(chalk.yellow(` ⚠ Enabling Accessibility will close ${terminal}.`));
56
- console.log(chalk.yellow(` Don't worry — just reopen ${terminal} and run the same command.`));
64
+ console.log(chalk.yellow(` Don't worry. Just reopen ${terminal} and run the same command.`));
57
65
  if (args.pair) {
58
66
  console.log(chalk.green(` Your pairing code ${chalk.bold(args.pair)} will still work.`));
59
67
  }
@@ -83,13 +91,13 @@ async function main() {
83
91
  console.log(` After granting permissions and reopening ${terminal}, run:`);
84
92
  console.log("");
85
93
  if (args.pair) {
86
- console.log(` ${chalk.cyan(`npx @instaclaw/dispatch@0.5.0 --pair ${args.pair}`)}`);
94
+ console.log(` ${chalk.cyan(`npx @instaclaw/dispatch@latest --pair ${args.pair}`)}`);
87
95
  }
88
96
  else {
89
97
  console.log(` ${chalk.cyan("npx @instaclaw/dispatch")}`);
90
98
  }
91
99
  console.log("");
92
- console.log(` ${chalk.dim("Or just reopen Terminal — we'll auto-resume if possible.")}`);
100
+ console.log(` ${chalk.dim("Or just reopen Terminal and we'll auto-resume if possible.")}`);
93
101
  console.log("");
94
102
  console.log(` Press ${chalk.bold("Enter")} when ready (or grant Accessibility and Terminal will restart)...`);
95
103
  await waitForEnter();
@@ -97,7 +105,7 @@ async function main() {
97
105
  const recheck = checkPermissions();
98
106
  if (recheck.accessibility === false) {
99
107
  console.log(`\n ${chalk.yellow("⚠")} Accessibility still not granted. Click/type commands will fail.`);
100
- console.log(` ${chalk.dim("Continuing anyway — screenshots may still work.\n")}`);
108
+ console.log(` ${chalk.dim("Continuing anyway. Screenshots may still work.\n")}`);
101
109
  }
102
110
  else {
103
111
  console.log(`\n ${chalk.green("✓")} Permissions: all OK\n`);
@@ -114,7 +122,7 @@ async function main() {
114
122
  }
115
123
  else {
116
124
  console.log(` ${chalk.yellow("⚠")} ${test.error}`);
117
- console.log(" Continuing anyway — some commands may fail.\n");
125
+ console.log(" Continuing anyway. Some commands may fail.\n");
118
126
  }
119
127
  clearRetryState();
120
128
  }
@@ -159,6 +167,15 @@ async function main() {
159
167
  console.log(` ${chalk.dim("VM:")} ${config.vmAddress}:${config.port}`);
160
168
  console.log(` ${chalk.dim("Mode:")} ${config.mode}`);
161
169
  console.log(` ${chalk.dim("Token:")} ${config.gatewayToken.substring(0, 8)}...\n`);
170
+ // ── 2b. PASS 43 scoped mode (scope.ts): fixed at start, before any command can arrive; a refusal to start ends here. ──
171
+ try {
172
+ if (await initScope())
173
+ console.log(` ${chalk.yellow("Scoped mode")}: only windows this test opens; the first refusal stops the client.`);
174
+ }
175
+ catch (e) {
176
+ console.log(` ${chalk.red("✗")} ${e.message}`);
177
+ process.exit(3);
178
+ }
162
179
  // ── 3. Connect to VM ──
163
180
  connect({
164
181
  vmAddress: config.vmAddress,
@@ -339,6 +356,22 @@ function parseArgs() {
339
356
  else if ((args[i] === "--pair" || args[i] === "--code") && args[i + 1]) {
340
357
  result.pair = args[++i];
341
358
  }
359
+ else {
360
+ // PASS 43 (PRD 43.18): --help, --version and ANY argument this client does not know print and exit BEFORE anything
361
+ // starts. An unknown flag used to fall through and start the full client: its permission check runs a full screen
362
+ // capture (measured 2026-10-31 by `--help` on a Mac it was never meant to touch).
363
+ const known = args[i] === "--help" || args[i] === "-h" || args[i] === "--version" || args[i] === "-V";
364
+ if (args[i] === "--version" || args[i] === "-V") {
365
+ console.log(CLIENT_VERSION);
366
+ process.exit(0);
367
+ }
368
+ console.log(`${known ? "" : ` Unknown argument: ${args[i]}\n\n`} Usage:
369
+ npx @instaclaw/dispatch --pair ABCD-1234 [--autonomous] pair with the code from your dashboard
370
+ npx @instaclaw/dispatch --token TOKEN --vm HOST direct connect (advanced)
371
+ npx @instaclaw/dispatch --help | --version
372
+ Nothing was started.`);
373
+ process.exit(known ? 0 : 2);
374
+ }
342
375
  }
343
376
  return result;
344
377
  }
@@ -0,0 +1,3 @@
1
+ /** True when a shell command names one of our own web origins, gateway subdomains excepted. */
2
+ export declare function commandNamesOwnPage(command: string): boolean;
3
+ export declare const OWN_PAGE_REFUSAL = "Refused: this would act on an InstaClaw page. Your agent is not allowed to approve its own actions.";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * THE OWN-PAGES REFUSAL, Node CLI. Parity with the Mac app's OwnPages.swift and the extension's
3
+ * isForbiddenAgentUrl.
4
+ *
5
+ * READ THIS BEFORE TRUSTING IT. This closes ONE vector and deliberately does not pretend to close
6
+ * the one that matters most.
7
+ *
8
+ * WHAT IT CLOSES: an `exec` whose command text names one of our own web origins, so the agent
9
+ * cannot curl an approval endpoint or drive a browser at our pages by naming them.
10
+ *
11
+ * WHAT IT CANNOT CLOSE, and no client side check ever will: this CLI can `click` at coordinates and
12
+ * `type` text on the owner's real desktop. To approve its own spend it needs only `screenshot` to
13
+ * find the button and `click` to press it. A click carries no text, so no pattern can match it, and
14
+ * from the operating system's point of view it is indistinguishable from the owner's own click.
15
+ * CONNECTORS reached the same conclusion independently in lib/owner-request-origin.ts: "a click on
16
+ * the owner's screen is indistinguishable from the owner's click. THIS MODULE CANNOT" detect it.
17
+ * FRONTIER reached it too, in lib/frontier-approval-outofband.ts: "a URL-pattern block does not
18
+ * survive... Dispatch bypasses the browser entirely."
19
+ *
20
+ * SO THE REAL FIX IS NOT HERE. It is the out of band completion token FRONTIER already built, which
21
+ * requires a factor delivered to a surface the agent has no path to. That module is PURE and INERT
22
+ * with no route importing it. Until it is wired, a machine running this CLI in autonomous mode can
23
+ * approve its own spend, and adding more client side checks here would be theatre that makes the
24
+ * hole look closed.
25
+ */
26
+ const FORBIDDEN_HOSTS = ["instaclaw.io", "www.instaclaw.io"];
27
+ /** True when a shell command names one of our own web origins, gateway subdomains excepted. */
28
+ export function commandNamesOwnPage(command) {
29
+ const c = String(command || "").toLowerCase();
30
+ for (const host of FORBIDDEN_HOSTS) {
31
+ let idx = 0;
32
+ for (;;) {
33
+ const at = c.indexOf(host, idx);
34
+ if (at === -1)
35
+ break;
36
+ const prefix = c.slice(0, at);
37
+ // The customer's own gateway (vm-123.vm.instaclaw.io) is explicitly allowed.
38
+ if (!prefix.endsWith(".vm.") && !prefix.endsWith("vm."))
39
+ return true;
40
+ idx = at + host.length;
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+ export const OWN_PAGE_REFUSAL = "Refused: this would act on an InstaClaw page. Your agent is not allowed to approve its own actions.";
46
+ //# sourceMappingURL=own-pages.js.map
@@ -59,7 +59,7 @@ export async function runPermissionTest() {
59
59
  fs.unlinkSync(tmpPath);
60
60
  if (size > 500)
61
61
  return { ok: true };
62
- return { ok: false, error: "Screenshot captured but empty — Screen Recording permission may be missing" };
62
+ return { ok: false, error: "Screenshot captured but empty. Screen Recording permission may be missing" };
63
63
  }
64
64
  return { ok: false, error: "Screenshot file not created" };
65
65
  }
@@ -0,0 +1,51 @@
1
+ export type Win = {
2
+ id: number;
3
+ ownerPid: number;
4
+ ownerName: string;
5
+ title: string;
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ };
11
+ export type ScopeConfig = {
12
+ apps: string[];
13
+ folder: string;
14
+ log: string;
15
+ };
16
+ export type Verdict = {
17
+ ok: true;
18
+ windowId?: number;
19
+ } | {
20
+ ok: false;
21
+ why: string;
22
+ wouldTouch: string;
23
+ };
24
+ export declare class Scope {
25
+ readonly cfg: ScopeConfig;
26
+ readonly baselineIds: Set<number>;
27
+ readonly baselinePids: Set<number>;
28
+ constructor(cfg: ScopeConfig, baseline: Win[]);
29
+ /** Refuse to start when an allowed app already has a window: its windows could be the owner's. */
30
+ static startRefusal(cfg: ScopeConfig, baseline: Win[]): string | null;
31
+ isTestWindow(w: Win): boolean;
32
+ /** The topmost window containing a desktop point (front to back), if any. */
33
+ static topAt(wins: Win[], p: {
34
+ x: number;
35
+ y: number;
36
+ }): Win | null;
37
+ private describe;
38
+ screenshot(wins: Win[]): Verdict;
39
+ windows(wins: Win[]): Win[];
40
+ point(wins: Win[], p: {
41
+ x: number;
42
+ y: number;
43
+ }, what: string): Verdict;
44
+ keys(wins: Win[], what: string): Verdict;
45
+ exec(command: string): Verdict;
46
+ /** The kill point: log what it would have touched (locally), then the caller exits. */
47
+ refuse(v: Extract<Verdict, {
48
+ ok: false;
49
+ }>, command: string): string;
50
+ }
51
+ export declare function loadScopeConfig(): ScopeConfig | null;
package/dist/scope.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * SCOPED MODE (DISPATCH pass 43, PRD 43.11): the client acts ONLY inside windows the test opened itself.
3
+ *
4
+ * Written for one machine first: Cooper's own Mac, under his line "only windows you open yourself, nothing closed, quit,
5
+ * deleted or typed into that you did not open". It is fail closed by construction:
6
+ * - At start, every window that exists is recorded and refused forever (the BASELINE). If an allowed app is already
7
+ * running, the client REFUSES TO START: a window of a running app cannot be told apart from the owner's own.
8
+ * - A screenshot is of the frontmost TEST window only, never the full screen (his screen holds terminals and keys).
9
+ * `windows` returns test windows only, so no title of his reaches a model.
10
+ * - A click, drag or scroll lands only when the desktop point is inside a test window AND that window is the topmost
11
+ * one at the point. Typing and keys only when the frontmost window is a test window.
12
+ * - `exec` runs only the exact allowed commands (open an allowed app; make the test folder).
13
+ * - THE KILL POINT: the first refusal logs, locally only, what it would have touched, and the client exits. The agent
14
+ * is told only "refused: outside the test's own windows", never a title of the owner's.
15
+ * Enabled when INSTACLAW_DISPATCH_SCOPE_FILE names a JSON file: { "apps": ["TextEdit"], "folder": "/Users/x/dir", "log": "/path" }.
16
+ *
17
+ * ASSUMPTION, stated: the window list is front to back (CGWindowListCopyWindowInfo's order for on screen windows). It is
18
+ * proven on the rented Mac before his Mac (J5a), never assumed there.
19
+ */
20
+ import * as fs from "node:fs";
21
+ export class Scope {
22
+ cfg;
23
+ baselineIds;
24
+ baselinePids;
25
+ constructor(cfg, baseline) {
26
+ this.cfg = cfg;
27
+ this.baselineIds = new Set(baseline.map((w) => w.id));
28
+ this.baselinePids = new Set(baseline.map((w) => w.ownerPid));
29
+ }
30
+ /** Refuse to start when an allowed app already has a window: its windows could be the owner's. */
31
+ static startRefusal(cfg, baseline) {
32
+ if (!cfg.apps.length)
33
+ return "no allowed app is named";
34
+ if (!cfg.folder.startsWith("/") || cfg.folder.includes(".."))
35
+ return "the test folder is not an absolute plain path";
36
+ const open = baseline.filter((w) => cfg.apps.includes(w.ownerName));
37
+ return open.length ? `${open[0].ownerName} is already open; close it yourself first, or the test cannot tell your windows from its own` : null;
38
+ }
39
+ isTestWindow(w) {
40
+ return !this.baselineIds.has(w.id) && !this.baselinePids.has(w.ownerPid) && this.cfg.apps.includes(w.ownerName);
41
+ }
42
+ /** The topmost window containing a desktop point (front to back), if any. */
43
+ static topAt(wins, p) {
44
+ return wins.find((w) => p.x >= w.x && p.x < w.x + w.width && p.y >= w.y && p.y < w.y + w.height) ?? null;
45
+ }
46
+ describe(w) { return w ? `${w.ownerName} window ${w.id} "${w.title.slice(0, 60)}"` : "no window (the desktop or the menu bar)"; }
47
+ screenshot(wins) {
48
+ const t = wins.find((w) => this.isTestWindow(w));
49
+ return t ? { ok: true, windowId: t.id } : { ok: false, why: "no test window is open", wouldTouch: "a capture of the whole screen" };
50
+ }
51
+ windows(wins) { return wins.filter((w) => this.isTestWindow(w)); }
52
+ point(wins, p, what) {
53
+ const top = Scope.topAt(wins, p);
54
+ return top && this.isTestWindow(top) ? { ok: true } : { ok: false, why: `${what} outside the test's own windows`, wouldTouch: this.describe(top) };
55
+ }
56
+ keys(wins, what) {
57
+ const front = wins[0] ?? null;
58
+ return front && this.isTestWindow(front) ? { ok: true } : { ok: false, why: `${what} while the front window is not the test's`, wouldTouch: this.describe(front) };
59
+ }
60
+ exec(command) {
61
+ const c = command.trim();
62
+ const apps = this.cfg.apps.map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
63
+ if (new RegExp(`^open -a (${apps})$`).test(c))
64
+ return { ok: true };
65
+ if (c === `mkdir -p ${this.cfg.folder}` || c === `mkdir -p "${this.cfg.folder}"`)
66
+ return { ok: true };
67
+ return { ok: false, why: "a shell command outside the allowed list", wouldTouch: `the shell: ${c.slice(0, 80)}` };
68
+ }
69
+ /** The kill point: log what it would have touched (locally), then the caller exits. */
70
+ refuse(v, command) {
71
+ const line = JSON.stringify({ at: new Date().toISOString(), refused: command, why: v.why, would_touch: v.wouldTouch });
72
+ try {
73
+ fs.appendFileSync(this.cfg.log, line + "\n");
74
+ }
75
+ catch { }
76
+ return "refused: outside the test's own windows; the test stopped here";
77
+ }
78
+ }
79
+ export function loadScopeConfig() {
80
+ const f = process.env.INSTACLAW_DISPATCH_SCOPE_FILE;
81
+ if (!f)
82
+ return null;
83
+ const j = JSON.parse(fs.readFileSync(f, "utf8"));
84
+ if (!Array.isArray(j.apps) || typeof j.folder !== "string" || typeof j.log !== "string")
85
+ throw new Error("scope file needs apps, folder and log");
86
+ return { apps: j.apps.map(String), folder: j.folder, log: j.log };
87
+ }
88
+ //# sourceMappingURL=scope.js.map
@@ -3,6 +3,8 @@ export interface ScreenshotOptions {
3
3
  quality?: number;
4
4
  /** Max width — downscale if screen is larger (default 1280) */
5
5
  maxWidth?: number;
6
+ /** PASS 43 scoped mode: capture this one window only, never the full screen. */
7
+ windowId?: number;
6
8
  }
7
9
  export interface ScreenshotCapture {
8
10
  buffer: Buffer;
@@ -26,7 +26,7 @@ export async function captureScreenshot(opts = {}) {
26
26
  const quality = opts.quality || 55;
27
27
  const maxWidth = opts.maxWidth || 1280;
28
28
  try {
29
- const result = await uc.screenshot({ path: tmpPath, display: null, window: null, region: null, annotate: null });
29
+ const result = await uc.screenshot({ path: tmpPath, display: null, window: opts.windowId ?? null, region: null, annotate: null });
30
30
  const pngBuffer = fs.readFileSync(tmpPath);
31
31
  let outputBuffer;
32
32
  let outWidth = result.imageWidth;
@@ -1,4 +1,6 @@
1
1
  import type { DispatchCommand } from "./types.js";
2
2
  export declare function setMode(m: "supervised" | "autonomous"): void;
3
3
  export declare function requestApproval(command: DispatchCommand): Promise<boolean>;
4
+ /** True when the agent's own stated intent names a consequential act. */
5
+ export declare function descriptionIsConsequential(description: unknown): boolean;
4
6
  export declare function closeSupervisor(): void;
@@ -39,20 +39,44 @@ export function setMode(m) {
39
39
  mode = m;
40
40
  }
41
41
  export async function requestApproval(command) {
42
- // Always auto-approve passive commands
42
+ // Passive commands (a screenshot, the window list, status) are approved without a per action prompt ONLY when the
43
+ // owner opted in (autonomous) or is at the terminal that started the session. PASS 43 (PRD 43.18): they were approved
44
+ // unconditionally, so a session that never opted in, with no terminal, handed out a FULL SCREEN capture and every
45
+ // window's title. Measured on main's 0.7.0 at 2026-10-31 20:3xZ: screenshot, windows and status all true.
43
46
  if (AUTO_APPROVE_TYPES.has(command.type)) {
44
- return true;
47
+ if (rl || mode === "autonomous")
48
+ return true;
49
+ console.log(`\n Agent wants to: ${formatCommand(command)}`);
50
+ console.log(" REFUSED. This session did not opt in, and nothing here can show you what it would see.");
51
+ return false;
45
52
  }
46
53
  const desc = formatCommand(command);
47
54
  const dangerous = isDangerous(command);
48
- // Non-interactive (no TTY) — auto-approve everything (can't prompt)
55
+ // THE NO-TTY PATH, CORRECTED 2026-09-23. It previously read "can't prompt, therefore approve".
56
+ //
57
+ // That is the wrong direction and it was the more serious half of this hole. `rl` exists only
58
+ // when stdin is a TTY, so ANY script, launchd job, systemd unit, CI runner or wrapper fell into
59
+ // this branch and received full auto-approval while the session was in the DEFAULT supervised
60
+ // mode, which nobody opted into. Measured against the published 0.6.2 on 2026-09-23: supervised
61
+ // with no TTY returned screenshot=true click=true type=true.
62
+ //
63
+ // Being unable to ask for consent is not the same as having it. We now refuse unless autonomous
64
+ // was explicitly chosen, which preserves the documented path exactly (the dashboard prints
65
+ // --autonomous) and closes the silent one.
49
66
  if (!rl) {
50
67
  if (dangerous) {
51
68
  console.log(`\n BLOCKED (dangerous, no TTY): ${desc}`);
52
69
  return false;
53
70
  }
54
- console.log(`\n Agent wants to: ${desc}`);
55
- console.log(" Auto-approved (non-interactive mode)");
71
+ if (mode !== "autonomous") {
72
+ console.log(`\n Agent wants to: ${desc}`);
73
+ console.log(" REFUSED. This session did not opt in, and nothing here can show you");
74
+ console.log(" what is happening or let you stop it.");
75
+ console.log(" Start Dispatch from your dashboard, or re-run with --autonomous.");
76
+ return false;
77
+ }
78
+ console.log(`\n Agent: ${desc}`);
79
+ console.log(" Auto-approved (autonomous, non-interactive)");
56
80
  return true;
57
81
  }
58
82
  // Autonomous mode — auto-approve non-dangerous actions
@@ -63,7 +87,7 @@ export async function requestApproval(command) {
63
87
  // Supervised mode OR dangerous action in autonomous mode — prompt user
64
88
  console.log(`\n Agent wants to: ${desc}`);
65
89
  if (dangerous) {
66
- console.log(" ⚠️ DANGEROUS ACTION — requires confirmation");
90
+ console.log(" ⚠️ DANGEROUS ACTION, requires confirmation");
67
91
  }
68
92
  return new Promise((resolve) => {
69
93
  rl.question(" [Enter] to approve, [n] to deny: ", (answer) => {
@@ -75,23 +99,72 @@ export async function requestApproval(command) {
75
99
  });
76
100
  });
77
101
  }
78
- function isDangerous(cmd) {
79
- const p = cmd.params;
80
- if (cmd.type === "type") {
81
- const text = String(p.text || "");
82
- return DANGEROUS_TEXT_PATTERNS.some((pat) => pat.test(text));
83
- }
84
- if (cmd.type === "press") {
85
- const key = String(p.key || "").toLowerCase();
86
- return DANGEROUS_KEY_COMBOS.has(key);
87
- }
88
- // Check exec commands for dangerous patterns
89
- if (cmd.type === "exec") {
102
+ /**
103
+ * Per-type danger classification.
104
+ *
105
+ * EXHAUSTIVE BY CONSTRUCTION. This is a Record over the declared union, so adding an
106
+ * eleventh member to DispatchCommand["type"] is a COMPILE ERROR until it is classified
107
+ * here. Before 2026-09-23 this was an if chain that handled four of the ten declared
108
+ * types and let the other six reach a bare "return false".
109
+ *
110
+ * WHAT IS DELIBERATELY UNCHANGED, and why. click, scroll and drag stay not dangerous.
111
+ * They are the ordinary acting verbs of autonomous mode, and the dashboard's own
112
+ * fallback command ends in --autonomous, so classifying them dangerous would refuse the
113
+ * product's documented use. The protection against acting with no person present is the
114
+ * no TTY branch in confirmAction, never this table. Every verdict for the ten known
115
+ * types is preserved exactly as it shipped.
116
+ */
117
+ /**
118
+ * CONSEQUENTIAL INTENT, read from the command's OWN description.
119
+ *
120
+ * Cooper's ruling of 2026-09-23: an opted-in session acts without asking per action, and pauses
121
+ * only before an act that leaves the customer's account or cannot be undone, namely sending,
122
+ * buying, deleting or submitting.
123
+ *
124
+ * A click carries coordinates, so this layer cannot know what sits under the cursor. But the
125
+ * BOX sends a `description` alongside it, which is the agent stating its own intent, and before
126
+ * 2026-09-23 nothing read it. Measured that day against the published 0.6.2 and against this
127
+ * build: "Send the email", "Buy it now" and "Delete the file" were ALL auto-approved.
128
+ *
129
+ * HONEST LIMIT, stated because it decides where the real fix lives: this only sees intent the
130
+ * agent chose to write down. An agent that sends no description, or writes "click the button",
131
+ * passes straight through. So this is the LAST line and not the authoritative one. The
132
+ * authoritative pause belongs in the agent's own instructions, which CORTEX owns, and this is
133
+ * filed there. Defence in depth, not a substitute.
134
+ */
135
+ const CONSEQUENTIAL_INTENT_PATTERNS = [
136
+ // Leaves the customer's account
137
+ /\bsend(s|ing)?\b/i, /\bsubmit(s|ting)?\b/i, /\bpost(s|ing)?\b/i,
138
+ /\bpublish(es|ing)?\b/i, /\bshare(s|ing)?\b/i, /\breply\b/i, /\bforward(s|ing)?\b/i,
139
+ // Spends money
140
+ /\bbuy(s|ing)?\b/i, /\bpurchase(s|ing)?\b/i, /\bpay(s|ing|ment)?\b/i,
141
+ /\bcheckout\b/i, /\border\b/i, /\btransfer(s|ring)?\b/i, /\bwithdraw(s|ing)?\b/i,
142
+ // Cannot be undone
143
+ /\bdelete(s|ing)?\b/i, /\bremove(s|ing)?\b/i, /\btrash\b/i, /\berase(s|ing)?\b/i,
144
+ /\bconfirm(s|ing)?\b/i, /\bapprove(s|ing)?\b/i, /\baccept(s|ing)?\b/i,
145
+ ];
146
+ /** True when the agent's own stated intent names a consequential act. */
147
+ export function descriptionIsConsequential(description) {
148
+ const d = String(description || "");
149
+ if (!d)
150
+ return false;
151
+ return CONSEQUENTIAL_INTENT_PATTERNS.some((pat) => pat.test(d));
152
+ }
153
+ const DANGER_BY_TYPE = {
154
+ // Reads. They observe the computer and change nothing.
155
+ screenshot: () => false,
156
+ status: () => false,
157
+ windows: () => false,
158
+ // Acting verbs, deliberately not dangerous. See the note above.
159
+ click: () => false,
160
+ scroll: () => false,
161
+ drag: () => false,
162
+ type: (p) => DANGEROUS_TEXT_PATTERNS.some((pat) => pat.test(String(p.text || ""))),
163
+ press: (p) => DANGEROUS_KEY_COMBOS.has(String(p.key || "").toLowerCase()),
164
+ exec: (p) => {
90
165
  const command = String(p.command || "");
91
- // Check against text patterns (reuses the same dangerous detection)
92
166
  if (DANGEROUS_TEXT_PATTERNS.some((pat) => pat.test(command)))
93
167
  return true;
94
- // Additional exec-specific dangerous patterns
95
168
  if (/\brm\s+-rf\b/.test(command))
96
169
  return true;
97
170
  if (/\bsudo\b/.test(command))
@@ -101,21 +174,37 @@ function isDangerous(cmd) {
101
174
  if (/\bcurl\b.*\|\s*(bash|sh)\b/.test(command))
102
175
  return true;
103
176
  return false;
104
- }
105
- // Check all sub-actions in a batch
106
- if (cmd.type === "batch") {
177
+ },
178
+ batch: (p) => {
107
179
  const actions = p.actions;
108
- if (Array.isArray(actions)) {
109
- return actions.some((a) => isDangerous({ id: "", type: a.type, params: a.params || {} }));
110
- }
111
- }
112
- return false;
180
+ if (!Array.isArray(actions))
181
+ return false;
182
+ return actions.some((a) => isDangerous({
183
+ id: "",
184
+ type: a.type,
185
+ params: a.params || {},
186
+ }));
187
+ },
188
+ };
189
+ function isDangerous(cmd) {
190
+ // THE INTENT CHECK, before the per-type table. Applies to EVERY type, because a described
191
+ // exec or drag is just as consequential as a described click.
192
+ if (descriptionIsConsequential(cmd.description))
193
+ return true;
194
+ const classify = DANGER_BY_TYPE[cmd.type];
195
+ // FAIL CLOSED on a type this build has never heard of. A newer or a hostile server can
196
+ // send one, and before 2026-09-23 it reached "return false" and was called safe.
197
+ // Calling it dangerous costs nothing real, because the executor cannot run a type it
198
+ // does not implement either, and it means the next type added cannot arrive unclassified.
199
+ if (typeof classify !== "function")
200
+ return true;
201
+ return classify(cmd.params);
113
202
  }
114
203
  function formatCommand(cmd) {
115
204
  const p = cmd.params;
116
205
  switch (cmd.type) {
117
206
  case "click":
118
- return `Click at (${p.x}, ${p.y})${cmd.description ? ` — "${cmd.description}"` : ""}`;
207
+ return `Click at (${p.x}, ${p.y})${cmd.description ? `, "${cmd.description}"` : ""}`;
119
208
  case "type":
120
209
  return `Type: "${String(p.text).substring(0, 50)}${String(p.text).length > 50 ? "..." : ""}"`;
121
210
  case "press":
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@instaclaw/dispatch",
3
- "version": "0.6.2",
4
- "description": "Let your InstaClaw AI agent control your computer — screenshots, clicks, typing, and more via a secure WebSocket relay",
3
+ "version": "0.7.0",
4
+ "description": "Let your InstaClaw AI agent control your computer \u2014 screenshots, clicks, typing, and more via a secure WebSocket relay",
5
5
  "bin": {
6
6
  "instaclaw-dispatch": "./dist/index.js"
7
7
  },