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)
This commit is contained in:
@@ -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<string, string>([
|
||||
[
|
||||
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<string[]> {
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user