Skip to content

Commit 98069db

Browse files
authored
fix: harden command execution and replay script escaping (#364)
* fix: harden command execution and replay script escaping * fix: tighten whichCmd executable checks * fix: allow explicit relative executables
1 parent 3eccb5b commit 98069db

7 files changed

Lines changed: 215 additions & 36 deletions

File tree

src/daemon/__tests__/session-store.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,25 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe',
345345
assert.match(script, /swipe 10 20 30 40 --count 3 --pause-ms 12 --pattern ping-pong/);
346346
assert.match(script, /fill @e5 "search" --delay-ms 40/);
347347
});
348+
349+
test('writeSessionLog escapes device labels with quotes and backslashes', () => {
350+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-device-label-'));
351+
const store = new SessionStore(root);
352+
const session = makeSession('default');
353+
session.device.name = 'QA "Lab" \\ Shelf';
354+
store.recordAction(session, {
355+
command: 'open',
356+
positionals: ['Settings'],
357+
flags: { platform: 'ios', saveScript: true },
358+
result: {},
359+
});
360+
361+
store.writeSessionLog(session);
362+
const scriptFile = fs.readdirSync(root).find((file) => file.endsWith('.ad'));
363+
assert.ok(scriptFile);
364+
const script = fs.readFileSync(path.join(root, scriptFile!), 'utf8');
365+
assert.match(
366+
script,
367+
/context platform=ios device="QA \\"Lab\\" \\\\ Shelf" kind=simulator theme=unknown/,
368+
);
369+
});

src/daemon/handlers/__tests__/session-replay-script.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ test('type replay script preserves literal delay flag tokens', () => {
130130
assert.equal(parsed[0]?.flags.delayMs, undefined);
131131
});
132132

