Skill Tracer
Skills › gstack › browse › commands.ts

commands.ts

154 lines · browse/src/routes/commands.ts at 3f79d81

1/**
2 * Command routes: POST /command (one command; the only non-/connect tunnel
3 * route) and POST /batch (N commands in one round trip). Both accept root and
4 * scoped tokens and run through the full command security pipeline. Also owns
5 * the tunnel command allowlist.
6 */
7
8import { type RouteEntry, jsonError } from './table';
9import { canonicalizeCommand } from '../commands';
10import { hasOutArg } from '../read-commands';
11import { emitActivity } from '../activity';
12import { logTunnelDenial } from '../tunnel-denial-log';
13import { sanitizeBody, stripLoneSurrogateEscapes } from '../sanitize';
15/**
16 * Commands reachable via POST /command over the tunnel surface. A paired
17 * remote agent can drive the browser (goto, click, text, etc.) but cannot
18 * configure the daemon, bootstrap new sessions, import cookies, or reach
19 * extension-inspector state. This allowlist maps to the eng-review decision
20 * logged in the CEO plan for sec-wave v1.6.0.0.
21 */
22export const TUNNEL_COMMANDS = new Set<string>([
23 // Original 17
24 'goto', 'click', 'text', 'screenshot',
25 'html', 'links', 'forms', 'accessibility',
26 'attrs', 'media', 'data',
27 'scroll', 'press', 'type', 'select', 'wait', 'eval',
28 // Tab + navigation primitives operator docs and CLI hints already promised
29 'newtab', 'tabs', 'back', 'forward', 'reload',
30 // Read/inspect/write operators paired agents need to be useful
31 'snapshot', 'fill', 'url', 'closetab',
32]);
34/**
35 * Pure gate: returns true iff the command is reachable over the tunnel surface.
36 * Canonicalizes the command (so aliases hit the same set) and returns false
37 * for null/undefined input.
38 *
39 * `args` is consulted so an `--out` invocation (e.g. `eval --out <file>`) is
40 * NEVER tunnel-dispatchable: `--out` turns an otherwise-readable command into a
41 * local-disk WRITE, and the tunnel surface never grants disk-write capability to
42 * remote paired agents. Omitting `args` preserves the old command-only behavior.
43 */
44export function canDispatchOverTunnel(command: string | undefined | null, args?: string[]): boolean {
45 if (typeof command !== 'string' || command.length === 0) return false;
46 if (Array.isArray(args) && hasOutArg(args)) return false;
47 const cmd = canonicalizeCommand(command);
48 return TUNNEL_COMMANDS.has(cmd);
51export const commandRoutes: RouteEntry[] = [
52 // ─── Batch endpoint — N commands, 1 HTTP round-trip ─────────────
53 // Executes commands sequentially through the full security pipeline.
54 // Designed for remote agents where tunnel latency dominates.
55 {
56 method: 'POST', path: '/batch', auth: 'scoped', surfaces: ['local'],
57 handler: async (req, { tokenInfo }, ctx) => {
58 const { browserManager } = ctx;
59 ctx.resetIdleTimer();
60 const body = await req.json();
61 const { commands } = body;
62 if (!Array.isArray(commands) || commands.length === 0) return jsonError(400, '"commands" must be a non-empty array');
63 if (commands.length > 50) return jsonError(400, 'Max 50 commands per batch');
65 const startTime = Date.now();
66 emitActivity({
67 type: 'command_start',
68 command: 'batch',
69 args: [`${commands.length} commands`],
70 url: browserManager.getCurrentUrl(),
71 tabs: browserManager.getTabCount(),
72 mode: browserManager.getConnectionMode(),
73 clientId: tokenInfo?.clientId,
74 });
76 const results: Array<{ index: number; status: number; result: string; command: string; tabId?: number }> = [];
77 for (let i = 0; i < commands.length; i++) {
78 const cmd = commands[i];
79 if (!cmd || typeof cmd.command !== 'string') {
80 results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), command: '' });
81 continue;
82 }
83 // Reject nested batches
84 if (cmd.command === 'batch') {
85 results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Nested batch commands are not allowed' }), command: 'batch' });
86 continue;
87 }
88 const cr = await ctx.commands.handleInternal(
89 { command: cmd.command, args: cmd.args, tabId: cmd.tabId },
90 tokenInfo,
91 { skipRateCheck: true, skipActivity: true },
92 );
93 // Sanitize lone surrogates per-result (#1440 — /batch bypasses the
94 // handleCommand chokepoint, so it needs its own sanitization).
95 const safeResult = typeof cr.result === 'string' ? sanitizeBody(cr.result, !!cr.json) : cr.result;
96 results.push({
97 index: i,
98 status: cr.status,
99 result: safeResult,
100 command: cmd.command,
101 tabId: cmd.tabId,
102 });
103 }
105 const duration = Date.now() - startTime;
106 emitActivity({
107 type: 'command_end',
108 command: 'batch',
109 args: [`${commands.length} commands`],
110 url: browserManager.getCurrentUrl(),
111 duration,
112 status: 'ok',
113 result: `${results.filter(r => r.status === 200).length}/${commands.length} succeeded`,
114 tabs: browserManager.getTabCount(),
115 mode: browserManager.getConnectionMode(),
116 clientId: tokenInfo?.clientId,
117 });
119 // Sanitize the JSON envelope a second time (defense in depth) — catches
120 // any \uXXXX escape sequences for lone surrogates that survived the
121 // per-result pass.
122 const batchBody = stripLoneSurrogateEscapes(JSON.stringify({
123 results,
124 duration,
125 total: commands.length,
126 succeeded: results.filter(r => r.status === 200).length,
127 failed: results.filter(r => r.status !== 200).length,
128 }));
129 return new Response(batchBody, {
130 status: 200,
131 headers: { 'Content-Type': 'application/json' },
132 });
133 },
134 },
136 // ─── Command endpoint ───────────────────────────────────────────
137 {
138 method: 'POST', path: '/command', auth: 'scoped', surfaces: ['local', 'tunnel'],
139 handler: async (req, { url, surface, tokenInfo }, ctx) => {
140 ctx.resetIdleTimer();
141 const body = await req.json() as any;
142 // Tunnel surface: only commands in TUNNEL_COMMANDS are allowed.
143 // Paired remote agents drive the browser but cannot configure the
144 // daemon, launch new browsers, import cookies, or rotate tokens.
145 if (surface === 'tunnel' && !canDispatchOverTunnel(body?.command, body?.args)) {
146 logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
147 return jsonError(403, `Command '${body?.command}' is not allowed over the tunnel surface`, {
148 hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Note: --out (disk write) is never allowed over the tunnel.`,
149 });
150 }
151 return ctx.commands.handle(body, tokenInfo);
152 },
153 },
154];