mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
0d4e58914e
Work-in-progress snapshot preserved for the dedicated #2026 PR. NOT for merge as-is: the migration is incomplete (injected-executor ...WithAdb helpers, packages/platform-* host ports, and ~30 test files still assert the legacy ['shell', ...] shape), and a partially enforced seam is exactly what #2026 must avoid landing. Mechanism (complete, green in isolation): - packages/kernel/src/shell-safe.ts: branded ShellSafe plus sh.lit/lits/arg/num/raw, byte-identical to raw argv for safe-charset input (parity test). - runAndroidShell/runAndroidExecOut, runHarmonyShell, runLimrunAndroidShell funnels; a raw shell/exec-out argv is rejected at the adb executor, hdc, and limrun boundaries (catches variable-built argv, which a static gate cannot). - src/platforms/android/__tests__/device-shell-boundary.test.ts: planted-red tests for raw and indirect/variable-built argv, plus a ts-expect-error proving raw strings do not typecheck. - scripts/shell-safe: AST backstop holding sh.raw and ShellSafe casts to sites carrying a shell-safe-approved comment with a reason. Migrated so far: android input-actions/perf*/settings/screenshot/notifications/ permission-grant-state/app-deployment-resolution, all harmonyos, limrun, daemon/android-system-dialog, platform-runtime-runtime-hints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8
132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
// Backstop for the typed device-shell boundary. `ShellSafe` (packages/kernel/
|
|
// src/shell-safe.ts) makes a raw string unable to reach a device shell — but
|
|
// TypeScript's nominal typing can always be defeated by an assertion, and the
|
|
// `sh.raw()` constructor is a deliberate escape hatch for author-written shell
|
|
// fragments. This gate holds both to an approved set, the same shape as
|
|
// scripts/di-seams: an AST match must carry a `// shell-safe-approved: <reason>`
|
|
// comment on the immediately-preceding line, or the gate fails.
|
|
//
|
|
// Two things are matched in production source:
|
|
// - `sh.raw(...)` calls (the escape hatch): every use must be justified.
|
|
// - `as ShellSafe` / `as unknown as ShellSafe` casts: a hole in the brand.
|
|
// These are legitimate ONLY inside the constructors in shell-safe.ts, which
|
|
// the check exempts by path; anywhere else they need approval.
|
|
|
|
import { parseSync } from 'oxc-parser';
|
|
|
|
const APPROVAL_MARKER = 'shell-safe-approved:';
|
|
|
|
export type SourceFile = {
|
|
readonly path: string;
|
|
readonly source: string;
|
|
};
|
|
|
|
export type ShellSafeMatch = {
|
|
readonly file: string;
|
|
readonly line: number;
|
|
readonly kind: 'sh.raw' | 'as-shell-safe';
|
|
readonly text: string;
|
|
readonly approved: boolean;
|
|
};
|
|
|
|
type AstNode = Record<string, unknown>;
|
|
type Comment = { readonly value: string; readonly start: number; readonly end: number };
|
|
|
|
function lineOf(source: string, offset: number): number {
|
|
return source.slice(0, offset).split('\n').length;
|
|
}
|
|
|
|
/**
|
|
* True when a `shell-safe-approved: <reason>` comment sits on a line at or just
|
|
* above the match's line — the same "marker precedes what it exempts" shape as
|
|
* scripts/di-seams, but line-anchored so an approval directly above a statement
|
|
* covers a `sh.raw(...)` nested inside that statement (`const x = sh.raw(...)`).
|
|
*/
|
|
function isApproved(source: string, comments: readonly Comment[], start: number): boolean {
|
|
const matchLine = lineOf(source, start);
|
|
for (const comment of comments) {
|
|
if (comment.end > start) continue;
|
|
const commentLine = lineOf(source, comment.end);
|
|
if (commentLine !== matchLine && commentLine !== matchLine - 1) continue;
|
|
const value = comment.value.trim();
|
|
if (value.startsWith(APPROVAL_MARKER)) {
|
|
return value.slice(APPROVAL_MARKER.length).trim().length > 0;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isShRaw(node: AstNode): boolean {
|
|
if (node['type'] !== 'CallExpression') return false;
|
|
const callee = node['callee'] as AstNode | undefined;
|
|
if (callee?.['type'] !== 'MemberExpression') return false;
|
|
const object = callee['object'] as AstNode | undefined;
|
|
const property = callee['property'] as AstNode | undefined;
|
|
return (
|
|
object?.['type'] === 'Identifier' &&
|
|
object['name'] === 'sh' &&
|
|
property?.['type'] === 'Identifier' &&
|
|
property['name'] === 'raw'
|
|
);
|
|
}
|
|
|
|
function isShellSafeCast(node: AstNode): boolean {
|
|
if (node['type'] !== 'TSAsExpression') return false;
|
|
const annotation = node['typeAnnotation'] as AstNode | undefined;
|
|
return (
|
|
annotation?.['type'] === 'TSTypeReference' &&
|
|
(annotation['typeName'] as AstNode | undefined)?.['type'] === 'Identifier' &&
|
|
(annotation['typeName'] as AstNode)['name'] === 'ShellSafe'
|
|
);
|
|
}
|
|
|
|
/** Path whose `as ShellSafe` casts are the sanctioned constructors, not holes. */
|
|
function isBrandConstructorModule(file: string): boolean {
|
|
return file.endsWith('packages/kernel/src/shell-safe.ts') || file.endsWith('kernel/src/shell-safe.ts');
|
|
}
|
|
|
|
export function findShellSafeMatches(files: readonly SourceFile[]): ShellSafeMatch[] {
|
|
const matches: ShellSafeMatch[] = [];
|
|
for (const { path: file, source } of files) {
|
|
if (!source.includes('sh.raw') && !source.includes('as ShellSafe')) continue;
|
|
const parsed = parseSync(file, source);
|
|
const comments = parsed.comments as readonly Comment[];
|
|
const constructorModule = isBrandConstructorModule(file);
|
|
const visit = (node: unknown): void => {
|
|
if (node === null || typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) visit(child);
|
|
return;
|
|
}
|
|
const record = node as AstNode;
|
|
const kind = isShRaw(record)
|
|
? ('sh.raw' as const)
|
|
: isShellSafeCast(record) && !constructorModule
|
|
? ('as-shell-safe' as const)
|
|
: undefined;
|
|
if (kind) {
|
|
const start = record['start'] as number;
|
|
matches.push({
|
|
file,
|
|
line: lineOf(source, start),
|
|
kind,
|
|
text: source
|
|
.slice(start, record['end'] as number)
|
|
.replace(/\s+/g, ' ')
|
|
.slice(0, 100),
|
|
approved: isApproved(source, comments, start),
|
|
});
|
|
}
|
|
for (const value of Object.values(record)) visit(value);
|
|
};
|
|
visit(parsed.program);
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
export function unapprovedShellSafeMatches(
|
|
matches: readonly ShellSafeMatch[],
|
|
): readonly ShellSafeMatch[] {
|
|
return matches.filter((match) => !match.approved);
|
|
}
|