Merge pull request #4068 from code-yeongyu/feat/pre-publish-fix-v420

v4.2.0: pre-publish review fixes (BLOCKER-1..3, HIGH-5..10, MID-11/12)
This commit is contained in:
YeonGyu-Kim
2026-05-16 14:50:54 +09:00
committed by GitHub
19 changed files with 1707 additions and 726 deletions
@@ -0,0 +1,203 @@
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 MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map<string, string>([
// TODO(MOCK-MODULE-AUDIT): add cleanup for ast-grep tool module mocks.
[
path.join(SOURCE_ROOT, "tools", "ast-grep", "tools.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks.
[
path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for doctor dependency module mocks.
[
path.join(SOURCE_ROOT, "cli", "doctor", "checks", "dependencies.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for session recovery module mocks.
[
path.join(SOURCE_ROOT, "hooks", "session-recovery", "index.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for auto-update checker hook module mocks.
[
path.join(SOURCE_ROOT, "hooks", "auto-update-checker", "hook.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux layout-runner module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "layout-runner.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close-runner module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close-runner.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-dimensions module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-dimensions.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill-runner module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill-runner.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux stale-session sweep module mocks.
[
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "stale-session-sweep-runtime.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
])
async function listTestFiles(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 listTestFiles(entryPath)
}
if (entry.isFile() && 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)
}
function isMockModuleCall(node: ts.CallExpression): boolean {
const expression = node.expression
return ts.isPropertyAccessExpression(expression)
&& ts.isIdentifier(expression.expression)
&& expression.expression.text === "mock"
&& expression.name.text === "module"
}
function getMockModulePath(node: ts.CallExpression): string | null {
if (!isMockModuleCall(node)) {
return null
}
const modulePath = node.arguments[0]
if (!modulePath || !ts.isStringLiteralLike(modulePath)) {
return null
}
return modulePath.text
}
function collectMockModulePaths(sourceFile: ts.SourceFile): string[] {
const modulePaths: string[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const modulePath = getMockModulePath(node)
if (modulePath) {
modulePaths.push(modulePath)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return modulePaths
}
function hasMockModuleCall(sourceFile: ts.SourceFile): boolean {
return collectMockModulePaths(sourceFile).length > 0
}
function hasDuplicateModuleReset(sourceFile: ts.SourceFile): boolean {
const seenModulePaths = new Set<string>()
for (const modulePath of collectMockModulePaths(sourceFile)) {
if (seenModulePaths.has(modulePath)) {
return true
}
seenModulePaths.add(modulePath)
}
return false
}
function isCleanupCall(node: ts.CallExpression): boolean {
if (ts.isIdentifier(node.expression)) {
return node.expression.text === "afterEach" || node.expression.text === "afterAll"
}
const expression = node.expression
return ts.isPropertyAccessExpression(expression)
&& ts.isIdentifier(expression.expression)
&& expression.expression.text === "mock"
&& expression.name.text === "restore"
}
function hasCleanupPattern(sourceFile: ts.SourceFile): boolean {
if (hasDuplicateModuleReset(sourceFile)) {
return true
}
let foundCleanup = false
const visit = (node: ts.Node): void => {
if (foundCleanup) {
return
}
if (ts.isCallExpression(node) && isCleanupCall(node)) {
foundCleanup = true
return
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return foundCleanup
}
describe("mock.module lifecycle hygiene", () => {
test("#given test files using mock.module #when audited #then each must pair with cleanup", async () => {
// given
const files = await listTestFiles(SOURCE_ROOT)
const offenders: string[] = []
// when
for (const filePath of files) {
if (MOCK_MODULE_LIFECYCLE_ALLOWLIST.has(filePath)) {
continue
}
const contents = await readFile(filePath, "utf8")
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
offenders.push(relativeSourcePath(filePath))
}
}
// then
expect(offenders.sort()).toEqual([])
})
})
+11 -1
View File
@@ -5,7 +5,11 @@ import {
PROMPT_TIMEOUT_MS,
type PromptRetryOptions,
} from "./prompt-timeout-context"
import { promptAfterSessionIdle, promptAsyncAfterSessionIdle } from "./prompt-async-gate"
import {
promptAfterSessionIdle,
promptAsyncAfterSessionIdle,
releasePromptAsyncReservation,
} from "./prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
@@ -119,6 +123,7 @@ export async function promptWithModelSuggestionRetry(
if (timeoutContext.wasTimedOut()) {
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
}
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry")
throw error
} finally {
timeoutContext.cleanup()
@@ -169,6 +174,11 @@ export async function promptSyncWithModelSuggestionRetry(
throw error
}
// The first attempt failed synchronously with ProviderModelNotFoundError, which means the
// prompt did not reach the server. Release the post-dispatch reservation hold so the
// immediate retry can dispatch without waiting for the hold window to expire.
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry:sync")
log("[model-suggestion-retry] Model not found, retrying with suggestion", {
original: `${suggestion.providerID}/${suggestion.modelID}`,
suggested: suggestion.suggestion,
+140 -103
View File
@@ -6,6 +6,7 @@ import {
} from "./session-idle-settle"
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
type PromptAsyncInput = {
path?: { id?: string }
@@ -36,6 +37,9 @@ type PromptAsyncReservation = {
expiresAt?: number
}
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
export type PromptAsyncGateResult =
| { status: "dispatched"; response: unknown }
| { status: "active" }
@@ -84,11 +88,114 @@ function reservationSourceMatches(
return false
}
if (typeof expectedPrefix === "string") {
return reservationSource.startsWith(expectedPrefix)
const prefixes = typeof expectedPrefix === "string" ? [expectedPrefix] : expectedPrefix
return prefixes
.filter((prefix) => prefix.length > 0 && prefix.endsWith(":"))
.some((prefix) => reservationSource.startsWith(prefix))
}
async function withDispatchTimeout<T>(
operation: Promise<T>,
dispatchTimeoutMs: number,
operationName: string,
): Promise<T> {
if (dispatchTimeoutMs <= 0) {
return operation
}
return expectedPrefix.some((prefix) => reservationSource.startsWith(prefix))
let timeoutID: ReturnType<typeof globalThis.setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(`${operationName} timed out after ${dispatchTimeoutMs}ms`))
}, dispatchTimeoutMs)
})
try {
return await Promise.race([operation, timeoutPromise])
} finally {
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
}
}
}
async function dispatchAfterSessionIdle<TInput>(args: {
sessionName: "promptAsync" | "prompt"
client: { session?: { status?: () => Promise<unknown> } }
sessionID: string
input: TInput
source: string
settleMs: number
postDispatchHoldMs: number
dispatchTimeoutMs: number
checkStatus: boolean
dispatch: (input: TInput) => Promise<unknown>
}): Promise<PromptAsyncGateResult> {
const {
sessionName,
client,
sessionID,
input,
source,
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus,
dispatch,
} = args
const existing = getActiveReservation(sessionID)
if (existing) {
log(`[prompt-async-gate] ${sessionName} skipped because session is reserved`, {
sessionID,
source,
reservedBy: existing.source,
reservedAgeMs: Date.now() - existing.reservedAt,
})
return { status: "reserved", reservedBy: existing.source }
}
const reservation: PromptAsyncReservation = {
source,
reservedAt: Date.now(),
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let dispatchAttempted = false
try {
const canReadStatus = checkStatus && typeof client.session?.status === "function"
if (settleMs > 0) {
await settleAfterSessionIdle(settleMs)
}
if (canReadStatus && await isSessionActive(client, sessionID)) {
log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source })
return { status: "active" }
}
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
dispatchAttempted = true
const response = await withDispatchTimeout(
dispatch(input),
dispatchTimeoutMs,
`[prompt-async-gate] ${sessionName} dispatch`,
)
log(`[prompt-async-gate] ${sessionName} dispatched`, { sessionID, source })
return { status: "dispatched", response }
} catch (error) {
log(`[prompt-async-gate] ${sessionName} failed`, { sessionID, source, error: String(error) })
return { status: "failed", error }
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
if (dispatchAttempted && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
}
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
@@ -98,6 +205,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
source: string
settleMs?: number
postDispatchHoldMs?: number
dispatchTimeoutMs?: number
checkStatus?: boolean
}): Promise<PromptAsyncGateResult> {
const {
@@ -108,62 +216,26 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
} = args
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
const promptAsync = client.session?.promptAsync
if (typeof client.session?.promptAsync !== "function") {
if (typeof promptAsync !== "function") {
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
return { status: "unavailable" }
}
const existing = getActiveReservation(sessionID)
if (existing) {
log("[prompt-async-gate] promptAsync skipped because session is reserved", {
sessionID,
source,
reservedBy: existing.source,
reservedAgeMs: Date.now() - existing.reservedAt,
})
return { status: "reserved", reservedBy: existing.source }
}
const reservation: PromptAsyncReservation = {
return dispatchAfterSessionIdle({
sessionName: "promptAsync",
client,
sessionID,
input,
source,
reservedAt: Date.now(),
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let holdReservationAfterDispatch = false
try {
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
if (settleMs > 0) {
await settleAfterSessionIdle(settleMs)
}
if (canReadStatus && await isSessionActive(client, sessionID)) {
log("[prompt-async-gate] promptAsync skipped because session is active", { sessionID, source })
return { status: "active" }
}
log("[prompt-async-gate] promptAsync dispatching", { sessionID, source })
const response = await client.session.promptAsync(input)
if (postDispatchHoldMs > 0) {
holdReservationAfterDispatch = true
}
log("[prompt-async-gate] promptAsync dispatched", { sessionID, source })
return { status: "dispatched", response }
} catch (error) {
log("[prompt-async-gate] promptAsync failed", { sessionID, source, error: String(error) })
return { status: "failed", error }
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
dispatch: (dispatchInput) => promptAsync(dispatchInput),
})
}
export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
@@ -173,6 +245,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
source: string
settleMs?: number
postDispatchHoldMs?: number
dispatchTimeoutMs?: number
checkStatus?: boolean
}): Promise<PromptAsyncGateResult> {
const {
@@ -183,62 +256,26 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
} = args
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
const prompt = client.session?.prompt
if (typeof client.session?.prompt !== "function") {
if (typeof prompt !== "function") {
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
return { status: "unavailable" }
}
const existing = getActiveReservation(sessionID)
if (existing) {
log("[prompt-async-gate] prompt skipped because session is reserved", {
sessionID,
source,
reservedBy: existing.source,
reservedAgeMs: Date.now() - existing.reservedAt,
})
return { status: "reserved", reservedBy: existing.source }
}
const reservation: PromptAsyncReservation = {
return dispatchAfterSessionIdle({
sessionName: "prompt",
client,
sessionID,
input,
source,
reservedAt: Date.now(),
token: Symbol(source),
}
promptAsyncReservations.set(sessionID, reservation)
let holdReservationAfterDispatch = false
try {
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
if (settleMs > 0) {
await settleAfterSessionIdle(settleMs)
}
if (canReadStatus && await isSessionActive(client, sessionID)) {
log("[prompt-async-gate] prompt skipped because session is active", { sessionID, source })
return { status: "active" }
}
log("[prompt-async-gate] prompt dispatching", { sessionID, source })
const response = await client.session.prompt(input)
if (postDispatchHoldMs > 0) {
holdReservationAfterDispatch = true
}
log("[prompt-async-gate] prompt dispatched", { sessionID, source })
return { status: "dispatched", response }
} catch (error) {
log("[prompt-async-gate] prompt failed", { sessionID, source, error: String(error) })
return { status: "failed", error }
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
reservation.expiresAt = Date.now() + postDispatchHoldMs
} else {
promptAsyncReservations.delete(sessionID)
}
}
}
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
dispatch: (dispatchInput) => prompt(dispatchInput),
})
}
export function releaseAllPromptAsyncReservationsForTesting(): void {
+213 -17
View File
@@ -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))
}