From 8c4cc09de7ac9d2c9f246af3b9bd735228455dd2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 00:49:50 +0900 Subject: [PATCH] test(prompt-async-route-audit): migrate to TypeScript AST walker Replaces the previous regex-based audit (6 line-prefix patterns) with a TypeScript Compiler API AST walker that detects raw client.session.prompt and client.session.promptAsync access in any access shape: - direct call (existing): client.session.promptAsync(...) - property access reference: const x = client.session.promptAsync - bracket access: client['session']['promptAsync'] - optional chaining: client.session?.promptAsync - type cast aliasing: (client.session as { promptAsync }).promptAsync - destructuring: const { promptAsync } = client.session RAW_PROMPT_ALLOWLIST captures two legitimate callers that route through the gate but reference promptAsync as a property value: - src/plugin/event.ts wires a client facade for team-idle-wake-hint - src/hooks/session-recovery/recover-unavailable-tool.ts guards capability before dispatching through promptAsyncAfterSessionIdle. Each allowlist entry carries a justification string so future contributors understand why the exception exists. Closes HIGH-5 Co-authored-by: audit-ast (deep / gpt-5.3-codex high) --- src/shared/prompt-async-route-audit.test.ts | 230 ++++++++++++++++++-- 1 file changed, 213 insertions(+), 17 deletions(-) diff --git a/src/shared/prompt-async-route-audit.test.ts b/src/shared/prompt-async-route-audit.test.ts index 32b00f88b..21e7b8d98 100644 --- a/src/shared/prompt-async-route-audit.test.ts +++ b/src/shared/prompt-async-route-audit.test.ts @@ -1,9 +1,20 @@ import { describe, expect, test } from "bun:test" import { readdir, readFile } from "node:fs/promises" import path from "node:path" +import ts from "typescript" const SOURCE_ROOT = path.resolve(import.meta.dir, "..") const PROMPT_GATE_FILE = path.join(SOURCE_ROOT, "shared", "prompt-async-gate.ts") +const RAW_PROMPT_ALLOWLIST = new Map([ + [ + path.join(SOURCE_ROOT, "plugin", "event.ts"), + "team idle wake hint wires a client facade for downstream gate-routed dispatch", + ], + [ + path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"), + "runtime type guard checks promptAsync presence before gate-routed promptAsyncAfterSessionIdle", + ], +]) async function listSourceFiles(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }) @@ -30,35 +41,220 @@ function relativeSourcePath(filePath: string): string { return path.relative(SOURCE_ROOT, filePath) } -function uncommentedLines(contents: string): string[] { - return contents - .split("\n") - .map((line) => line.trimStart()) - .filter((line) => !line.startsWith("//") && !line.startsWith("*")) +function getPropertyName(node: ts.PropertyName | ts.MemberName | ts.Expression): string | null { + if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) { + return node.text + } + + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text + } + + return null +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + if (ts.isParenthesizedExpression(expression)) { + return unwrapExpression(expression.expression) + } + + if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) { + return unwrapExpression(expression.expression) + } + + if (ts.isNonNullExpression(expression)) { + return unwrapExpression(expression.expression) + } + + return expression +} + +function isSessionAccessExpression(expression: ts.Expression): boolean { + const unwrapped = unwrapExpression(expression) + + if (ts.isIdentifier(unwrapped)) { + return unwrapped.text === "session" + } + + if ( + ts.isPropertyAccessExpression(unwrapped) + || ts.isPropertyAccessChain(unwrapped) + ) { + const propertyName = getPropertyName(unwrapped.name) + return propertyName === "session" + } + + if ( + ts.isElementAccessExpression(unwrapped) + || ts.isElementAccessChain(unwrapped) + ) { + const argument = unwrapped.argumentExpression + if (!argument) { + return false + } + + return getPropertyName(argument) === "session" + } + + return false +} + +function isRawPromptPropertyAccess(node: ts.Node): boolean { + if ( + ts.isPropertyAccessExpression(node) + || ts.isPropertyAccessChain(node) + ) { + const propertyName = getPropertyName(node.name) + if (propertyName !== "prompt" && propertyName !== "promptAsync") { + return false + } + + return isSessionAccessExpression(node.expression) + } + + if ( + ts.isElementAccessExpression(node) + || ts.isElementAccessChain(node) + ) { + const argument = node.argumentExpression + if (!argument) { + return false + } + + const propertyName = getPropertyName(argument) + if (propertyName !== "prompt" && propertyName !== "promptAsync") { + return false + } + + return isSessionAccessExpression(node.expression) + } + + return false +} + +function isPromptBindingPattern(node: ts.Node): boolean { + if (!ts.isVariableDeclaration(node) || !node.initializer || !ts.isObjectBindingPattern(node.name)) { + return false + } + + if (!isSessionAccessExpression(node.initializer)) { + return false + } + + return node.name.elements.some((element) => { + const keyName = element.propertyName + ? getPropertyName(element.propertyName) + : getPropertyName(element.name) + return keyName === "prompt" || keyName === "promptAsync" + }) +} + +function isReflectApplyPromptCall(node: ts.Node): boolean { + if (!ts.isCallExpression(node)) { + return false + } + + const callee = unwrapExpression(node.expression) + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "apply") { + return false + } + + if (!ts.isIdentifier(callee.expression) || callee.expression.text !== "Reflect") { + return false + } + + const firstArgument = node.arguments[0] + if (!firstArgument) { + return false + } + + return isRawPromptPropertyAccess(firstArgument) +} + +function isTypeofPromptCheck(node: ts.Node): boolean { + return ts.isTypeOfExpression(node.parent) +} + +function detectRawPromptInSnippet(contents: string): boolean { + const sourceFile = ts.createSourceFile("audit-snippet.ts", contents, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + let detected = false + + const visit = (node: ts.Node): void => { + if (detected) { + return + } + + const isRawPromptAccess = isRawPromptPropertyAccess(node) && !isTypeofPromptCheck(node) + if (isRawPromptAccess || isPromptBindingPattern(node) || isReflectApplyPromptCall(node)) { + detected = true + return + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return detected } describe("production prompt injection routes", () => { + test("#given a destructuring promptAsync reference #when audit scans snippet #then it is flagged", () => { + // given + const snippet = "const { promptAsync } = client.session" + + // when + const detected = detectRawPromptInSnippet(snippet) + + // then + expect(detected).toBe(true) + }) + + test("#given bracket promptAsync reference #when audit scans snippet #then it is flagged", () => { + // given + const snippet = "const value = client['session']['promptAsync']" + + // when + const detected = detectRawPromptInSnippet(snippet) + + // then + expect(detected).toBe(true) + }) + + test("#given type-cast promptAsync reference #when audit scans snippet #then it is flagged", () => { + // given + const snippet = "const promptAsync = (client.session as { promptAsync?: unknown }).promptAsync" + + // when + const detected = detectRawPromptInSnippet(snippet) + + // then + expect(detected).toBe(true) + }) + + test("#given optional-chain promptAsync call #when audit scans snippet #then it is flagged", () => { + // given + const snippet = "await client.session?.promptAsync({ body: { text: 'hi' } })" + + // when + const detected = detectRawPromptInSnippet(snippet) + + // then + expect(detected).toBe(true) + }) + test("#given production TypeScript sources #when prompt routes are audited #then only the shared gate may call raw OpenCode prompt APIs", async () => { // given const files = await listSourceFiles(SOURCE_ROOT) const offenders: string[] = [] - const rawPromptPatterns = [ - /\bsession\.promptAsync\s*\(/, - /\bsession\.prompt\s*\(/, - /\bReflect\.apply\s*\(\s*\w*promptAsync\b/, - /\bReflect\.apply\s*\(\s*\w*prompt\b/, - /\b(?:const|let|var)\s+\w*promptAsync\w*\s*=\s*[\w.]+\.session\.promptAsync\b/, - /\b(?:const|let|var)\s+\w*prompt\w*\s*=\s*[\w.]+\.session\.prompt\b/, - ] // when for (const filePath of files) { - if (filePath === PROMPT_GATE_FILE) { + if (filePath === PROMPT_GATE_FILE || RAW_PROMPT_ALLOWLIST.has(filePath)) { continue } - const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n") - if (rawPromptPatterns.some((pattern) => pattern.test(contents))) { + const contents = await readFile(filePath, "utf8") + if (detectRawPromptInSnippet(contents)) { offenders.push(relativeSourcePath(filePath)) } } @@ -74,7 +270,7 @@ describe("production prompt injection routes", () => { // when for (const filePath of files) { - const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n") + const contents = await readFile(filePath, "utf8") if (/postDispatchHoldMs\s*:\s*0\b/.test(contents)) { offenders.push(relativeSourcePath(filePath)) }