2026-05-15 13:19:10 +09:00
import { describe , expect , test } from "bun:test"
import { readdir , readFile } from "node:fs/promises"
import path from "node:path"
2026-05-16 00:49:50 +09:00
import ts from "typescript"
2026-05-15 13:19:10 +09:00
const SOURCE_ROOT = path . resolve ( import . meta . dir , ".." )
const PROMPT_GATE_FILE = path . join ( SOURCE_ROOT , "shared" , "prompt-async-gate.ts" )
2026-05-16 00:49:50 +09:00
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" ,
] ,
2026-05-17 03:10:02 +09:00
[
path . join ( SOURCE_ROOT , "plugin" , "build-team-idle-wake-hint-client.ts" ) ,
"binds SDK Session.promptAsync/.status into a narrow facade consumed only by gate-routed team-idle-wake-hint dispatch; performs no direct dispatch itself" ,
] ,
2026-05-16 00:49:50 +09:00
[
path . join ( SOURCE_ROOT , "hooks" , "session-recovery" , "recover-unavailable-tool.ts" ) ,
2026-05-17 17:16:18 +09:00
"runtime type guard checks promptAsync presence before gate-routed dispatchInternalPrompt" ,
2026-05-16 00:49:50 +09:00
] ,
] )
2026-05-15 13:19:10 +09:00
async function listSourceFiles ( directory : string ) : Promise < string [ ] > {
const entries = await readdir ( directory , { withFileTypes : true } )
const nestedFiles = await Promise . all ( entries . map ( async ( entry ) = > {
const entryPath = path . join ( directory , entry . name )
if ( entry . isDirectory ( ) ) {
return listSourceFiles ( entryPath )
}
if (
entry . isFile ( )
&& entry . name . endsWith ( ".ts" )
&& ! entry . name . endsWith ( ".test.ts" )
&& ! entry . name . endsWith ( ".d.ts" )
) {
return [ entryPath ]
}
return [ ]
} ) )
return nestedFiles . flat ( )
}
function relativeSourcePath ( filePath : string ) : string {
return path . relative ( SOURCE_ROOT , filePath )
}
2026-05-16 00:49:50 +09:00
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 )
2026-05-17 17:17:20 +09:00
: ts . isIdentifier ( element . name ) ? getPropertyName ( element . name ) : null
2026-05-16 00:49:50 +09:00
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
2026-05-15 13:19:10 +09:00
}
describe ( "production prompt injection routes" , ( ) = > {
2026-05-16 00:49:50 +09:00
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 )
} )
2026-05-15 13:19:10 +09:00
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 [ ] = [ ]
// when
for ( const filePath of files ) {
2026-05-16 00:49:50 +09:00
if ( filePath === PROMPT_GATE_FILE || RAW_PROMPT_ALLOWLIST . has ( filePath ) ) {
2026-05-15 13:19:10 +09:00
continue
}
2026-05-16 00:49:50 +09:00
const contents = await readFile ( filePath , "utf8" )
if ( detectRawPromptInSnippet ( contents ) ) {
2026-05-15 13:19:10 +09:00
offenders . push ( relativeSourcePath ( filePath ) )
}
}
// then
expect ( offenders ) . toEqual ( [ ] )
} )
test ( "#given production TypeScript sources #when prompt gate callers are audited #then callers cannot disable the post-dispatch reservation hold" , async ( ) = > {
// given
const files = await listSourceFiles ( SOURCE_ROOT )
const offenders : string [ ] = [ ]
// when
for ( const filePath of files ) {
2026-05-16 00:49:50 +09:00
const contents = await readFile ( filePath , "utf8" )
2026-05-15 13:19:10 +09:00
if ( /postDispatchHoldMs\s*:\s*0\b/ . test ( contents ) ) {
offenders . push ( relativeSourcePath ( filePath ) )
}
}
// then
expect ( offenders ) . toEqual ( [ ] )
} )
} )