-
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 2 commits
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,76 @@ | ||
| 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); | ||
| }); | ||
|
|
||
| it("flags a tool that names a dangerous module in its import specifier", () => { | ||
| const content = [ | ||
| 'import { tool } from "ai";', | ||
| 'import { execFile } from "node:child_process";', | ||
| "export const runCommandTool = tool({", | ||
| ' description: "Run a repository maintenance command",', | ||
| " execute: async ({ command }) => {", | ||
| " execFile(command, []);", | ||
| " return { ok: true };", | ||
| " },", | ||
| "});", | ||
| ].join("\n"); | ||
| const findings = runScanRule(agentToolCapabilityRisk, { | ||
| relativePath: "src/agents/tools/run-command-tool.ts", | ||
| content, | ||
| }); | ||
| expect(findings).toHaveLength(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { stripStringLiteralsKeepingModuleSpecifiers } from "./strip-string-literals-keeping-module-specifiers.js"; | ||
|
|
||
| describe("security-scan/utils/strip-string-literals-keeping-module-specifiers", () => { | ||
| 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 = stripStringLiteralsKeepingModuleSpecifiers(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 = stripStringLiteralsKeepingModuleSpecifiers(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 = stripStringLiteralsKeepingModuleSpecifiers(source); | ||
| expect(stripped).not.toContain("exec"); | ||
| expect(stripped).toContain("spawn(cmd)"); | ||
| }); | ||
|
|
||
| it("keeps module-specifier strings: import, export-from, and require paths", () => { | ||
| const source = [ | ||
| `import { execFile } from "node:child_process";`, | ||
| `export { readFile } from "node:fs/promises";`, | ||
| `const vm = require("node:vm");`, | ||
| `const dynamic = import("node:child_process");`, | ||
| ].join("\n"); | ||
| const stripped = stripStringLiteralsKeepingModuleSpecifiers(source); | ||
| expect(stripped).toContain("node:child_process"); | ||
| expect(stripped).toContain("node:fs/promises"); | ||
| expect(stripped).toContain("node:vm"); | ||
| expect(stripped).toBe(source); | ||
| }); | ||
|
|
||
| it("blanks member-access strings that merely look like an import", () => { | ||
| const source = `const copy = Buffer.from("fetch the bytes");`; | ||
| const stripped = stripStringLiteralsKeepingModuleSpecifiers(source); | ||
| expect(stripped).not.toContain("fetch"); | ||
| expect(stripped).toMatch(/^const copy = Buffer\.from\(" +"\);$/); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| // Builds the "is this keyword in real code?" view used by capability/keyword | ||
| // gates: string-literal *contents* are blanked with spaces so a keyword that | ||
| // appears only in prose — a tool's human-readable `description`, "ALWAYS fetch | ||
| // the numbers first" — can't satisfy a gate. Module-specifier strings | ||
| // (`from "node:child_process"`, `require("node:fs")`) are kept, because an | ||
| // import path is code, not prose, and naming a dangerous module is a real | ||
| // capability signal. Delimiters, newlines, and every offset are preserved so a | ||
| // blanked region still maps 1:1 onto the original file. Expects comment-stripped | ||
| // input so a quote inside a comment is never treated as a string delimiter. | ||
| const MODULE_SPECIFIER_KEYWORDS = new Set(["from", "import", "require"]); | ||
|
|
||
| const isModuleSpecifierQuote = (content: string, quoteIndex: number): boolean => { | ||
| let cursor = quoteIndex - 1; | ||
| while (cursor >= 0 && /\s/.test(content[cursor])) cursor -= 1; | ||
| if (content[cursor] === "(") { | ||
| cursor -= 1; | ||
| while (cursor >= 0 && /\s/.test(content[cursor])) cursor -= 1; | ||
| } | ||
| const wordEnd = cursor; | ||
| while (cursor >= 0 && /[\w$]/.test(content[cursor])) cursor -= 1; | ||
| const precedingWord = content.slice(cursor + 1, wordEnd + 1); | ||
| if (!MODULE_SPECIFIER_KEYWORDS.has(precedingWord)) return false; | ||
| // A member access (`Buffer.from("…")`, `db.import("…")`) is not an import. | ||
| return content[cursor] !== "."; | ||
| }; | ||
|
|
||
| export const stripStringLiteralsKeepingModuleSpecifiers = (content: string): string => { | ||
| const characters = content.split(""); | ||
| let stringDelimiter: string | null = null; | ||
| let isModuleSpecifier = false; | ||
| let index = 0; | ||
|
|
||
| while (index < content.length) { | ||
| const character = content[index]; | ||
|
|
||
| if (stringDelimiter !== null) { | ||
| if (character === "\\") { | ||
| if (!isModuleSpecifier) { | ||
| 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 (!isModuleSpecifier && character !== "\n") characters[index] = " "; | ||
| index += 1; | ||
| continue; | ||
|
Contributor
Author
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. 🟡 Template literal expressions ( The execute: async ({ cmd }) => `Result: ${exec(cmd)}`would have Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
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. Fixed in 90a3469. The stripper is now interpolation-aware: inside a backtick literal it blanks only the literal text and treats |
||
| } | ||
|
|
||
| if (character === '"' || character === "'" || character === "`") { | ||
| stringDelimiter = character; | ||
| isModuleSpecifier = isModuleSpecifierQuote(content, index); | ||
| index += 1; | ||
| continue; | ||
| } | ||
|
|
||
| index += 1; | ||
| } | ||
|
|
||
| return characters.join(""); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.