Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
372 changes: 372 additions & 0 deletions docs/research/prompt-injection-detector.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions node/src/commands/hook/posttool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Command } from "commander";
import { RegexScanner } from "../../scanners/regex-scanner.js";
import { AuditLogger } from "../../core/audit-logger.js";
import { PromptInjectionDetector } from "../../scanners/prompt-injection.js";
import type { InjectionSeverity } from "../../scanners/prompt-injection-patterns.js";

type HookFormat = "claude" | "cursor" | "gemini" | "windsurf";

Expand All @@ -24,6 +26,8 @@ export function createHookPosttoolCommand(): Command {
return new Command("posttool")
.description("PostToolUse hook handler (reads stdin, redacts secrets in output, writes JSON to stdout)")
.option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
.option("--experimental-prompt-injection", "(experimental) flag tool responses with prompt-injection markers (does NOT modify response)")
.option("--prompt-injection-min-severity <level>", "minimum severity to flag", "high")
.action(async (opts) => {
const format = (opts.format || "claude") as HookFormat;
try {
Expand All @@ -45,6 +49,15 @@ export function createHookPosttoolCommand(): Command {

const payload = normalizePostInput(raw, format);
const output = evaluateToolResponse(payload);

// Experimental: scan response for prompt-injection markers. We do
// NOT modify the response — just emit a stderr warning. See bead
// rf-bmo / docs/research/prompt-injection-detector.md.
if (opts.experimentalPromptInjection) {
const minSeverity = (opts.promptInjectionMinSeverity || "high") as InjectionSeverity;
checkPromptInjection(payload, minSeverity);
}

writeOutput(output, format);
} catch {
// Any unexpected error → fail open
Expand All @@ -53,6 +66,25 @@ export function createHookPosttoolCommand(): Command {
});
}

function checkPromptInjection(payload: PostToolInput, minSeverity: InjectionSeverity): void {
const tr = payload.tool_response;
if (!tr) return;
const detector = new PromptInjectionDetector();
const candidates: Array<[string, string]> = [];
if (typeof tr.output === "string" && tr.output) candidates.push(["output", tr.output]);
if (typeof tr.content === "string" && tr.content) candidates.push(["content", tr.content]);

for (const [field, text] of candidates) {
const result = detector.scan(text, { minSeverity });
if (result.findings.length === 0) continue;
const top = result.findings.slice(0, 3).map(f => `${f.severity}:${f.pattern}`).join(", ");
process.stderr.write(
`Rafter (experimental): possible prompt injection in ${payload.tool_name}.${field} ` +
`[verdict=${result.verdict}, score=${result.score}] — ${top}\n`
);
}
}

/**
* Normalize platform-specific PostToolUse stdin into common shape.
* Windsurf sends { tool_info: { stdout, stderr } }, Cursor sends { output, ... }.
Expand Down
3 changes: 3 additions & 0 deletions node/src/commands/scan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { Command } from "commander";
import { runRemoteScan } from "../backend/run.js";
import { createScanCommand as createLocalScanCommand } from "../agent/scan.js";
import { createScanInjectionCommand } from "./injection.js";

export function createScanGroupCommand(): Command {
// "local" subcommand — back-compat alias for `rafter secrets`. Hidden from help.
Expand Down Expand Up @@ -46,6 +47,8 @@ export function createScanGroupCommand(): Command {

scanGroup.addCommand(localCmd, { hidden: true });
scanGroup.addCommand(remoteCmd);
// EXPERIMENTAL: hidden from help while feature stabilizes (bead rf-bmo).
scanGroup.addCommand(createScanInjectionCommand(), { hidden: true });

// When invoked with no subcommand, run remote scan
scanGroup.action(async (opts) => {
Expand Down
73 changes: 73 additions & 0 deletions node/src/commands/scan/injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* rafter scan injection — EXPERIMENTAL prompt-injection scan.
*
* Reads a file (or stdin with `-`) and reports possible prompt-injection
* findings. Hidden from help until the parallel APPROVE bead (rf-i17)
* closes. See docs/research/prompt-injection-detector.md.
*/

import { Command } from "commander";
import fs from "fs";
import {
PromptInjectionDetector,
InjectionScanResult,
} from "../../scanners/prompt-injection.js";
import type { InjectionSeverity } from "../../scanners/prompt-injection-patterns.js";

const SEVERITY_RANK: Record<InjectionSeverity, number> = {
low: 0,
medium: 1,
high: 2,
critical: 3,
};

export function createScanInjectionCommand(): Command {
return new Command("injection")
.description("(experimental) Scan a file or stdin for prompt-injection patterns")
.argument("<path>", "file path, or - for stdin")
.option("--min-severity <level>", "low | medium | high | critical", "low")
.option("--fail-on <level>", "exit 1 when finding ≥ this severity", "medium")
.option("--json", "output JSON (default human-readable)")
.option("--quiet", "suppress non-essential output")
.action((path: string, opts) => {
const minSeverity = (opts.minSeverity || "low") as InjectionSeverity;
const failOn = (opts.failOn || "medium") as InjectionSeverity;
let text: string;
try {
text = path === "-"
? fs.readFileSync(0, "utf-8")
: fs.readFileSync(path, "utf-8");
} catch (err: any) {
if (!opts.quiet) {
process.stderr.write(`error reading ${path}: ${err.message}\n`);
}
process.exit(2);
}

const det = new PromptInjectionDetector();
const result = det.scan(text, { minSeverity });

if (opts.json) {
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else {
printHuman(result, path);
}

const failRank = SEVERITY_RANK[failOn];
const triggered = result.findings.some(f => SEVERITY_RANK[f.severity] >= failRank);
process.exit(triggered ? 1 : 0);
});
}

function printHuman(r: InjectionScanResult, path: string): void {
if (r.findings.length === 0) {
process.stdout.write(`✓ ${path}: clean (score 0)\n`);
return;
}
process.stdout.write(`! ${path}: ${r.verdict} (score ${r.score})\n`);
for (const f of r.findings) {
process.stdout.write(
` [${f.severity.padEnd(8)}] ${f.pattern} @${f.offset}: ${f.evidence}\n`
);
}
}
155 changes: 155 additions & 0 deletions node/src/scanners/prompt-injection-patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Prompt-injection detection patterns.
*
* EXPERIMENTAL — see docs/research/prompt-injection-detector.md.
* Pattern-based, English-only, trivially bypassable by paraphrase. We accept
* this in exchange for zero-dependency, deterministic behavior. Pair with a
* model-based judge for production deployments.
*/

export type InjectionCategory =
| "role_override"
| "tool_exfil"
| "hidden_unicode"
| "html_comment"
| "encoded_payload";

export type InjectionSeverity = "low" | "medium" | "high" | "critical";

export interface InjectionPattern {
name: string;
category: InjectionCategory;
severity: InjectionSeverity;
regex: RegExp;
description: string;
}

export const ROLE_OVERRIDE_PATTERNS: InjectionPattern[] = [
{
name: "ignore_previous_instructions",
category: "role_override",
severity: "high",
regex: /\bignore(?:\s+(?:all|any|the))?\s+(?:previous|prior|above|preceding)\s+(?:instructions?|rules?|directives?|prompts?|messages?)\b/i,
description: "Classic 'ignore previous instructions' jailbreak phrasing.",
},
{
name: "disregard_above",
category: "role_override",
severity: "high",
regex: /\bdisregard\s+(?:the\s+)?(?:above|prior|previous|preceding)\b/i,
description: "Variant of role-override using 'disregard'.",
},
{
name: "forget_everything",
category: "role_override",
severity: "high",
regex: /\bforget\s+(?:everything|all)\s+(?:you(?:'ve|\s+have)\s+been\s+told|prior|previous)\b/i,
description: "Memory-wipe role override.",
},
{
name: "system_prompt_mimicry",
category: "role_override",
severity: "high",
// Match a line that *opens* with system: / [SYSTEM] / <system> as if it
// were a real system delimiter. Anchored to start-of-line.
regex: /(?:^|\n)\s*(?:system\s*:\s*$|\[SYSTEM\]|<\s*system\s*>)/im,
description: "Text impersonates a system-prompt delimiter.",
},
{
name: "new_instructions_block",
category: "role_override",
severity: "medium",
regex: /(?:^|\n)\s*(?:new|updated|revised)\s+instructions?\s*:\s*\n/i,
description: "Tries to declare a new instruction block.",
},
{
name: "developer_or_dan_mode",
category: "role_override",
severity: "high",
regex: /\b(?:developer\s+mode|DAN\s+mode|jailbroken|do\s+anything\s+now|unfiltered\s+mode)\b/i,
description: "Known persona-jailbreak names.",
},
{
name: "you_are_now_persona",
category: "role_override",
severity: "medium",
// "you are now X" where X looks like a persona/role-shift, not benign.
regex: /\byou\s+are\s+now\s+(?:[A-Z]\w+|an?\s+(?:unrestricted|unfiltered|jailbroken|evil|malicious|admin|root))\b/i,
description: "Persona swap attempt.",
},
];

export const TOOL_EXFIL_PATTERNS: InjectionPattern[] = [
{
name: "execute_following_command",
category: "tool_exfil",
severity: "high",
regex: /\b(?:execute|run|invoke|call)\s+(?:the\s+|this\s+)?(?:following|below|next)\s+(?:command|code|script|tool|function)\b/i,
description: "Instructs the agent to execute attacker-supplied content.",
},
{
name: "use_shell_to",
category: "tool_exfil",
severity: "medium",
regex: /\buse\s+(?:the\s+)?(?:bash|shell|terminal|command\s+line)\s+to\b/i,
description: "Tells the agent to use a shell.",
},
{
name: "curl_pipe_shell",
category: "tool_exfil",
severity: "critical",
regex: /curl\s+[^\n]*\|\s*(?:sh|bash|zsh)\b/i,
description: "curl|sh remote-execution pattern.",
},
{
name: "exfil_credentials",
category: "tool_exfil",
severity: "critical",
regex: /\b(?:send|exfiltrate|post|upload|transmit|leak|share)\b[^\n]{0,80}\b(?:api[\s_-]?keys?|tokens?|credentials?|secrets?|passwords?|\.env|ssh\s+keys?)\b/i,
description: "Asks the agent to exfiltrate secrets.",
},
{
name: "delete_all_files",
category: "tool_exfil",
severity: "critical",
regex: /\b(?:delete|remove|wipe|erase|destroy)\s+(?:all|every|the)\s+(?:files?|data|directories|folders|repos?)\b/i,
description: "Asks the agent to perform destructive action.",
},
];

export const HTML_COMMENT_PATTERNS: InjectionPattern[] = [
{
name: "html_comment_imperative",
category: "html_comment",
severity: "medium",
// HTML comment containing an imperative verb suggestive of injection.
regex: /<!--[^>]*?\b(?:ignore|disregard|forget|execute|run|delete|exfiltrate|send|reveal)\b[^>]*?-->/i,
description: "HTML comment contains imperative instruction.",
},
{
name: "markdown_html_hidden_directive",
category: "html_comment",
severity: "medium",
// [//]: # (...) markdown-comment style with imperative
regex: /\[\/\/\]:\s*#\s*\([^)]*\b(?:ignore|disregard|execute|delete|exfiltrate|reveal)\b[^)]*\)/i,
description: "Markdown-style hidden directive.",
},
];

/**
* Hidden-unicode detection runs as code, not regex (cleaner for
* codepoint-class checks). We export ranges here for parity with Python.
*/
export const HIDDEN_UNICODE_RANGES: Array<{ name: string; test: (cp: number) => boolean; severity: InjectionSeverity }> = [
{ name: "tag_characters", severity: "critical", test: (cp) => cp >= 0xe0000 && cp <= 0xe007f },
{ name: "bidi_override", severity: "critical", test: (cp) => cp === 0x202e || cp === 0x202d || cp === 0x2066 || cp === 0x2067 },
// Zero-width chars are flagged ONLY when embedded in a word (not at edges
// of an emoji ZWJ sequence). Detector handles that contextually.
{ name: "zero_width_in_word", severity: "high", test: (cp) => cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0xfeff },
];

export const ALL_TEXT_PATTERNS: InjectionPattern[] = [
...ROLE_OVERRIDE_PATTERNS,
...TOOL_EXFIL_PATTERNS,
...HTML_COMMENT_PATTERNS,
];
Loading