-
Notifications
You must be signed in to change notification settings - Fork 420
fix(rules): silence false positives in agent/mcp-tool-capability-risk and server-sequential-independent-await (#838, #839) #841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
9eca626
88a55b4
90a3469
afcf1df
2cdd5a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| --- | ||
| "oxlint-plugin-react-doctor": patch | ||
| --- | ||
|
|
||
| Fix false positives in three rules (#838, #837, #839). | ||
|
|
||
| - `agent-tool-capability-risk` / `mcp-tool-capability-risk` (#838): capability | ||
| keywords are now matched in code only. Words inside a tool's `description` | ||
| string (e.g. "ALWAYS fetch the underlying numbers first") and the AI-SDK | ||
| `execute:` handler key no longer satisfy the dangerous-capability gate. A | ||
| handler that actually calls `exec`/`fetch`/`readFile`/etc. still fires. | ||
|
|
||
| - `url-prefilled-privileged-action` (#837): the validating-helper lookbehind now | ||
| allows a member-access object between the helper call and the read, so | ||
| `sanitizeAuthCallbackURL(url.searchParams.get("callbackURL"))` and | ||
| `resolveSafeAuthCallbackURL(url.searchParams.get(...))` are recognized as | ||
| validated and suppressed. | ||
|
|
||
| - `server-sequential-independent-await` (#839): declared-name collection now | ||
| recurses into nested destructuring patterns, so | ||
| `const [{ slug }, { isEnabled }] = await Promise.all(...)` followed by an | ||
| `await` that reads `slug`/`isEnabled` is correctly treated as dependent and | ||
| not flagged as a waterfall. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { runScanRule } from "../../../test-utils/run-scan-rule.js"; | ||
| import { agentToolCapabilityRisk } from "./agent-tool-capability-risk.js"; | ||
|
|
||
| describe("security-scan/agent-tool-capability-risk — regressions", () => { | ||
| it("stays silent when capability words appear only in the description and execute handler key", () => { | ||
| const content = [ | ||
| 'import { tool } from "ai";', | ||
| "export const listItems = tool({", | ||
| ' description: "List items. ALWAYS fetch the underlying numbers first.",', | ||
| " inputSchema: z.object({ organizationId: z.string() }),", | ||
| " execute: async ({ organizationId }) => {", | ||
| ' if (organizationId !== allowedOrgId) return { error: "Access denied" };', | ||
| " return prisma.item.findMany({ where: { organizationId } });", | ||
| " },", | ||
| "});", | ||
| ].join("\n"); | ||
| const findings = runScanRule(agentToolCapabilityRisk, { | ||
| relativePath: "src/lib/tools/campaign-stats.ts", | ||
| content, | ||
| }); | ||
| expect(findings).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("flags a tool handler that actually shells out", () => { | ||
| const content = [ | ||
| 'import { tool } from "ai";', | ||
| "export const runShell = tool({", | ||
| ' description: "Run a command",', | ||
| " inputSchema: z.object({ cmd: z.string() }),", | ||
| " execute: async ({ cmd }) => {", | ||
| " return exec(cmd);", | ||
| " },", | ||
| "});", | ||
| ].join("\n"); | ||
| const findings = runScanRule(agentToolCapabilityRisk, { | ||
| relativePath: "src/lib/tools/run-shell.ts", | ||
| content, | ||
| }); | ||
| expect(findings).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("flags a tool handler that actually calls fetch in code", () => { | ||
| const content = [ | ||
| 'import { tool } from "ai";', | ||
| "export const getData = tool({", | ||
| ' description: "Get data",', | ||
| " execute: async ({ url }) => fetch(url),", | ||
| "});", | ||
| ].join("\n"); | ||
| const findings = runScanRule(agentToolCapabilityRisk, { | ||
| relativePath: "src/lib/tools/get-data.ts", | ||
| content, | ||
| }); | ||
| expect(findings).toHaveLength(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { stripStringLiteralsPreservingPositions } from "./strip-string-literals-preserving-positions.js"; | ||
|
|
||
| describe("security-scan/utils/strip-string-literals-preserving-positions", () => { | ||
| it("blanks string contents while preserving offsets, delimiters, and newlines", () => { | ||
| const source = `const description = "ALWAYS fetch the numbers first";\nconst run = exec(cmd);`; | ||
| const stripped = stripStringLiteralsPreservingPositions(source); | ||
| expect(stripped).toHaveLength(source.length); | ||
| expect(stripped).not.toContain("fetch"); | ||
| expect(stripped.split("\n")[0]).toMatch(/^const description = " +";$/); | ||
| expect(stripped.split("\n")[1]).toBe("const run = exec(cmd);"); | ||
| }); | ||
|
|
||
| it("blanks template-literal text but keeps newlines for multi-line strings", () => { | ||
| const source = "const help = `line one\nfetch line two`;\nconst keep = true;"; | ||
| const stripped = stripStringLiteralsPreservingPositions(source); | ||
| expect(stripped).not.toContain("fetch"); | ||
| expect(stripped.split("\n")).toHaveLength(3); | ||
| expect(stripped.split("\n")[2]).toBe("const keep = true;"); | ||
| }); | ||
|
|
||
| it("does not let an escaped quote close the string early", () => { | ||
| const source = `const note = "say \\"exec\\" out loud"; const real = spawn(cmd);`; | ||
| const stripped = stripStringLiteralsPreservingPositions(source); | ||
| expect(stripped).not.toContain("exec"); | ||
| expect(stripped).toContain("spawn(cmd)"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // Pattern scans repeatedly match capability keywords that live only inside | ||
| // string literals — a tool's human-readable `description`, prose like | ||
| // "ALWAYS fetch the underlying numbers first". This blanks string-literal | ||
| // contents with spaces so a keyword gate can require a real code occurrence; | ||
| // the delimiters, newlines, and every offset are preserved so match indices, | ||
| // lines, and columns still map 1:1 onto the original file. Expects | ||
| // comment-stripped input so a quote inside a comment is never treated as a | ||
| // string delimiter. | ||
| export const stripStringLiteralsPreservingPositions = (content: string): string => { | ||
| const characters = content.split(""); | ||
| let stringDelimiter: string | null = null; | ||
| let index = 0; | ||
|
|
||
| while (index < content.length) { | ||
| const character = content[index]; | ||
|
|
||
| if (stringDelimiter !== null) { | ||
| if (character === "\\") { | ||
| characters[index] = " "; | ||
| if (content[index + 1] !== undefined && content[index + 1] !== "\n") { | ||
| characters[index + 1] = " "; | ||
| } | ||
| index += 2; | ||
| continue; | ||
| } | ||
| if (character === stringDelimiter) { | ||
| stringDelimiter = null; | ||
| index += 1; | ||
| continue; | ||
| } | ||
| if (character !== "\n") characters[index] = " "; | ||
| index += 1; | ||
| continue; | ||
| } | ||
|
|
||
| if (character === '"' || character === "'" || character === "`") { | ||
| stringDelimiter = character; | ||
| index += 1; | ||
| continue; | ||
| } | ||
|
|
||
| index += 1; | ||
| } | ||
|
|
||
| return characters.join(""); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { runRule } from "../../../test-utils/run-rule.js"; | ||
| import { serverSequentialIndependentAwait } from "./server-sequential-independent-await.js"; | ||
|
|
||
| describe("server-sequential-independent-await", () => { | ||
| it("flags two genuinely independent sequential awaits", () => { | ||
| const result = runRule( | ||
| serverSequentialIndependentAwait, | ||
| `export const load = async () => { | ||
| const user = await fetchUser(); | ||
| const posts = await fetchPosts(); | ||
| return { user, posts }; | ||
| };`, | ||
| { filename: "/repo/app/page.tsx" }, | ||
| ); | ||
|
|
||
| expect(result.diagnostics).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("stays silent when the second await reads names destructured from the first (array of object patterns)", () => { | ||
| const result = runRule( | ||
| serverSequentialIndependentAwait, | ||
| `export default async function Page({ params }: PageProps<"/preview/blog/[slug]">) { | ||
| const [{ slug }, { isEnabled }] = await Promise.all([params, draftMode()]); | ||
| const data = await client.fetch( | ||
| BlogPostQuery, | ||
| { slug }, | ||
| isEnabled ? { perspective: "drafts", stega: true } : { next: { revalidate: 3600 } }, | ||
| ); | ||
| if (!data) notFound(); | ||
| return <BlogPostPageUI blogPost={data} />; | ||
| }`, | ||
| { filename: "/repo/app/(site)/preview/blog/[slug]/page.tsx" }, | ||
| ); | ||
|
|
||
| expect(result.diagnostics).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("stays silent when the second await reads a deeply nested destructured binding", () => { | ||
| const result = runRule( | ||
| serverSequentialIndependentAwait, | ||
| `export const load = async () => { | ||
| const { data: { token } } = await getSession(); | ||
| const profile = await fetchProfile(token); | ||
| return profile; | ||
| };`, | ||
| { filename: "/repo/app/page.tsx" }, | ||
| ); | ||
|
|
||
| expect(result.diagnostics).toHaveLength(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { collectPatternNames } from "../../utils/collect-pattern-names.js"; | ||
| import { defineRule } from "../../utils/define-rule.js"; | ||
| import { walkAst } from "../../utils/walk-ast.js"; | ||
| import type { EsTreeNode } from "../../utils/es-tree-node.js"; | ||
|
|
@@ -19,24 +20,7 @@ const collectDeclaredNames = (declaration: EsTreeNode): Set<string> => { | |
| const names = new Set<string>(); | ||
| if (!isNodeOfType(declaration, "VariableDeclaration")) return names; | ||
| for (const declarator of declaration.declarations ?? []) { | ||
| if (isNodeOfType(declarator.id, "Identifier")) { | ||
| names.add(declarator.id.name); | ||
| } else if (isNodeOfType(declarator.id, "ObjectPattern")) { | ||
| for (const property of declarator.id.properties ?? []) { | ||
| if (isNodeOfType(property, "Property") && isNodeOfType(property.value, "Identifier")) { | ||
| names.add(property.value.name); | ||
| } else if ( | ||
| isNodeOfType(property, "RestElement") && | ||
| isNodeOfType(property.argument, "Identifier") | ||
| ) { | ||
| names.add(property.argument.name); | ||
| } | ||
| } | ||
| } else if (isNodeOfType(declarator.id, "ArrayPattern")) { | ||
| for (const element of declarator.id.elements ?? []) { | ||
| if (isNodeOfType(element, "Identifier")) names.add(element.name); | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Destructuring keys mask dependencyLow Severity After nested destructuring names are collected correctly, Reviewed by Cursor Bugbot for commit 2cdd5a1. Configure here. |
||
| collectPatternNames(declarator.id, names); | ||
| } | ||
| return names; | ||
| }; | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.