fix(prompt-gate): harden sync and team prompt dispatch

This commit is contained in:
YeonGyu-Kim
2026-05-19 17:43:37 +09:00
parent 1492bffd20
commit bcea4a9d28
44 changed files with 688 additions and 140 deletions
+118
View File
@@ -205,6 +205,74 @@ function detectRawPromptInSnippet(contents: string): boolean {
return detected
}
function objectLiteralHasQueueBehavior(node: ts.ObjectLiteralExpression, sourceFile: ts.SourceFile): boolean {
return node.properties.some((property) => {
if (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) {
return getPropertyName(property.name) === "queueBehavior"
}
if (ts.isSpreadAssignment(property)) {
return property.expression.getText(sourceFile).includes("queueBehavior")
}
return false
})
}
function callExpressionName(node: ts.Expression): string | null {
const callee = unwrapExpression(node)
if (ts.isIdentifier(callee)) {
return callee.text
}
if (ts.isPropertyAccessExpression(callee) || ts.isPropertyAccessChain(callee)) {
return getPropertyName(callee.name)
}
return null
}
function findPromptGateCallsWithoutQueueBehavior(filePath: string, contents: string): number[] {
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const offenders: number[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
if (callExpressionName(node.expression) === "dispatchInternalPrompt") {
const firstArgument = node.arguments[0]
if (
!firstArgument
|| !ts.isObjectLiteralExpression(firstArgument)
|| !objectLiteralHasQueueBehavior(firstArgument, sourceFile)
) {
offenders.push(sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1)
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return offenders
}
function findPromptRetryCallsWithoutQueueBehavior(filePath: string, contents: string): number[] {
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const offenders: number[] = []
const guardedNames = new Set(["promptWithModelSuggestionRetry", "promptSyncWithModelSuggestionRetry"])
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && guardedNames.has(callExpressionName(node.expression) ?? "")) {
const optionsArgument = node.arguments[2]
if (!optionsArgument || !ts.isObjectLiteralExpression(optionsArgument) || !objectLiteralHasQueueBehavior(optionsArgument, sourceFile)) {
offenders.push(sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return offenders
}
describe("production prompt injection routes", () => {
test("#given a destructuring promptAsync reference #when audit scans snippet #then it is flagged", () => {
// given
@@ -250,6 +318,20 @@ describe("production prompt injection routes", () => {
expect(detected).toBe(true)
})
test("#given indirect dispatchInternalPrompt options #when audit scans snippet #then it is flagged", () => {
// given
const snippet = `
const options = { mode: "async", queueBehavior: "defer" }
await dispatchInternalPrompt(options)
`
// when
const offenders = findPromptGateCallsWithoutQueueBehavior("audit-snippet.ts", snippet)
// then
expect(offenders).toEqual([3])
})
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)
@@ -304,4 +386,40 @@ describe("production prompt injection routes", () => {
// then
expect(offenders).toEqual([])
})
test("#given production TypeScript sources #when prompt gate callers are audited #then every route declares queue behavior explicitly", async () => {
// given
const files = await listSourceFiles(SOURCE_ROOT)
const offenders: string[] = []
// when
for (const filePath of files) {
const contents = await readFile(filePath, "utf8")
const missingLines = findPromptGateCallsWithoutQueueBehavior(filePath, contents)
for (const line of missingLines) {
offenders.push(`${relativeSourcePath(filePath)}:${line}`)
}
}
// then
expect(offenders).toEqual([])
})
test("#given production TypeScript sources #when model-suggestion prompt wrappers are audited #then every retry caller declares queue behavior explicitly", async () => {
// given
const files = await listSourceFiles(SOURCE_ROOT)
const offenders: string[] = []
// when
for (const filePath of files) {
const contents = await readFile(filePath, "utf8")
const missingLines = findPromptRetryCallsWithoutQueueBehavior(filePath, contents)
for (const line of missingLines) {
offenders.push(`${relativeSourcePath(filePath)}:${line}`)
}
}
// then
expect(offenders).toEqual([])
})
})