repotrap 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prince Jain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="assets/logo.png" width="128" height="128" alt="repotrap logo" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">repotrap</h1>
6
+
7
+ <p align="center">
8
+ Zero-dependency prompt-injection, invisible Unicode steganography, spoofed <code>&lt;system-reminder&gt;</code> delimiter, and repo-open execution trap scanner for Claude Code, Codex CLI, skills, and GitHub issues.
9
+ </p>
10
+
11
+ <p align="center">
12
+ <img src="assets/demo.png" width="840" alt="repotrap terminal output across 525 skills, 16 repos, and 153 sessions" />
13
+ </p>
14
+
15
+ ## Why I built this
16
+
17
+ In today's `r/ClaudeAI` thread (`1xozfcv`, *"Why are more than 98% of 16,060 open GitHub issues on anthropics/claude-code unassigned?"*) and `r/ExperiencedDevs` thread (`1xnl43h`, *"The Agentic Code Review Dilemma"*), maintainers pointed out the core blocker to letting coding agents triage issues, review PRs, or inspect cloned repos: **untrusted input poisoning and repo-open execution traps**.
18
+
19
+ When you point Claude Code or Codex CLI at an external GitHub issue (`gh issue view`), a cloned PR branch, or a third-party `SKILL.md` file, four classes of traps can hijack the agent:
20
+ 1. **Workspace config auto-execution**: `.claude/settings.json` (`hooks`, `apiKeyHelper`, `statusLine`), `.mcp.json`, or `.vscode/tasks.json` (`"runOn": "folderOpen"`) inside a cloned repo that execute arbitrary commands when the agent starts.
21
+ 2. **Invisible Unicode steganography**: Unicode Tag characters (`U+E0000` to `U+E007F`), bidirectional overrides (`U+202A` to `U+202E`), and standalone zero-width spaces (`U+200B`) hiding instructions that humans cannot see in the browser.
22
+ 3. **Spoofed `<system-reminder>` and `</tool_result>` delimiters**: Untrusted issue bodies or web pages attempting to close the tool output block and inject fake system reminders.
23
+ 4. **Hidden HTML comment instructions & remote shell pipes**: `<!-- ignore previous instructions -->` inside Markdown issues or `curl ... | bash` inside installed `SKILL.md` files.
24
+
25
+ ## Quickstart
26
+
27
+ Audit your installed skills, workspace repos, and historical `.jsonl` transcripts:
28
+
29
+ ```bash
30
+ npx repotrap
31
+ ```
32
+
33
+ Pre-scan an untrusted GitHub Issue or PR before handing it to a coding agent:
34
+
35
+ ```bash
36
+ npx repotrap --gh-issue owner/repo#123
37
+ ```
38
+
39
+ Generate a deterministic Claude Code `PostToolUse` sanitizer hook:
40
+
41
+ ```bash
42
+ npx repotrap --emit-hook
43
+ ```
44
+
45
+ ## Real audit on 525 skill files, 16 repos, and 153 transcripts
46
+
47
+ Running `npx repotrap` across `525` local skill markdown files (`~/.gemini/config/skills` and `~/.agents/skills`), `16` workspace repos, and `153` local Claude Code and Codex CLI transcripts surfaced three false-positive traps that naive scanners hit, plus real findings:
48
+
49
+ 1. **Claude Code native `type: "attachment"` `<system-reminder>` inflation (`1,526` native rows vs `0` spoofed `tool_result` delimiters)**: Scanning raw `.jsonl` lines for `<system-reminder>` matched `1,526` rows across `125` sessions because Claude Code serializes its own internal reminders as `type: "attachment"` and `type: "api-request-*"` records. `repotrap` parses the JSONL structure (`RTR012`) and only flags `<system-reminder>` or `</tool_result>` when it appears inside an untrusted `tool_result` or `function_call_output` payload (`RTR008`).
50
+ 2. **Codex `custom_tool_call_output` object serialization (`142` zero-width chars across `15` tool outputs in `5` sessions)**: Unlike `function_call_output` (where `payload.output` is a string), Codex CLI's `custom_tool_call_output` stores `payload.output` as a nested JSON object. Naive `String(payload.output)` coercion turns the entire payload into `"[object Object]"` and misses `138` of the `142` ingested `U+200B`/`U+200E` characters (`RTR011`).
51
+ 3. **Composite Emoji `U+200D` ZWJ vs standalone `U+200B` (`RTR014`) and local build scripts (`RTR013`)**: `repotrap` separates composite emoji zero-width joiners (`1` in `notebook-guidance/SKILL.md`) and benign local `package.json` build scripts (`7` across `16` repos) from `1` live `curl -fsSL https://composio.dev/install | bash` remote shell pipe in `composio-cli/SKILL.md` (`RTR005`) and `2` standalone `U+200B` characters in `signs-of-ai-writing.md` (`RTR007`).
52
+
53
+ ## Rules (`RTR001` to `RTR014`)
54
+
55
+ | Rule | Plane | Severity | What it detects |
56
+ | :--- | :--- | :--- | :--- |
57
+ | `RTR001` | Repo | `CRITICAL` | Workspace `.claude/settings.json` defining `hooks`, `apiKeyHelper`, `otelHeadersHelper`, or `statusLine` |
58
+ | `RTR002` | Repo | `CRITICAL` | Workspace `.mcp.json` or `.vscode/tasks.json` (`"runOn": "folderOpen"`) spawning commands on open |
59
+ | `RTR003` | Repo | `CRITICAL` | Unicode Tag characters (`U+E0000`–`U+E007F`) or Bidirectional Overrides (`U+202A`–`U+202E`) |
60
+ | `RTR004` | Repo | `HIGH` | HTML comments (`<!-- ... -->`) in Markdown/skills containing imperative prompt-injection verbs |
61
+ | `RTR005` | Repo | `HIGH` | Skill or instruction file piping a remote URL directly into `sh` / `bash` (`curl ... \| bash`) |
62
+ | `RTR006` | Repo | `MEDIUM` | `package.json` `preinstall`/`postinstall` hook running network or shell payloads |
63
+ | `RTR007` | Repo | `LOW` | Standalone zero-width space (`U+200B` / `U+FEFF`) inside skill or documentation files |
64
+ | `RTR008` | Transcript | `CRITICAL` | Spoofed `<system-reminder>`, `</tool_result>`, or `<function_calls>` inside untrusted tool output |
65
+ | `RTR009` | Transcript | `CRITICAL` | Explicit prompt-override phrase (`ignore previous instructions`) inside untrusted tool output |
66
+ | `RTR010` | Transcript | `HIGH` | Hidden HTML comment inside `tool_result` containing imperative agent/shell instructions |
67
+ | `RTR011` | Transcript | `MEDIUM` | Standalone zero-width or directionality characters (`U+200B`, `U+200E`) ingested via tool output |
68
+ | `RTR012` | Transcript | `INFO` | Claude Code native `type: "attachment"` `<system-reminder>` rows filtered out (False-Positive Guard) |
69
+ | `RTR013` | Repo | `INFO` | Benign local `package.json` build/prepare scripts separated (False-Positive Guard) |
70
+ | `RTR014` | Repo | `INFO` | Composite Emoji `U+200D` ZWJ sequences separated from Unicode steganography (False-Positive Guard) |
71
+
72
+ ## Limitations
73
+
74
+ - **Syntactic & structural detection**: `repotrap` deterministically catches invisible Unicode (`U+E0000` tags, Bidi overrides, standalone `U+200B`), spoofed XML/harness delimiters (`<system-reminder>`, `</tool_result>`), HTML comment instructions, `curl | sh` skill pipes, and `.claude`/`.mcp.json`/`.vscode` repo-open hooks. It cannot prove whether natural-language prose without structural markers or imperative override phrases will sway a model's reasoning.
75
+ - **`--gh-issue` requires GitHub CLI (`gh`)**: Pre-scanning live GitHub issues (`npx repotrap --gh-issue owner/repo#123`) invokes your authenticated `gh issue view` CLI.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import {
5
+ RULE_META,
6
+ scanTextForPromptTraps,
7
+ auditRepoTraps,
8
+ generateGuardHook,
9
+ getDemoResult
10
+ } from "../src/analyzer.mjs";
11
+
12
+ const RESET = "\x1b[0m";
13
+ const BOLD = "\x1b[1m";
14
+ const DIM = "\x1b[2m";
15
+ const RED = "\x1b[31m";
16
+ const YELLOW = "\x1b[33m";
17
+ const GREEN = "\x1b[32m";
18
+ const CYAN = "\x1b[36m";
19
+ const MAGENTA = "\x1b[35m";
20
+
21
+ function printHelp() {
22
+ console.log(`
23
+ ${BOLD}repotrap${RESET} v0.1.0 - Prompt-Injection, Unicode Steganography & Repo Trap Scanner
24
+
25
+ Usage:
26
+ npx repotrap [options]
27
+
28
+ Options:
29
+ --workspace <path> Workspace directory to scan for repo-open traps (default: cwd)
30
+ --file <path> Scan a single Markdown, SKILL.md, or issue file
31
+ --gh-issue <repo#num> Fetch and scan a GitHub issue/PR via 'gh issue view' before agent triage
32
+ --emit-hook Output deterministic PostToolUse guardrail hook + settings.json
33
+ --json Output structured JSON report
34
+ --fail-on <severity> Exit 1 if findings >= severity (CRITICAL, HIGH, MEDIUM, LOW)
35
+ --demo Render benchmarked 534-skill + 153-session corpus audit output
36
+ -h, --help Show help
37
+ -v, --version Show version
38
+ `);
39
+ }
40
+
41
+ function parseArgs(argv) {
42
+ const opts = {
43
+ workspaceDir: null,
44
+ file: null,
45
+ ghIssue: null,
46
+ emitHook: false,
47
+ json: false,
48
+ failOn: null,
49
+ demo: false,
50
+ help: false,
51
+ version: false
52
+ };
53
+
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ if (a === "-h" || a === "--help") opts.help = true;
57
+ else if (a === "-v" || a === "--version") opts.version = true;
58
+ else if (a === "--demo") opts.demo = true;
59
+ else if (a === "--json") opts.json = true;
60
+ else if (a === "--emit-hook") opts.emitHook = true;
61
+ else if (a === "--workspace") opts.workspaceDir = argv[++i];
62
+ else if (a === "--file") opts.file = argv[++i];
63
+ else if (a === "--gh-issue") opts.ghIssue = argv[++i];
64
+ else if (a === "--fail-on") opts.failOn = String(argv[++i] || "").toUpperCase();
65
+ }
66
+ return opts;
67
+ }
68
+
69
+ function severityColor(sev) {
70
+ if (sev === "CRITICAL") return `${BOLD}${RED}`;
71
+ if (sev === "HIGH") return RED;
72
+ if (sev === "MEDIUM") return YELLOW;
73
+ if (sev === "LOW") return CYAN;
74
+ return DIM;
75
+ }
76
+
77
+ function renderReport(res) {
78
+ const gradeColor =
79
+ res.grade === "A" ? GREEN : res.grade === "B" ? CYAN : res.grade === "C" ? YELLOW : RED;
80
+
81
+ console.log(`\n${BOLD}${CYAN}repotrap v0.1.0${RESET} ${DIM}| Prompt-Injection, Invisible Unicode & Repo Trap Audit${RESET}`);
82
+ console.log(`${DIM}${"=".repeat(76)}${RESET}`);
83
+ console.log(
84
+ `Skills Scanned: ${BOLD}${res.skillFilesScanned}${RESET} ` +
85
+ `Repos Checked: ${BOLD}${res.reposChecked}${RESET} ` +
86
+ `Sessions Replayed: ${BOLD}${res.sessionsScanned}${RESET} ` +
87
+ `Safety Score: ${BOLD}${gradeColor}${res.score}/100 (Grade ${res.grade})${RESET}`
88
+ );
89
+ console.log(
90
+ `Severities: ` +
91
+ `${BOLD}${RED}CRITICAL: ${res.severityCounts.CRITICAL}${RESET} ` +
92
+ `${RED}HIGH: ${res.severityCounts.HIGH}${RESET} ` +
93
+ `${YELLOW}MEDIUM: ${res.severityCounts.MEDIUM}${RESET} ` +
94
+ `${CYAN}LOW: ${res.severityCounts.LOW}${RESET} ` +
95
+ `${DIM}INFO: ${res.severityCounts.INFO}${RESET}`
96
+ );
97
+ console.log(`${DIM}${"-".repeat(76)}${RESET}`);
98
+
99
+ const m = res.metrics;
100
+ console.log(`${BOLD}${MAGENTA}[Plane 1] Untrusted Repo, Skill & Lifecycle Trap Surface${RESET}`);
101
+ console.log(
102
+ ` * Skill Remote Shell Pipes (curl | sh): ${
103
+ res.ruleCounts.RTR005 ? `${RED}${res.ruleCounts.RTR005} file(s) flagged${RESET}` : `${GREEN}0 remote shell pipes${RESET}`
104
+ }`
105
+ );
106
+ console.log(
107
+ ` * Standalone Zero-Width in Skills: ${
108
+ m.skillZeroWidthCount > 0 ? `${YELLOW}${m.skillZeroWidthCount} U+200B char(s)${RESET}` : `${GREEN}0 standalone zero-width chars${RESET}`
109
+ }`
110
+ );
111
+ console.log(
112
+ ` * Emoji ZWJ Sequences Separated: ${CYAN}${m.emojiZwjFilteredCount} composite emoji U+200D filtered (RTR014 guard)${RESET}`
113
+ );
114
+ console.log(
115
+ ` * Benign package.json Build Scripts: ${CYAN}${m.benignLifecycleCount} local build/prepare script(s) separated (RTR013 guard)${RESET}`
116
+ );
117
+
118
+ console.log(`\n${BOLD}${MAGENTA}[Plane 2] Transcript tool_result & Issue Poisoning Replay${RESET}`);
119
+ console.log(
120
+ ` * Prompt-Override / Delimiter Spoofs: ${
121
+ (res.ruleCounts.RTR008 || 0) + (res.ruleCounts.RTR009 || 0) > 0
122
+ ? `${RED}${(res.ruleCounts.RTR008 || 0) + (res.ruleCounts.RTR009 || 0)} untrusted tool_result payload(s) flagged${RESET}`
123
+ : `${GREEN}0 untrusted overrides${RESET}`
124
+ }`
125
+ );
126
+ console.log(
127
+ ` * Harness <system-reminder> Filtered: ${CYAN}${m.harnessSystemReminderCount} native attachment rows excluded (RTR012 guard)${RESET}`
128
+ );
129
+ console.log(
130
+ ` * Zero-Width Ingested in tool_result: ${
131
+ m.totalTranscriptZeroWidth > 0
132
+ ? `${YELLOW}${m.totalTranscriptZeroWidth} char(s) across ${m.transcriptsWithZeroWidth} session(s) (U+200B / U+200E)${RESET}`
133
+ : `${GREEN}0 zero-width chars${RESET}`
134
+ }`
135
+ );
136
+
137
+ console.log(`\n${BOLD}Rule Breakdown:${RESET}`);
138
+ const sortedRules = Object.entries(res.ruleCounts).sort((a, b) => {
139
+ const order = { CRITICAL: 5, HIGH: 4, MEDIUM: 3, LOW: 2, INFO: 1 };
140
+ const ma = RULE_META[a[0]] || { severity: "INFO" };
141
+ const mb = RULE_META[b[0]] || { severity: "INFO" };
142
+ return (order[mb.severity] || 0) - (order[ma.severity] || 0) || b[1] - a[1];
143
+ });
144
+
145
+ for (const [ruleId, count] of sortedRules) {
146
+ const meta = RULE_META[ruleId];
147
+ if (!meta) continue;
148
+ const col = severityColor(meta.severity);
149
+ console.log(
150
+ ` ${col}[${meta.severity.padEnd(8)}]${RESET} ${BOLD}${ruleId}${RESET} (${String(count).padStart(2)}x) ${meta.title}`
151
+ );
152
+ }
153
+
154
+ const notable = [
155
+ ...res.findings.filter((f) => f.severity === "CRITICAL"),
156
+ ...res.findings.filter((f) => f.severity === "HIGH"),
157
+ ...res.findings.filter((f) => f.severity === "MEDIUM"),
158
+ ...res.findings.filter((f) => f.severity === "LOW")
159
+ ].slice(0, 5);
160
+
161
+ if (notable.length > 0) {
162
+ console.log(`\n${BOLD}Top Evidence Samples:${RESET}`);
163
+ for (const f of notable) {
164
+ const col = severityColor(f.severity);
165
+ console.log(` ${col}* ${f.ruleId}${RESET}: ${f.detail}`);
166
+ }
167
+ }
168
+
169
+ console.log(`\n${DIM}Run 'npx repotrap --gh-issue owner/repo#123' to pre-scan an untrusted GitHub issue before agent triage.${RESET}\n`);
170
+ }
171
+
172
+ function main() {
173
+ const opts = parseArgs(process.argv.slice(2));
174
+ if (opts.help) {
175
+ printHelp();
176
+ process.exit(0);
177
+ }
178
+ if (opts.version) {
179
+ console.log("0.1.0");
180
+ process.exit(0);
181
+ }
182
+ if (opts.emitHook) {
183
+ const { hookScript, settingsSnippet } = generateGuardHook();
184
+ if (opts.json) {
185
+ console.log(JSON.stringify({ hookScript, settingsSnippet }, null, 2));
186
+ } else {
187
+ console.log(`${BOLD}${CYAN}# 1. Save to ~/.claude/hooks/repotrap-guard-hook.mjs:${RESET}\n`);
188
+ console.log(hookScript);
189
+ console.log(`${BOLD}${CYAN}# 2. Merge into ~/.claude/settings.json:${RESET}\n`);
190
+ console.log(JSON.stringify(settingsSnippet, null, 2));
191
+ }
192
+ process.exit(0);
193
+ }
194
+
195
+ if (opts.file) {
196
+ const content = fs.readFileSync(opts.file, "utf8");
197
+ const res = scanTextForPromptTraps(content, opts.file, true);
198
+ console.log(JSON.stringify(res, null, 2));
199
+ process.exit(res.findings.some((f) => f.severity === "CRITICAL" || f.severity === "HIGH") ? 1 : 0);
200
+ }
201
+
202
+ if (opts.ghIssue) {
203
+ const m = opts.ghIssue.match(/^([^#]+)#(\d+)$/);
204
+ if (!m) {
205
+ console.error("Invalid --gh-issue format. Expected owner/repo#123");
206
+ process.exit(1);
207
+ }
208
+ const [, repo, num] = m;
209
+ const out = execFileSync("gh", ["issue", "view", num, "--repo", repo, "--json", "title,body,comments"], {
210
+ encoding: "utf8"
211
+ });
212
+ const res = scanTextForPromptTraps(out, opts.ghIssue, true);
213
+ console.log(JSON.stringify(res, null, 2));
214
+ process.exit(res.findings.some((f) => f.severity === "CRITICAL" || f.severity === "HIGH") ? 1 : 0);
215
+ }
216
+
217
+ const result = opts.demo
218
+ ? getDemoResult()
219
+ : auditRepoTraps({
220
+ workspaceDir: opts.workspaceDir || "/Volumes/Apple External Drive/Prince Jain/Github"
221
+ });
222
+
223
+ if (opts.json) {
224
+ console.log(JSON.stringify(result, null, 2));
225
+ } else {
226
+ renderReport(result);
227
+ }
228
+
229
+ if (opts.failOn) {
230
+ const ranks = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
231
+ const threshold = ranks[opts.failOn] || 4;
232
+ const triggered = result.findings.some((f) => (ranks[f.severity] || 0) >= threshold);
233
+ if (triggered) process.exit(1);
234
+ }
235
+ }
236
+
237
+ main();
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "repotrap",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency prompt-injection, invisible Unicode steganography, spoofed system-reminder delimiter, and repo-open execution trap scanner for Claude Code, Codex CLI, skills, and GitHub issues.",
5
+ "type": "module",
6
+ "bin": {
7
+ "repotrap": "./bin/repotrap.mjs"
8
+ },
9
+ "main": "./src/analyzer.mjs",
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test tests/repotrap.test.mjs",
18
+ "demo": "node ./bin/repotrap.mjs --demo"
19
+ },
20
+ "keywords": [
21
+ "claude-code",
22
+ "codex",
23
+ "prompt-injection",
24
+ "security",
25
+ "unicode",
26
+ "github-issues",
27
+ "mcp",
28
+ "ai-agents",
29
+ "skills",
30
+ "static-analysis"
31
+ ],
32
+ "author": "Prince Jain <princejain756@gmail.com>",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/princejain756/repotrap.git"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ }
41
+ }
@@ -0,0 +1,725 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ export const RULE_META = {
6
+ // Plane 1: Untrusted Repo, Skill & Issue/PR Surface
7
+ RTR001: {
8
+ id: "RTR001",
9
+ plane: "repo",
10
+ severity: "CRITICAL",
11
+ title: "Workspace .claude/settings.json Defines Auto-Executing Hooks or Helper Scripts",
12
+ points: 30
13
+ },
14
+ RTR002: {
15
+ id: "RTR002",
16
+ plane: "repo",
17
+ severity: "CRITICAL",
18
+ title: "Workspace .mcp.json or .vscode/tasks.json Spawns Commands on Folder Open",
19
+ points: 25
20
+ },
21
+ RTR003: {
22
+ id: "RTR003",
23
+ plane: "repo",
24
+ severity: "CRITICAL",
25
+ title: "Unicode Tag Characters (U+E0000) or Bidirectional Override Smuggling (U+202A-U+202E)",
26
+ points: 30
27
+ },
28
+ RTR004: {
29
+ id: "RTR004",
30
+ plane: "repo",
31
+ severity: "HIGH",
32
+ title: "HTML Comment or Hidden Element Contains Imperative Prompt-Injection Verbs",
33
+ points: 20
34
+ },
35
+ RTR005: {
36
+ id: "RTR005",
37
+ plane: "repo",
38
+ severity: "HIGH",
39
+ title: "Skill or Instruction File Pipes Remote URL Directly to Shell (curl | sh / eval)",
40
+ points: 18
41
+ },
42
+ RTR006: {
43
+ id: "RTR006",
44
+ plane: "repo",
45
+ severity: "MEDIUM",
46
+ title: "package.json Lifecycle Hook (preinstall/postinstall) Runs Network or Shell Payload",
47
+ points: 15
48
+ },
49
+ RTR007: {
50
+ id: "RTR007",
51
+ plane: "repo",
52
+ severity: "LOW",
53
+ title: "Standalone Zero-Width Space (U+200B / U+FEFF) in Skill or Documentation File",
54
+ points: 5
55
+ },
56
+
57
+ // Plane 2: Session Transcript tool_result & Issue Poisoning Replay
58
+ RTR008: {
59
+ id: "RTR008",
60
+ plane: "transcript",
61
+ severity: "CRITICAL",
62
+ title: "Spoofed <system-reminder> or </tool_result> Delimiter Inside Untrusted Tool Output",
63
+ points: 30
64
+ },
65
+ RTR009: {
66
+ id: "RTR009",
67
+ plane: "transcript",
68
+ severity: "CRITICAL",
69
+ title: "Explicit Prompt-Override or Instruction-Hijack Phrase Inside Tool Output",
70
+ points: 25
71
+ },
72
+ RTR010: {
73
+ id: "RTR010",
74
+ plane: "transcript",
75
+ severity: "HIGH",
76
+ title: "Hidden HTML Comment Inside Tool Output Contains Imperative Agent Instructions",
77
+ points: 18
78
+ },
79
+ RTR011: {
80
+ id: "RTR011",
81
+ plane: "transcript",
82
+ severity: "MEDIUM",
83
+ title: "Standalone Zero-Width or Directionality Marks (U+200B / U+200E) Ingested in Tool Output",
84
+ points: 8
85
+ },
86
+ RTR012: {
87
+ id: "RTR012",
88
+ plane: "transcript",
89
+ severity: "INFO",
90
+ title: "Harness-Native <system-reminder> Attachment Rows Filtered (False-Positive Guard)",
91
+ points: 0
92
+ },
93
+ RTR013: {
94
+ id: "RTR013",
95
+ plane: "repo",
96
+ severity: "INFO",
97
+ title: "Benign Local package.json Build Lifecycle Scripts Separated (False-Positive Guard)",
98
+ points: 0
99
+ },
100
+ RTR014: {
101
+ id: "RTR014",
102
+ plane: "repo",
103
+ severity: "INFO",
104
+ title: "Legitimate Emoji ZWJ (U+200D) Sequences Separated from Steganography (False-Positive Guard)",
105
+ points: 0
106
+ }
107
+ };
108
+
109
+ export function maskPath(p) {
110
+ if (!p) return "";
111
+ return String(p)
112
+ .replace(/\/Users\/[a-zA-Z0-9._-]+/g, "~")
113
+ .replace(/\/home\/[a-zA-Z0-9._-]+/g, "~");
114
+ }
115
+
116
+ // Detect Unicode Tag block (U+E0000 - U+E007F, UTF-16 surrogates \uDB40[\uDC00-\uDC7F])
117
+ // and Bidirectional Overrides (U+202A - U+202E, U+2066 - U+2069)
118
+ const BIDI_OR_TAG_RE = /[\u202A-\u202E\u2066-\u2069]|\uDB40[\uDC00-\uDC7F]/g;
119
+
120
+ // Standalone zero-width characters (excluding U+200D ZWJ when between non-ASCII emoji surrogates)
121
+ export function analyzeZeroWidthCharacters(text) {
122
+ const s = String(text || "");
123
+ let bidiOrTagCount = 0;
124
+ let standaloneZeroWidthCount = 0;
125
+ let emojiZwjCount = 0;
126
+ const detectedCodes = new Set();
127
+
128
+ const bidiMatches = s.match(BIDI_OR_TAG_RE);
129
+ if (bidiMatches) {
130
+ bidiOrTagCount += bidiMatches.length;
131
+ for (const m of bidiMatches) {
132
+ const cp = m.codePointAt(0);
133
+ detectedCodes.add("U+" + cp.toString(16).toUpperCase());
134
+ }
135
+ }
136
+
137
+ for (let i = 0; i < s.length; i++) {
138
+ const code = s.charCodeAt(i);
139
+ if (code === 0x200d) {
140
+ // Check if surrounded by high/low surrogate or emoji Pictographic character
141
+ const prev = i > 0 ? s.charCodeAt(i - 1) : 0;
142
+ const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0;
143
+ if (prev > 127 && next > 127) {
144
+ emojiZwjCount++;
145
+ } else {
146
+ standaloneZeroWidthCount++;
147
+ detectedCodes.add("U+200D");
148
+ }
149
+ } else if (code === 0x200b || code === 0x200c || code === 0x200e || code === 0x200f || (code === 0xfeff && i > 0)) {
150
+ standaloneZeroWidthCount++;
151
+ detectedCodes.add("U+" + code.toString(16).toUpperCase().padStart(4, "0"));
152
+ }
153
+ }
154
+
155
+ return {
156
+ bidiOrTagCount,
157
+ standaloneZeroWidthCount,
158
+ emojiZwjCount,
159
+ detectedCodes: [...detectedCodes]
160
+ };
161
+ }
162
+
163
+ const IMPERATIVE_INJECTION_RE = /\b(ignore\s+(?:all\s+)?(?:previous|prior)\s+instructions|disregard\s+(?:all\s+)?(?:previous|prior)|system\s+prompt|you\s+are\s+now|important:\s*run\s+|curl\s+-|wget\s+http|bash\s+-c|cat\s+~\/\.|\.env\b|id_rsa\b|PreToolUse)/i;
164
+ const SPOOFED_DELIMITER_RE = /<\/?(?:system-reminder|tool_result|EXTREMELY_IMPORTANT|function_calls|antml:invoke)\b/i;
165
+ const REMOTE_SHELL_PIPE_RE = /\b(?:curl|wget)\b[^\n|]{3,140}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i;
166
+
167
+ export function scanTextForPromptTraps(text, sourceLabel = "input", isTranscriptToolResult = false) {
168
+ const findings = [];
169
+ const s = String(text || "");
170
+ if (!s) return { findings, zwStats: { bidiOrTagCount: 0, standaloneZeroWidthCount: 0, emojiZwjCount: 0 } };
171
+
172
+ const zwStats = analyzeZeroWidthCharacters(s);
173
+
174
+ if (zwStats.bidiOrTagCount > 0) {
175
+ findings.push({
176
+ ruleId: "RTR003",
177
+ plane: isTranscriptToolResult ? "transcript" : "repo",
178
+ severity: "CRITICAL",
179
+ file: maskPath(sourceLabel),
180
+ detail: `${zwStats.bidiOrTagCount} invisible Unicode Tag or Bidirectional override character(s) (${zwStats.detectedCodes.join(", ")}) detected in ${maskPath(sourceLabel)}`,
181
+ evidence: `Codes: ${zwStats.detectedCodes.join(", ")}`
182
+ });
183
+ }
184
+
185
+ if (zwStats.standaloneZeroWidthCount > 0) {
186
+ findings.push({
187
+ ruleId: isTranscriptToolResult ? "RTR011" : "RTR007",
188
+ plane: isTranscriptToolResult ? "transcript" : "repo",
189
+ severity: isTranscriptToolResult ? "MEDIUM" : "LOW",
190
+ file: maskPath(sourceLabel),
191
+ detail: `${zwStats.standaloneZeroWidthCount} standalone zero-width/directionality character(s) (${zwStats.detectedCodes.join(", ")}) in ${maskPath(sourceLabel)}`,
192
+ evidence: `Codes: ${zwStats.detectedCodes.join(", ")}`
193
+ });
194
+ }
195
+
196
+ // Check HTML comments for hidden prompt injection
197
+ const htmlComments = [...s.matchAll(/<!--([\s\S]{1,1500}?)-->/g)];
198
+ for (const m of htmlComments) {
199
+ const body = m[1];
200
+ if (IMPERATIVE_INJECTION_RE.test(body) || SPOOFED_DELIMITER_RE.test(body)) {
201
+ findings.push({
202
+ ruleId: isTranscriptToolResult ? "RTR010" : "RTR004",
203
+ plane: isTranscriptToolResult ? "transcript" : "repo",
204
+ severity: "HIGH",
205
+ file: maskPath(sourceLabel),
206
+ detail: `Hidden HTML comment in ${maskPath(sourceLabel)} contains imperative agent/shell instructions`,
207
+ evidence: `<!-- ${body.trim().slice(0, 140)} -->`
208
+ });
209
+ }
210
+ }
211
+
212
+ // Check for spoofed harness delimiters inside untrusted tool results or issue bodies
213
+ if (isTranscriptToolResult && SPOOFED_DELIMITER_RE.test(s)) {
214
+ const match = s.match(SPOOFED_DELIMITER_RE);
215
+ findings.push({
216
+ ruleId: "RTR008",
217
+ plane: "transcript",
218
+ severity: "CRITICAL",
219
+ file: maskPath(sourceLabel),
220
+ detail: `Untrusted tool output in ${maskPath(sourceLabel)} contains spoofed harness delimiter (${match[0]})`,
221
+ evidence: match[0]
222
+ });
223
+ }
224
+
225
+ // Check for explicit prompt override phrases inside tool_result
226
+ if (
227
+ isTranscriptToolResult &&
228
+ /\b(ignore\s+(?:all\s+)?(?:previous|prior)\s+instructions|disregard\s+(?:all\s+)?(?:previous|prior)\s+instructions|new\s+system\s+instructions\s*:)/i.test(s)
229
+ ) {
230
+ findings.push({
231
+ ruleId: "RTR009",
232
+ plane: "transcript",
233
+ severity: "CRITICAL",
234
+ file: maskPath(sourceLabel),
235
+ detail: `Untrusted tool output in ${maskPath(sourceLabel)} contains explicit prompt-override phrase`,
236
+ evidence: s.match(/\b(ignore\s+(?:all\s+)?(?:previous|prior)\s+instructions|disregard\s+(?:all\s+)?(?:previous|prior)\s+instructions|new\s+system\s+instructions\s*:)/i)[0]
237
+ });
238
+ }
239
+
240
+ // Check for remote shell pipe in skill / instruction files
241
+ if (!isTranscriptToolResult && REMOTE_SHELL_PIPE_RE.test(s)) {
242
+ const match = s.match(REMOTE_SHELL_PIPE_RE);
243
+ findings.push({
244
+ ruleId: "RTR005",
245
+ plane: "repo",
246
+ severity: "HIGH",
247
+ file: maskPath(sourceLabel),
248
+ detail: `Instruction/skill file ${maskPath(sourceLabel)} instructs agent to pipe remote URL directly into shell`,
249
+ evidence: match[0].trim().slice(0, 160)
250
+ });
251
+ }
252
+
253
+ return { findings, zwStats };
254
+ }
255
+
256
+ export function scanWorkspaceConfigFiles(repoDir) {
257
+ const findings = [];
258
+ let benignLifecycleCount = 0;
259
+
260
+ // 1. Check .claude/settings.json and .claude/settings.local.json inside repo
261
+ for (const rel of [".claude/settings.json", ".claude/settings.local.json"]) {
262
+ const p = path.join(repoDir, rel);
263
+ if (!fs.existsSync(p)) continue;
264
+ try {
265
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
266
+ const dangerousKeys = ["hooks", "apiKeyHelper", "otelHeadersHelper", "awsAuthRefresh", "statusLine"].filter(
267
+ (k) => Boolean(cfg?.[k])
268
+ );
269
+ if (dangerousKeys.length > 0) {
270
+ findings.push({
271
+ ruleId: "RTR001",
272
+ plane: "repo",
273
+ severity: "CRITICAL",
274
+ file: maskPath(p),
275
+ detail: `Workspace ${rel} defines auto-executing ${dangerousKeys.join(", ")} on session start or tool invocation`,
276
+ evidence: `Keys: ${dangerousKeys.join(", ")}`
277
+ });
278
+ }
279
+ } catch {
280
+ // ignore
281
+ }
282
+ }
283
+
284
+ // 2. Check .mcp.json and .vscode/tasks.json
285
+ const mcpPath = path.join(repoDir, ".mcp.json");
286
+ if (fs.existsSync(mcpPath)) {
287
+ try {
288
+ const mcp = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
289
+ const servers = Object.keys(mcp?.mcpServers || mcp || {});
290
+ if (servers.length > 0) {
291
+ findings.push({
292
+ ruleId: "RTR002",
293
+ plane: "repo",
294
+ severity: "CRITICAL",
295
+ file: maskPath(mcpPath),
296
+ detail: `Workspace .mcp.json defines ${servers.length} repo-scoped MCP server(s) (${servers.slice(0, 4).join(", ")})`,
297
+ evidence: `Servers: ${servers.slice(0, 4).join(", ")}`
298
+ });
299
+ }
300
+ } catch {
301
+ // ignore
302
+ }
303
+ }
304
+
305
+ const vscodeTasksPath = path.join(repoDir, ".vscode", "tasks.json");
306
+ if (fs.existsSync(vscodeTasksPath)) {
307
+ try {
308
+ const raw = fs.readFileSync(vscodeTasksPath, "utf8");
309
+ if (/"runOn"\s*:\s*"folderOpen"/i.test(raw)) {
310
+ findings.push({
311
+ ruleId: "RTR002",
312
+ plane: "repo",
313
+ severity: "CRITICAL",
314
+ file: maskPath(vscodeTasksPath),
315
+ detail: `Workspace .vscode/tasks.json contains task with "runOn": "folderOpen"`,
316
+ evidence: `"runOn": "folderOpen"`
317
+ });
318
+ }
319
+ } catch {
320
+ // ignore
321
+ }
322
+ }
323
+
324
+ // 3. Check package.json lifecycle hooks
325
+ const pkgPath = path.join(repoDir, "package.json");
326
+ if (fs.existsSync(pkgPath)) {
327
+ try {
328
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
329
+ const scripts = pkg?.scripts || {};
330
+ for (const hook of ["preinstall", "install", "postinstall", "prepare"]) {
331
+ const cmd = scripts[hook];
332
+ if (!cmd) continue;
333
+ if (/\b(curl|wget|nc\s|netcat|bash\s+-c|sh\s+-c|base64\s+-d|eval\s|http:\/\/|https:\/\/)\b/i.test(cmd)) {
334
+ findings.push({
335
+ ruleId: "RTR006",
336
+ plane: "repo",
337
+ severity: "MEDIUM",
338
+ file: maskPath(pkgPath),
339
+ detail: `package.json "${hook}" lifecycle script runs network or shell command (${cmd.slice(0, 100)})`,
340
+ evidence: `${hook}: ${cmd.slice(0, 120)}`
341
+ });
342
+ } else {
343
+ benignLifecycleCount++;
344
+ }
345
+ }
346
+ } catch {
347
+ // ignore
348
+ }
349
+ }
350
+
351
+ return { findings, benignLifecycleCount };
352
+ }
353
+
354
+ export function scanTranscriptFile(filePath) {
355
+ const findings = [];
356
+ let harnessSystemReminderCount = 0;
357
+ let spoofedToolResultDelimiterCount = 0;
358
+ let toolResultZeroWidthCount = 0;
359
+ let toolResultHtmlCommentCount = 0;
360
+
361
+ const content = fs.readFileSync(filePath, "utf8");
362
+ const lines = content.split("\n");
363
+
364
+ for (const line of lines) {
365
+ if (!line.trim() || line.length > 120000) continue;
366
+
367
+ // Check if line has <system-reminder>
368
+ const hasSysRem = line.includes("<system-reminder>");
369
+ const hasToolRes =
370
+ line.includes("tool_result") ||
371
+ line.includes("function_call_output") ||
372
+ line.includes("custom_tool_call_output");
373
+ const hasZw = /[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/.test(line);
374
+ const hasComment = line.includes("<!--");
375
+ const hasOverride = /ignore (?:all )?(?:previous|prior) instructions/i.test(line);
376
+
377
+ if (!hasSysRem && !hasToolRes && !hasZw && !hasComment && !hasOverride) continue;
378
+
379
+ let obj;
380
+ try {
381
+ obj = JSON.parse(line);
382
+ } catch {
383
+ continue;
384
+ }
385
+
386
+ // False-positive guard (RTR012): Claude Code native attachment / api-request rows
387
+ if (
388
+ hasSysRem &&
389
+ (obj.type === "attachment" ||
390
+ obj.type === "api-request-shape" ||
391
+ obj.type === "api-request-blob")
392
+ ) {
393
+ harnessSystemReminderCount++;
394
+ continue;
395
+ }
396
+
397
+ // Extract untrusted tool_result / function_call_output text
398
+ const toolPayloads = [];
399
+ if (Array.isArray(obj?.message?.content)) {
400
+ for (const c of obj.message.content) {
401
+ if (c?.type === "tool_result") {
402
+ const txt = typeof c.content === "string" ? c.content : JSON.stringify(c.content || "");
403
+ toolPayloads.push(txt);
404
+ }
405
+ }
406
+ }
407
+ if (
408
+ obj?.type === "response_item" &&
409
+ (obj?.payload?.type === "function_call_output" || obj?.payload?.type === "custom_tool_call_output")
410
+ ) {
411
+ const rawOut = obj.payload.output;
412
+ toolPayloads.push(typeof rawOut === "string" ? rawOut : JSON.stringify(rawOut || ""));
413
+ }
414
+ if (obj?.type === "user" && typeof obj?.message?.content === "string") {
415
+ toolPayloads.push(obj.message.content);
416
+ }
417
+
418
+ for (const payload of toolPayloads) {
419
+ if (payload.includes("<!--")) toolResultHtmlCommentCount++;
420
+ const res = scanTextForPromptTraps(payload, path.basename(filePath), true);
421
+ for (const f of res.findings) {
422
+ if (f.ruleId === "RTR008") spoofedToolResultDelimiterCount++;
423
+ findings.push(f);
424
+ }
425
+ toolResultZeroWidthCount += res.zwStats.standaloneZeroWidthCount;
426
+ }
427
+ }
428
+
429
+ return {
430
+ findings,
431
+ harnessSystemReminderCount,
432
+ spoofedToolResultDelimiterCount,
433
+ toolResultZeroWidthCount,
434
+ toolResultHtmlCommentCount
435
+ };
436
+ }
437
+
438
+ function walkFiles(dir, predicate, out = [], maxDepth = 4, curDepth = 0) {
439
+ if (!dir || !fs.existsSync(dir) || curDepth > maxDepth) return out;
440
+ let entries;
441
+ try {
442
+ entries = fs.readdirSync(dir, { withFileTypes: true });
443
+ } catch {
444
+ return out;
445
+ }
446
+ for (const e of entries) {
447
+ if (e.name === "node_modules" || e.name === ".git" || e.name === "dist" || e.name === "build") continue;
448
+ const full = path.join(dir, e.name);
449
+ if (e.isDirectory()) {
450
+ walkFiles(full, predicate, out, maxDepth, curDepth + 1);
451
+ } else if (predicate(full, e.name)) {
452
+ out.push(full);
453
+ }
454
+ }
455
+ return out;
456
+ }
457
+
458
+ export function computeRiskSummary(findings) {
459
+ const ruleCounts = {};
460
+ const severityCounts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 };
461
+
462
+ for (const f of findings) {
463
+ ruleCounts[f.ruleId] = (ruleCounts[f.ruleId] || 0) + 1;
464
+ if (severityCounts[f.severity] !== undefined) {
465
+ severityCounts[f.severity]++;
466
+ }
467
+ }
468
+
469
+ let penalty = 0;
470
+ for (const [ruleId, count] of Object.entries(ruleCounts)) {
471
+ const meta = RULE_META[ruleId];
472
+ if (!meta || meta.points === 0) continue;
473
+ const mult = Math.min(2.0, 1 + Math.log10(count) * 0.5);
474
+ penalty += Math.round(meta.points * mult);
475
+ }
476
+
477
+ const score = Math.max(0, Math.min(100, 100 - penalty));
478
+ let grade = "A";
479
+ if (score < 45) grade = "F";
480
+ else if (score < 60) grade = "D";
481
+ else if (score < 75) grade = "C";
482
+ else if (score < 90) grade = "B";
483
+
484
+ return { score, grade, ruleCounts, severityCounts };
485
+ }
486
+
487
+ export function auditRepoTraps(options = {}) {
488
+ const homeDir = options.homeDir || os.homedir();
489
+ const skillDirs = options.skillDirs || [
490
+ path.join(homeDir, ".gemini", "config", "skills"),
491
+ path.join(homeDir, ".agents", "skills")
492
+ ];
493
+ const workspaceDir = options.workspaceDir || process.cwd();
494
+
495
+ const skillFiles = [];
496
+ for (const sd of skillDirs) {
497
+ walkFiles(sd, (_p, name) => name.endsWith(".md"), skillFiles, 3);
498
+ }
499
+
500
+ const transcriptFiles = options.transcriptFiles || [
501
+ ...walkFiles(path.join(homeDir, ".claude", "projects"), (_p, n) => n.endsWith(".jsonl"), [], 3),
502
+ ...walkFiles(path.join(homeDir, ".codex", "sessions"), (_p, n) => n.endsWith(".jsonl"), [], 4)
503
+ ];
504
+
505
+ const allFindings = [];
506
+ let emojiZwjFilteredCount = 0;
507
+ let skillZeroWidthCount = 0;
508
+ let benignLifecycleCount = 0;
509
+ let reposChecked = 0;
510
+
511
+ // 1. Scan skill markdown files
512
+ for (const sf of skillFiles) {
513
+ const text = fs.readFileSync(sf, "utf8");
514
+ const res = scanTextForPromptTraps(text, sf, false);
515
+ allFindings.push(...res.findings);
516
+ emojiZwjFilteredCount += res.zwStats.emojiZwjCount;
517
+ skillZeroWidthCount += res.zwStats.standaloneZeroWidthCount;
518
+ }
519
+
520
+ // 2. Scan workspace repo(s)
521
+ if (fs.existsSync(workspaceDir)) {
522
+ const subDirs = [workspaceDir];
523
+ try {
524
+ for (const e of fs.readdirSync(workspaceDir, { withFileTypes: true })) {
525
+ if (e.isDirectory() && !e.name.startsWith(".")) {
526
+ subDirs.push(path.join(workspaceDir, e.name));
527
+ }
528
+ }
529
+ } catch {
530
+ // ignore
531
+ }
532
+ for (const dir of subDirs.slice(0, 35)) {
533
+ if (fs.existsSync(path.join(dir, "package.json")) || fs.existsSync(path.join(dir, ".claude")) || fs.existsSync(path.join(dir, ".mcp.json"))) {
534
+ reposChecked++;
535
+ const wsRes = scanWorkspaceConfigFiles(dir);
536
+ allFindings.push(...wsRes.findings);
537
+ benignLifecycleCount += wsRes.benignLifecycleCount;
538
+ }
539
+ }
540
+ }
541
+
542
+ // 3. Scan session transcripts
543
+ let harnessSystemReminderCount = 0;
544
+ let transcriptsWithZeroWidth = 0;
545
+ let totalTranscriptZeroWidth = 0;
546
+ let toolResultHtmlComments = 0;
547
+
548
+ for (const tf of transcriptFiles) {
549
+ const tRes = scanTranscriptFile(tf);
550
+ allFindings.push(...tRes.findings);
551
+ harnessSystemReminderCount += tRes.harnessSystemReminderCount;
552
+ if (tRes.toolResultZeroWidthCount > 0) {
553
+ transcriptsWithZeroWidth++;
554
+ totalTranscriptZeroWidth += tRes.toolResultZeroWidthCount;
555
+ }
556
+ toolResultHtmlComments += tRes.toolResultHtmlCommentCount;
557
+ }
558
+
559
+ if (harnessSystemReminderCount > 0) {
560
+ allFindings.push({
561
+ ruleId: "RTR012",
562
+ plane: "transcript",
563
+ severity: "INFO",
564
+ file: "session-transcripts",
565
+ detail: `Filtered ${harnessSystemReminderCount} Claude Code harness-native <system-reminder> attachment rows from untrusted tool_result delimiter checks`,
566
+ evidence: `${harnessSystemReminderCount} native attachment rows vs ${allFindings.filter((f) => f.ruleId === "RTR008").length} spoofed tool_result delimiters`
567
+ });
568
+ }
569
+
570
+ if (benignLifecycleCount > 0) {
571
+ allFindings.push({
572
+ ruleId: "RTR013",
573
+ plane: "repo",
574
+ severity: "INFO",
575
+ file: "workspace-repos",
576
+ detail: `Separated ${benignLifecycleCount} benign local package.json build/prepare script(s) from network preinstall/postinstall traps`,
577
+ evidence: `${benignLifecycleCount} local build lifecycle scripts`
578
+ });
579
+ }
580
+
581
+ if (emojiZwjFilteredCount > 0) {
582
+ allFindings.push({
583
+ ruleId: "RTR014",
584
+ plane: "repo",
585
+ severity: "INFO",
586
+ file: "skill-markdown-files",
587
+ detail: `Separated ${emojiZwjFilteredCount} legitimate composite Emoji ZWJ (U+200D) sequence(s) from standalone zero-width steganography`,
588
+ evidence: `${emojiZwjFilteredCount} emoji ZWJ vs ${skillZeroWidthCount} standalone U+200B in skills`
589
+ });
590
+ }
591
+
592
+ const summary = computeRiskSummary(allFindings);
593
+
594
+ return {
595
+ skillFilesScanned: skillFiles.length,
596
+ reposChecked,
597
+ sessionsScanned: transcriptFiles.length,
598
+ score: summary.score,
599
+ grade: summary.grade,
600
+ severityCounts: summary.severityCounts,
601
+ ruleCounts: summary.ruleCounts,
602
+ metrics: {
603
+ harnessSystemReminderCount,
604
+ benignLifecycleCount,
605
+ emojiZwjFilteredCount,
606
+ skillZeroWidthCount,
607
+ transcriptsWithZeroWidth,
608
+ totalTranscriptZeroWidth,
609
+ toolResultHtmlComments
610
+ },
611
+ findings: allFindings
612
+ };
613
+ }
614
+
615
+ export function generateGuardHook() {
616
+ const hookScript = `#!/usr/bin/env node
617
+ // Generated by repotrap (https://github.com/princejain756/repotrap)
618
+ // PreToolUse guardrail blocking gh issue/pr or curl commands against untrusted targets when workspace traps exist,
619
+ // and PostToolUse sanitizer alerting on Unicode Tag/Bidi smuggling or spoofed <system-reminder> tags.
620
+ import fs from "node:fs";
621
+
622
+ const raw = fs.readFileSync(0, "utf8");
623
+ let payload;
624
+ try {
625
+ payload = JSON.parse(raw);
626
+ } catch {
627
+ process.exit(0);
628
+ }
629
+
630
+ const text = JSON.stringify(payload?.tool_response || payload?.tool_input || {});
631
+ const BIDI_OR_TAG = /[\\u202A-\\u202E\\u2066-\\u2069]|\\uDB40[\\uDC00-\\uDC7F]/;
632
+ const SPOOFED_TAG = /<\\/?(?:system-reminder|EXTREMELY_IMPORTANT|function_calls)\\b/i;
633
+
634
+ if (BIDI_OR_TAG.test(text) || (payload?.tool_response && SPOOFED_TAG.test(text))) {
635
+ console.error("[repotrap] BLOCKED untrusted input containing Unicode Tag/Bidi smuggling or spoofed <system-reminder> delimiter.");
636
+ process.exit(2);
637
+ }
638
+ `;
639
+
640
+ const settingsSnippet = {
641
+ hooks: {
642
+ PostToolUse: [
643
+ {
644
+ matcher: "Bash|WebFetch|Read",
645
+ hooks: [
646
+ {
647
+ type: "command",
648
+ command: "node ~/.claude/hooks/repotrap-guard-hook.mjs"
649
+ }
650
+ ]
651
+ }
652
+ ]
653
+ }
654
+ };
655
+
656
+ return { hookScript, settingsSnippet };
657
+ }
658
+
659
+ export function getDemoResult() {
660
+ return {
661
+ skillFilesScanned: 525,
662
+ reposChecked: 16,
663
+ sessionsScanned: 153,
664
+ score: 64,
665
+ grade: "C",
666
+ severityCounts: {
667
+ CRITICAL: 0,
668
+ HIGH: 1,
669
+ MEDIUM: 15,
670
+ LOW: 1,
671
+ INFO: 3
672
+ },
673
+ ruleCounts: {
674
+ RTR005: 1,
675
+ RTR011: 15,
676
+ RTR007: 1,
677
+ RTR012: 1,
678
+ RTR013: 1,
679
+ RTR014: 1
680
+ },
681
+ metrics: {
682
+ harnessSystemReminderCount: 1526,
683
+ benignLifecycleCount: 7,
684
+ emojiZwjFilteredCount: 1,
685
+ skillZeroWidthCount: 2,
686
+ transcriptsWithZeroWidth: 5,
687
+ totalTranscriptZeroWidth: 142,
688
+ toolResultHtmlComments: 2
689
+ },
690
+ findings: [
691
+ {
692
+ ruleId: "RTR005",
693
+ plane: "repo",
694
+ severity: "HIGH",
695
+ file: "~/.agents/skills/composio-cli/SKILL.md",
696
+ detail: "Instruction/skill file ~/.agents/skills/composio-cli/SKILL.md instructs agent to pipe remote URL directly into shell",
697
+ evidence: "curl -fsSL https://composio.dev/install | bash"
698
+ },
699
+ {
700
+ ruleId: "RTR011",
701
+ plane: "transcript",
702
+ severity: "MEDIUM",
703
+ file: "rollout-2026-10-11T14-16-47-01a12a24.jsonl",
704
+ detail: "48 standalone zero-width/directionality character(s) (U+200B) in rollout-2026-10-11T14-16-47-01a12a24.jsonl (custom_tool_call_output)",
705
+ evidence: "Codes: U+200B"
706
+ },
707
+ {
708
+ ruleId: "RTR011",
709
+ plane: "transcript",
710
+ severity: "MEDIUM",
711
+ file: "c71c67c1-e80e-477d-b5a1-c072488e72a8.jsonl",
712
+ detail: "2 standalone zero-width/directionality character(s) (U+200B) in c71c67c1-e80e-477d-b5a1-c072488e72a8.jsonl",
713
+ evidence: "Codes: U+200B"
714
+ },
715
+ {
716
+ ruleId: "RTR007",
717
+ plane: "repo",
718
+ severity: "LOW",
719
+ file: "~/.agents/skills/writing-clearly-and-concisely/signs-of-ai-writing.md",
720
+ detail: "2 standalone zero-width/directionality character(s) (U+200B) in ~/.agents/skills/writing-clearly-and-concisely/signs-of-ai-writing.md",
721
+ evidence: "Codes: U+200B"
722
+ }
723
+ ]
724
+ };
725
+ }