From 590ce30bee138c41ad77e2840096a7fbfa583187 Mon Sep 17 00:00:00 2001 From: artale Date: Sun, 21 Jun 2026 01:25:53 +0200 Subject: [PATCH] feat: add ast safety scanner --- COMMANDS.md | 1 + src/core/ast-safety.test.ts | 37 +++++++++++++++++ src/core/ast-safety.ts | 81 +++++++++++++++++++++++++++++++++++++ src/index.ts | 10 +++++ 4 files changed, 129 insertions(+) create mode 100644 src/core/ast-safety.test.ts create mode 100644 src/core/ast-safety.ts diff --git a/COMMANDS.md b/COMMANDS.md index 20b5e64..55e1cd2 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -91,6 +91,7 @@ fable-agent plinius godmode "improve explanation quality" ### `security` +- `security ast `: summarize JS/TS structure and risky capabilities - `security scan `: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers - `--include-fixtures`: include intentional red-team fixtures/generators diff --git a/src/core/ast-safety.test.ts b/src/core/ast-safety.test.ts new file mode 100644 index 0000000..f1c6ceb --- /dev/null +++ b/src/core/ast-safety.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { scanAstSource } from "./ast-safety.js"; + +describe("ast safety", () => { + it("allows simple pure code", () => { + const report = scanAstSource("pure.ts", "export function add(a: number, b: number) { return a + b; }"); + + expect(report.exports).toEqual(["add"]); + expect(report.decision).toBe("allow"); + }); + + it("flags shell, network, fs write, and eval-like behavior", () => { + const report = scanAstSource("risky.ts", ` + import { execSync } from "node:child_process"; + import * as fs from "node:fs"; + fetch("https://example.com"); + fs.writeFileSync("x", "y"); + execSync("echo hi"); + eval("1+1"); + `); + + expect(report.imports).toContain("node:child_process"); + expect(report.shell_exec).toBe(true); + expect(report.network).toBe(true); + expect(report.fs_write).toBe(true); + expect(report.eval_like).toBe(true); + expect(report.decision).toBe("review"); + }); + + it("flags private-use glyph code", () => { + const glyph = String.fromCharCode(0xe000); + const report = scanAstSource("glyph.ts", `export const ${glyph} = () => ${glyph};`); + + expect(report.private_glyphs).toBe(true); + expect(report.decision).toBe("review"); + }); +}); diff --git a/src/core/ast-safety.ts b/src/core/ast-safety.ts new file mode 100644 index 0000000..d336c22 --- /dev/null +++ b/src/core/ast-safety.ts @@ -0,0 +1,81 @@ +import * as fs from "node:fs"; +import ts from "typescript"; +import { assessLegibilityRisk } from "./unicode-safety.js"; + +export interface AstSafetyReport { + file: string; + imports: string[]; + exports: string[]; + calls: string[]; + shell_exec: boolean; + network: boolean; + fs_write: boolean; + eval_like: boolean; + private_glyphs: boolean; + legibility_reason: string; + decision: "allow" | "review"; +} + +const SHELL = new Set(["exec", "execSync", "spawn", "spawnSync", "fork"]); +const FS_WRITE = /^(writeFile|writeFileSync|appendFile|appendFileSync|rm|rmSync|unlink|unlinkSync|mkdir|mkdirSync|rename|renameSync)$/; +const NETWORK = /^(fetch|request|get|post|put|patch|delete)$/i; +const EVAL = /^(eval|Function|setTimeout|setInterval)$/; + +export function scanAstFile(file: string): AstSafetyReport { + return scanAstSource(file, fs.readFileSync(file, "utf-8")); +} + +export function scanAstSource(file: string, source: string): AstSafetyReport { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const imports = new Set(); + const exports = new Set(); + const calls = new Set(); + const risk = assessLegibilityRisk(source); + + const visit = (node: ts.Node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) imports.add(node.moduleSpecifier.text); + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) exports.add(node.moduleSpecifier.text); + if (ts.isExportAssignment(node)) exports.add("default"); + if (ts.isFunctionDeclaration(node) && hasExport(node.modifiers)) exports.add(node.name?.text ?? "default"); + if (ts.isClassDeclaration(node) && hasExport(node.modifiers)) exports.add(node.name?.text ?? "default"); + if (ts.isVariableStatement(node) && hasExport(node.modifiers)) { + for (const d of node.declarationList.declarations) if (ts.isIdentifier(d.name)) exports.add(d.name.text); + } + if (ts.isCallExpression(node) || ts.isNewExpression(node)) calls.add(callName(node.expression)); + ts.forEachChild(node, visit); + }; + visit(sf); + + const callList = [...calls].filter(Boolean).sort(); + const importList = [...imports].sort(); + const shell_exec = importList.some((i) => i === "node:child_process" || i === "child_process") || callList.some((c) => SHELL.has(c.split(".").pop() ?? c)); + const fs_write = callList.some((c) => FS_WRITE.test(c.split(".").pop() ?? c)); + const network = callList.some((c) => NETWORK.test(c.split(".").pop() ?? c)) || importList.some((i) => /^https?$|^node:https?$/.test(i)); + const eval_like = callList.some((c) => EVAL.test(c.split(".").pop() ?? c)); + const decision: AstSafetyReport["decision"] = shell_exec || fs_write || network || eval_like || risk.risky ? "review" : "allow"; + + return { + file, + imports: importList, + exports: [...exports].sort(), + calls: callList, + shell_exec, + network, + fs_write, + eval_like, + private_glyphs: risk.privateUseCount > 0, + legibility_reason: risk.reason, + decision, + }; +} + +function hasExport(modifiers: ts.NodeArray | undefined): boolean { + return modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false; +} + +function callName(expr: ts.Expression): string { + if (ts.isIdentifier(expr)) return expr.text; + if (ts.isPropertyAccessExpression(expr)) return `${callName(expr.expression)}.${expr.name.text}`; + if (ts.isElementAccessExpression(expr)) return callName(expr.expression); + return expr.getText(); +} diff --git a/src/index.ts b/src/index.ts index 85ba804..737e229 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3066,6 +3066,16 @@ cyber if (receipt.decision !== "allow_read_only") process.exit(1); }); +security + .command("ast ") + .description("Summarize JS/TS structure and risky capabilities") + .action(async (file: string) => { + const { scanAstFile } = await import("./core/ast-safety.js"); + const report = scanAstFile(path.resolve(file)); + console.log(JSON.stringify(report, null, 2)); + if (report.decision !== "allow") process.exit(1); + }); + security .command("scan ") .description("Scan files for prompt-injection markers")