133+
test('writeReplayScript escapes device labels with quotes and backslashes', () => {
134+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-script-device-label-'));
135+
const replayPath = path.join(root, 'flow.ad');
136+
const session = makeSession();
137+
session.device.name = 'Pixel "QA" \\ Lab';
138+
139+
writeReplayScript(replayPath, [], session);
140+
const script = fs.readFileSync(replayPath, 'utf8');
141+
142+
assert.match(
143+
script,
144+
/context platform=android device="Pixel \\"QA\\" \\\\ Lab" kind=emulator theme=unknown/,
145+
);
146+
});
147+
133148
test('readReplayScriptMetadata extracts platform from context header', () => {
134149
const metadata = readReplayScriptMetadata(
135150
'# comment\n\ncontext platform=android device="Pixel 9 Pro"\nopen "Demo"\n',

src/daemon/handlers/session-replay-script.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,20 @@ import {
99
appendScriptSeriesFlags,
1010
formatScriptArgQuoteIfNeeded,
1111
formatScriptArg,
12+
formatScriptStringLiteral,
1213
isClickLikeCommand,
1314
parseReplaySeriesFlags,
1415
parseReplayRuntimeFlags,
1516
} from '../script-utils.ts';
1617

1718
type ReplayScriptPlatform = Exclude<PlatformSelector, 'apple'>;
1819

19-
const REPLAY_METADATA_PLATFORMS = new Set<ReplayScriptPlatform>(['ios', 'android', 'macos', 'linux']);
20+
const REPLAY_METADATA_PLATFORMS = new Set<ReplayScriptPlatform>([
21+
'ios',
22+
'android',
23+
'macos',
24+
'linux',
25+
]);
2026

2127
export type ReplayScriptMetadata = {
2228
platform?: ReplayScriptPlatform;
@@ -310,11 +316,10 @@ export function writeReplayScript(
310316
// Session can be missing if the replay session is closed/deleted between execution and update write.
311317
// In that case we still persist healed actions and omit only the context header.
312318
if (session) {
313-
const deviceLabel = session.device.name.replace(/"/g, '\\"');
314319
const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
315320
const target = session.device.target ? ` target=${session.device.target}` : '';
316321
lines.push(
317-
`context platform=${session.device.platform}${target} device="${deviceLabel}"${kind} theme=unknown`,
322+
`context platform=${session.device.platform}${target} device=${formatScriptStringLiteral(session.device.name)}${kind} theme=unknown`,
318323
);
319324
}
320325
for (const action of actions) {

src/daemon/script-utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export function formatScriptArg(value: string): string {
3333
return JSON.stringify(trimmed);
3434
}
3535

36+
// Use for literal values such as device labels where leading/trailing whitespace must survive round-trips.
37+
export function formatScriptStringLiteral(value: string): string {
38+
return JSON.stringify(value);
39+
}
40+
3641
// Preserve readable CLI-ish script output for ordinary tokens while still quoting whitespace.
3742
export function formatScriptArgQuoteIfNeeded(value: string): string {
3843
const trimmed = value.trim();

src/daemon/session-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
appendScriptSeriesFlags,
1212
formatScriptArgQuoteIfNeeded,
1313
formatScriptArg,
14+
formatScriptStringLiteral,
1415
isClickLikeCommand,
1516
} from './script-utils.ts';
1617
import { emitDiagnostic } from '../utils/diagnostics.ts';
@@ -285,11 +286,10 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
285286

286287
function formatScript(session: SessionState, actions: SessionAction[]): string {
287288
const lines: string[] = [];
288-
const deviceLabel = session.device.name.replace(/"/g, '\\"');
289289
const kind = session.device.kind ? ` kind=${session.device.kind}` : '';
290290
const theme = 'unknown';
291291
lines.push(
292-
`context platform=${session.device.platform} device="${deviceLabel}"${kind} theme=${theme}`,
292+
`context platform=${session.device.platform} device=${formatScriptStringLiteral(session.device.name)}${kind} theme=${theme}`,
293293
);
294294
for (const action of actions) {
295295
if (action.flags?.noRecord) continue;

src/utils/__tests__/exec.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import { runCmd } from '../exec.ts';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { runCmd, whichCmd } from '../exec.ts';
47

58
test('runCmd enforces timeoutMs and rejects with COMMAND_FAILED', async () => {
69
await assert.rejects(
@@ -16,3 +19,50 @@ test('runCmd enforces timeoutMs and rejects with COMMAND_FAILED', async () => {
1619
},
1720
);
1821
});
22+
23+
test('whichCmd resolves absolute executable paths without invoking a shell', async () => {
24+
assert.equal(await whichCmd(process.execPath), true);
25+
});
26+
27+
test('whichCmd resolves bare commands from PATH', async () => {
28+
assert.equal(await whichCmd('node'), true);
29+
});
30+
31+
test.runIf(process.platform !== 'win32')(
32+
'runCmd allows explicit relative executable paths when shell execution is disabled',
33+
async () => {
34+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runcmd-relative-'));
35+
const target = path.join(root, 'local-node');
36+
fs.symlinkSync(process.execPath, target);
37+
38+
try {
39+
const result = await runCmd('./local-node', ['-e', 'process.stdout.write("ok")'], {
40+
cwd: root,
41+
});
42+
assert.equal(result.stdout, 'ok');
43+
} finally {
44+
fs.rmSync(root, { recursive: true, force: true });
45+
}
46+
},
47+
);
48+
49+
test('whichCmd rejects suspicious command strings', async () => {
50+
assert.equal(await whichCmd('node; rm -rf /'), false);
51+
assert.equal(await whichCmd('./node'), false);
52+
});
53+
54+
test.sequential('whichCmd ignores directories that match a command name in PATH', async () => {
55+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-whichcmd-'));
56+
const fakeCommandDir = path.join(root, 'fake-tool');
57+
fs.mkdirSync(fakeCommandDir);
58+
59+
const previousPath = process.env.PATH;
60+
process.env.PATH = `${root}${path.delimiter}${previousPath ?? ''}`;
61+
62+
try {
63+
assert.equal(await whichCmd('fake-tool'), false);
64+
} finally {
65+
process.env.PATH = previousPath;
66+
fs.rmSync(root, { recursive: true, force: true });
67+
}
68+
});

0 commit comments

Comments
 (0)