flint-agent 1.14.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.
Files changed (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,98 @@
1
+ // Audit logger — writes JSON lines to {sessionsDir}/audit.jsonl
2
+
3
+ import { existsSync, mkdirSync, appendFileSync, statSync, renameSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ let auditFile = null;
7
+ let auditDir = null;
8
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
9
+
10
+ /**
11
+ * Initialize audit logging.
12
+ * @param {object} config - App config (needs sessionsDir)
13
+ */
14
+ export function initAudit(config) {
15
+ auditDir = config.sessionsDir;
16
+ if (!existsSync(auditDir)) {
17
+ mkdirSync(auditDir, { recursive: true });
18
+ }
19
+ auditFile = path.join(auditDir, "audit.jsonl");
20
+ }
21
+
22
+ /**
23
+ * Rotate audit file if it exceeds MAX_FILE_SIZE.
24
+ */
25
+ function rotateIfNeeded() {
26
+ if (!auditFile) return;
27
+ try {
28
+ if (!existsSync(auditFile)) return;
29
+ const stat = statSync(auditFile);
30
+ if (stat.size >= MAX_FILE_SIZE) {
31
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
32
+ const rotated = path.join(auditDir, `audit-${ts}.jsonl`);
33
+ renameSync(auditFile, rotated);
34
+ }
35
+ } catch {
36
+ // Rotation failure is non-fatal
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Write an audit log entry.
42
+ * @param {string} event - Event type (TOOL_CALL, DENIED, etc.)
43
+ * @param {string} toolName - Tool name
44
+ * @param {object} args - Tool arguments (will be truncated)
45
+ * @param {object} [extra] - Additional data
46
+ */
47
+ export function auditLog(event, toolName, args, extra) {
48
+ if (!auditFile) return;
49
+
50
+ try {
51
+ rotateIfNeeded();
52
+
53
+ // Truncate args for logging
54
+ const safeArgs = {};
55
+ if (args && typeof args === "object") {
56
+ for (const [k, v] of Object.entries(args)) {
57
+ const s = typeof v === "string" ? v : JSON.stringify(v);
58
+ safeArgs[k] = s.length > 200 ? s.slice(0, 200) + "..." : s;
59
+ }
60
+ }
61
+
62
+ const entry = {
63
+ ts: new Date().toISOString(),
64
+ event,
65
+ tool: toolName || null,
66
+ args: safeArgs,
67
+ ...(extra || {}),
68
+ };
69
+
70
+ appendFileSync(auditFile, JSON.stringify(entry) + "\n");
71
+ } catch {
72
+ // Audit logging failure is non-fatal
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create audit beforeHook — logs TOOL_CALL events.
78
+ * @returns {Function} beforeHook(name, args)
79
+ */
80
+ export function createAuditBeforeHook() {
81
+ return function auditBeforeHook(name, args) {
82
+ auditLog("TOOL_CALL", name, args);
83
+ return null; // never block
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Create audit afterHook — logs TOOL_RESULT events.
89
+ * @returns {Function} afterHook(name, args, result)
90
+ */
91
+ export function createAuditAfterHook() {
92
+ return function auditAfterHook(name, args, result) {
93
+ const resultStr = result != null ? String(result) : "";
94
+ const truncated = resultStr.length > 100 ? resultStr.slice(0, 100) + "..." : resultStr;
95
+ auditLog("TOOL_RESULT", name, args, { resultPreview: truncated });
96
+ return null; // don't transform result
97
+ };
98
+ }
@@ -0,0 +1,41 @@
1
+ // Child policy — beforeHook that enforces max agent spawn depth
2
+
3
+ import { MAX_AGENT_DEPTH } from "./safety-constants.js";
4
+
5
+ /**
6
+ * Get current agent depth from environment variable.
7
+ * Root agent = 0, first child = 1, etc.
8
+ */
9
+ function getCurrentDepth() {
10
+ const envDepth = process.env.AGENT_DEPTH;
11
+ if (envDepth != null) {
12
+ const n = parseInt(envDepth, 10);
13
+ // Clamp to hardcoded max — env can't raise the ceiling
14
+ return isNaN(n) ? 0 : Math.min(n, MAX_AGENT_DEPTH);
15
+ }
16
+ return 0;
17
+ }
18
+
19
+ /**
20
+ * Create a child-policy beforeHook.
21
+ * @param {object} policy - Security policy from policies.js
22
+ * @returns {Function} beforeHook(name, args)
23
+ */
24
+ export function createChildPolicyHook(policy) {
25
+ // Policy can lower the max depth but never exceed the hardcoded constant
26
+ const maxDepth = Math.min(policy.child?.maxDepth ?? MAX_AGENT_DEPTH, MAX_AGENT_DEPTH);
27
+ const currentDepth = getCurrentDepth();
28
+
29
+ return function childPolicyHook(name, args) {
30
+ if (name !== "spawn_agent") return null;
31
+
32
+ if (currentDepth >= maxDepth) {
33
+ return {
34
+ deny: true,
35
+ reason: `max agent depth (${maxDepth}) reached — current depth is ${currentDepth}`,
36
+ };
37
+ }
38
+
39
+ return null;
40
+ };
41
+ }
@@ -0,0 +1,173 @@
1
+ // Command guard — beforeHook that blocks dangerous shell commands
2
+
3
+ /**
4
+ * Strip shell noise that is not going to be executed: content inside
5
+ * single- and double-quoted strings, and shell comments (# to EOL).
6
+ * This prevents false positives where `grep -n '| sh' file` is blocked
7
+ * because the pattern argument contains `| sh`, or a script with
8
+ * `# rm -f` in a comment is blocked because the comment contains `rm -f`.
9
+ *
10
+ * The result is only used for pattern matching — the original command
11
+ * is what gets executed.
12
+ *
13
+ * Quoted text is dropped ONLY when it cannot run. It can run when the
14
+ * command hands a string to something that executes it (`bash -c "..."`,
15
+ * `eval "..."`, `ssh host "..."`, `xargs sh`, `python -c`...), and when a
16
+ * double-quoted string holds `$(...)` or backticks. In those cases the
17
+ * command is matched whole, as before. An earlier version dropped all quoted
18
+ * text and let `bash -c "rm -rf /"` through.
19
+ *
20
+ * Pass the command BEFORE collapsing whitespace: a comment ends at a
21
+ * newline, and the next line is a new command that must be matched.
22
+ *
23
+ * @param {string} cmd - Raw command string
24
+ * @returns {string} Command with inert quoted content and comments stripped
25
+ */
26
+ const RUNS_ITS_ARGUMENT = /(^|[\s;|&(`])(sh|bash|zsh|dash|ksh|fish|busybox|eval|exec|source|\.|xargs|parallel|ssh|su|sudo|doas|env|nohup|setsid|watch|timeout|nice|script|at|batch|crontab|python\d*(\.\d+)?|node|deno|bun|perl|ruby|php|lua|awk|gawk|sed|powershell|pwsh|cmd|cmd\.exe|wsl|-exec|-execdir)(?=$|[\s;|&)`<>])/;
27
+ // Outside quotes, `$` and backticks turn text into commands (`x='...'; $x`,
28
+ // `` `echo '...'` ``), and `>` writes it somewhere it may run later
29
+ // (`printf '...' > x.sh; ./x.sh`). Any of them: match the command whole.
30
+ const QUOTED_TEXT_MAY_RUN = /[`$>]/;
31
+ // A quoted path is an argument, not inert text: one word starting at a drive,
32
+ // a slash or ~ (`rm -rf "/"`, `del /s /q "C:\"`). Dropping it let quoting carry
33
+ // a hard-denied command through (2026-10-02). Text with spaces, such as a grep
34
+ // pattern, stays dropped.
35
+ const QUOTED_PATH = /^(?:[a-zA-Z]:|[\\/~])\S*$/;
36
+
37
+ export function stripShellNoise(cmd) {
38
+ let result = "";
39
+ let inSingle = false;
40
+ let inDouble = false;
41
+ let inComment = false;
42
+ let quoted = "";
43
+
44
+ for (let i = 0; i < cmd.length; i++) {
45
+ const ch = cmd[i];
46
+
47
+ // Inside a shell comment — skip until newline
48
+ if (inComment) {
49
+ if (ch === "\n") {
50
+ result += "\n";
51
+ inComment = false;
52
+ }
53
+ continue;
54
+ }
55
+
56
+ // Inside single quotes — no escapes, everything is literal
57
+ if (inSingle) {
58
+ if (ch === "'") {
59
+ // A path is matched as if it were not quoted: drop the opening quote.
60
+ if (QUOTED_PATH.test(quoted)) result = result.slice(0, -1) + quoted;
61
+ else result += "'";
62
+ inSingle = false;
63
+ continue;
64
+ }
65
+ quoted += ch;
66
+ continue;
67
+ }
68
+
69
+ // Inside double quotes — backslash escapes work. Command substitution
70
+ // runs even inside double quotes, so such a string is kept.
71
+ if (inDouble) {
72
+ if (ch === "\\" && i + 1 < cmd.length) {
73
+ quoted += ch + cmd[i + 1];
74
+ i++; // skip escaped character
75
+ continue;
76
+ }
77
+ if (ch === '"') {
78
+ if (QUOTED_PATH.test(quoted)) {
79
+ // A path is matched as if it were not quoted: drop the opening quote.
80
+ result = result.slice(0, -1) + quoted;
81
+ inDouble = false;
82
+ continue;
83
+ }
84
+ if (/\$\(|`/.test(quoted)) result += quoted;
85
+ result += '"';
86
+ inDouble = false;
87
+ continue;
88
+ }
89
+ quoted += ch;
90
+ continue;
91
+ }
92
+
93
+ // Outside quotes and comments
94
+ if (ch === "'") {
95
+ result += "'";
96
+ inSingle = true;
97
+ quoted = "";
98
+ } else if (ch === '"') {
99
+ result += '"';
100
+ inDouble = true;
101
+ quoted = "";
102
+ } else if (ch === "#") {
103
+ // # starts a comment when it begins a word (preceded by space,
104
+ // semicolon, pipe, ampersand, or start of string)
105
+ if (i === 0 || /[\s;|&({]/.test(cmd[i - 1])) {
106
+ inComment = true;
107
+ } else {
108
+ result += ch;
109
+ }
110
+ } else {
111
+ result += ch;
112
+ }
113
+ }
114
+
115
+ // An unclosed quote means we did not understand the command: match it whole.
116
+ if (inSingle || inDouble) return cmd;
117
+ // Something in the command executes a string: quoted text may run.
118
+ if (RUNS_ITS_ARGUMENT.test(result) || QUOTED_TEXT_MAY_RUN.test(result)) return cmd;
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Create a command-guard beforeHook.
124
+ * @param {object} policy - Security policy from policies.js
125
+ * @returns {Function} beforeHook(name, args)
126
+ */
127
+ export function createCommandGuardHook(policy) {
128
+ return function commandGuardHook(name, args) {
129
+ if (name !== "run_command" && name !== "run_background_command") return null;
130
+
131
+ const command = args.command;
132
+ if (!command || typeof command !== "string") return null;
133
+
134
+ // Strip inert quoted text and comments first, on the raw command, so a
135
+ // newline still ends a comment; then normalize (collapse whitespace, trim).
136
+ const stripped = stripShellNoise(command).replace(/\s+/g, " ").trim();
137
+
138
+ // 1. Check hard deny patterns — these are always blocked
139
+ for (const pattern of policy.commandDenyPatterns) {
140
+ if (pattern.test(stripped)) {
141
+ return {
142
+ deny: true,
143
+ reason: `dangerous command blocked: matches pattern ${pattern.source.slice(0, 40)}`,
144
+ denyKey: `cmd:${pattern.source}`,
145
+ };
146
+ }
147
+ }
148
+
149
+ // 2. Check dangerous command patterns — force confirm even if permission is "allow"
150
+ if (policy.dangerousCommandPatterns) {
151
+ // An "[a]lways" the operator already gave for this command in THIS
152
+ // project. Checked here, at the point of asking, so the grant applies to
153
+ // every route to the same question — a tool set to "allow", a bulk
154
+ // /allow-all, whatever the level is. A grant consulted only by the
155
+ // permissions layer would not be consulted at all when the level was
156
+ // permissive, and the answer the operator gave would depend on a setting
157
+ // they did not think about.
158
+ if (policy.readCommandApproval && policy.readCommandApproval({ cwd: args.cwd, command })) {
159
+ return null;
160
+ }
161
+ for (const pattern of policy.dangerousCommandPatterns) {
162
+ if (pattern.test(stripped)) {
163
+ return {
164
+ confirm: true,
165
+ reason: `potentially destructive command: matches pattern ${pattern.source.slice(0, 40)}`,
166
+ };
167
+ }
168
+ }
169
+ }
170
+
171
+ return null;
172
+ };
173
+ }
@@ -0,0 +1,250 @@
1
+ // Content Gate — unified sanitizer for ALL tool results before entering agent context
2
+ // Handles: size limits, control chars, delimiter injection, secrets, prompt injection
3
+
4
+ import crypto from "node:crypto";
5
+ import { MAX_RESULT_BYTES } from "./safety-constants.js";
6
+
7
+ /**
8
+ * Generate a unique session delimiter to replace the hardcoded <tool_result>.
9
+ * Format: <tool_result_XXXXXXXXXXXX> where X is random hex.
10
+ */
11
+ export function generateSessionDelimiter() {
12
+ const suffix = crypto.randomBytes(6).toString("hex");
13
+ return `tool_result_${suffix}`;
14
+ }
15
+
16
+ // ── Control Character Stripping ─────────────────────────────
17
+
18
+ // Zero-width chars, RTL/LTR overrides, other invisible manipulators
19
+ const CONTROL_CHAR_RE = /[\u200B-\u200F\u202A-\u202E\uFEFF\u00AD\u2060-\u2064\u2066-\u2069\u0000-\u0008\u000E-\u001F]/g;
20
+
21
+ export function stripControlChars(text) {
22
+ return text.replace(CONTROL_CHAR_RE, "");
23
+ }
24
+
25
+ // ── Injection Detection ─────────────────────────────────────
26
+ //
27
+ // Detection approach: multi-pattern regex heuristics with categorization
28
+ // and severity scoring. Covers OWASP LLM Top 10 #1 (Prompt Injection).
29
+ //
30
+ // Categories:
31
+ // instruction_override — attempts to replace/ignore system instructions
32
+ // role_manipulation — attempts to change the agent's identity/role
33
+ // delimiter_injection — attempts to break message boundaries
34
+ // safety_bypass — attempts to disable safety/security features
35
+ // data_exfil — attempts to extract system prompt or secrets
36
+ //
37
+ // Severity: high (3), medium (2), low (1)
38
+ // Score >= 3 → high confidence injection
39
+ // Score 1-2 → suspicious, log but don't block
40
+
41
+ const INJECTION_RULES = [
42
+ // instruction_override (high severity)
43
+ { pattern: /\bignore\s+(all\s+)?previous\s+instructions?\b/i, category: "instruction_override", severity: 3 },
44
+ { pattern: /\bnew\s+instructions?\s*:/i, category: "instruction_override", severity: 3 },
45
+ { pattern: /\bforget\s+(everything|all|your)\b/i, category: "instruction_override", severity: 3 },
46
+ { pattern: /\bdisregard\s+(all\s+)?(previous|above|prior)\b/i, category: "instruction_override", severity: 3 },
47
+ { pattern: /\bdo\s+not\s+follow\s+(any|your|the)\s+(previous|original)\b/i, category: "instruction_override", severity: 3 },
48
+
49
+ // role_manipulation (high severity)
50
+ { pattern: /\byou\s+are\s+now\b/i, category: "role_manipulation", severity: 3 },
51
+ { pattern: /\bACT\s+AS\b/i, category: "role_manipulation", severity: 2 },
52
+ { pattern: /\bpretend\s+(you\s+are|to\s+be)\b/i, category: "role_manipulation", severity: 2 },
53
+ { pattern: /\bDAN\s+mode\b/i, category: "role_manipulation", severity: 3 },
54
+ { pattern: /\bjailbreak\b/i, category: "role_manipulation", severity: 3 },
55
+ { pattern: /\brole\s*:\s*system\b/i, category: "role_manipulation", severity: 3 },
56
+ { pattern: /\bsimulate\s+(being|a)\b/i, category: "role_manipulation", severity: 1 },
57
+ { pattern: /\brespond\s+only\s+(in|as|like)\b/i, category: "role_manipulation", severity: 2 },
58
+ { pattern: /\bfrom\s+now\s+on\s+you\s+are\b/i, category: "role_manipulation", severity: 3 },
59
+
60
+ // delimiter_injection (high severity)
61
+ { pattern: /<\/?system>/i, category: "delimiter_injection", severity: 3 },
62
+ { pattern: /\bsystem\s*:\s*/i, category: "delimiter_injection", severity: 2 },
63
+ { pattern: /\[INST\]/i, category: "delimiter_injection", severity: 3 },
64
+ { pattern: /<<SYS>>/i, category: "delimiter_injection", severity: 3 },
65
+ { pattern: /<\|im_start\|>/i, category: "delimiter_injection", severity: 3 },
66
+ { pattern: /\bHuman\s*:\s*$/m, category: "delimiter_injection", severity: 2 },
67
+ { pattern: /\bAssistant\s*:\s*$/m, category: "delimiter_injection", severity: 2 },
68
+
69
+ // safety_bypass (high severity)
70
+ { pattern: /\boverride\s+safety\b/i, category: "safety_bypass", severity: 3 },
71
+ { pattern: /\bdisable\s+(all\s+)?filters?\b/i, category: "safety_bypass", severity: 3 },
72
+ { pattern: /\bno\s+restrictions?\b/i, category: "safety_bypass", severity: 2 },
73
+ { pattern: /\bwithout\s+(any\s+)?(restrictions?|limitations?|guardrails?)\b/i, category: "safety_bypass", severity: 2 },
74
+ { pattern: /\bturn\s+off\s+(safety|content\s+filter|moderation)\b/i, category: "safety_bypass", severity: 3 },
75
+ { pattern: /\bbypass\s+(content\s+)?(filter|policy|safety)\b/i, category: "safety_bypass", severity: 3 },
76
+
77
+ // data_exfil (medium severity)
78
+ { pattern: /\b(reveal|show|print|output|display)\s+(your\s+)?(system\s+prompt|instructions?|rules?)\b/i, category: "data_exfil", severity: 2 },
79
+ { pattern: /\bwhat\s+(are|is)\s+your\s+(system\s+)?(prompt|instructions?)\b/i, category: "data_exfil", severity: 1 },
80
+ { pattern: /\brepeat\s+(everything|all|the\s+text)\s+(above|before)\b/i, category: "data_exfil", severity: 2 },
81
+ ];
82
+
83
+ // ── Leet-speak / Obfuscation Normalization ──────────────────
84
+
85
+ const LEET_MAP = {
86
+ "0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a",
87
+ "$": "s", "!": "i", "|": "l",
88
+ };
89
+
90
+ const LEET_RE = /[013457@$!|]/g;
91
+
92
+ /**
93
+ * Normalize text for injection detection: lowercase, leet-speak → ascii, unicode whitespace → space.
94
+ */
95
+ export function normalizeForDetection(text) {
96
+ return text
97
+ .toLowerCase()
98
+ // Normalize unicode whitespace to regular space
99
+ .replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ")
100
+ // Normalize leet-speak substitutions
101
+ .replace(LEET_RE, (ch) => LEET_MAP[ch] || ch);
102
+ }
103
+
104
+ /**
105
+ * Detect prompt injection patterns in text.
106
+ * Returns structured result with all matched patterns, categories, and total severity score.
107
+ * Applies leet-speak normalization before matching.
108
+ * @param {string} text
109
+ * @returns {{ detected: boolean, score: number, matches: Array<{ pattern: string, category: string, severity: number }> }}
110
+ */
111
+ export function detectInjection(text) {
112
+ if (!text || typeof text !== "string") return { detected: false, score: 0, matches: [] };
113
+
114
+ // Check both original and normalized text
115
+ const normalized = normalizeForDetection(text);
116
+ const matches = [];
117
+ let score = 0;
118
+
119
+ for (const rule of INJECTION_RULES) {
120
+ if (rule.pattern.test(text) || rule.pattern.test(normalized)) {
121
+ matches.push({
122
+ pattern: rule.pattern.source,
123
+ category: rule.category,
124
+ severity: rule.severity,
125
+ });
126
+ score += rule.severity;
127
+ }
128
+ }
129
+
130
+ return {
131
+ detected: matches.length > 0,
132
+ score,
133
+ matches,
134
+ };
135
+ }
136
+
137
+ // Legacy compat: flat list for existing tests
138
+ const INJECTION_PATTERNS = INJECTION_RULES.map((r) => r.pattern);
139
+
140
+ // Tools whose output is the local disk or a local process. Injection is still
141
+ // detected and audited for these; it is not blocked. A command can fetch from
142
+ // the network, so this is a trust decision about the operator's machine, not
143
+ // a claim that the bytes are safe.
144
+ export const LOCAL_SOURCE_TOOLS = new Set([
145
+ "read_file", "search_in_files", "list_directory", "glob",
146
+ "run_command", "run_background_command", "peek_process",
147
+ ]);
148
+
149
+ // ── Content Gate Hook ───────────────────────────────────────
150
+
151
+ /**
152
+ * Create a Content Gate afterHook — the unified sanitizer for all tool outputs.
153
+ * Pipeline: size gate → control chars → delimiter escape → secret redaction → injection scan
154
+ *
155
+ * @param {string} delimiter - Session-unique delimiter tag name
156
+ * @param {object} policy - Security policy (for secretPatterns)
157
+ * @param {object} [auditFns] - { auditLog } for logging events
158
+ * @returns {Function} afterHook(name, args, result)
159
+ */
160
+ export function createContentFenceHook(delimiter, policy, auditFns) {
161
+ const auditLog = auditFns?.auditLog || (() => {});
162
+ const blockInjections = process.env.NODE_ENV === "test" && process.env.FLINT_UNSAFE_TEST_MODE === "1"
163
+ ? process.env.AGENT_CONTENT_GATE_BLOCK_INJECTIONS !== "false"
164
+ : true;
165
+
166
+ return function contentGateHook(name, args, result) {
167
+ if (name === "think") return null;
168
+ if (result == null) return null;
169
+ if (typeof result === "object") return null; // don't touch image/table objects
170
+
171
+ let text = String(result);
172
+ let modified = false;
173
+
174
+ // 1. Size gate — hard limit to prevent context flooding
175
+ const byteLength = Buffer.byteLength(text, "utf-8");
176
+ if (byteLength > MAX_RESULT_BYTES) {
177
+ const truncated = Buffer.from(text, "utf-8").subarray(0, MAX_RESULT_BYTES).toString("utf-8");
178
+ text = truncated + `\n\n[Content Gate: truncated from ${(byteLength / 1024 / 1024).toFixed(1)} MB to ${(MAX_RESULT_BYTES / 1024 / 1024).toFixed(1)} MB]`;
179
+ modified = true;
180
+ auditLog("CONTENT_TRUNCATED", name, args, {
181
+ originalBytes: byteLength,
182
+ maxBytes: MAX_RESULT_BYTES,
183
+ });
184
+ }
185
+
186
+ // 2. Strip invisible/control characters (zero-width, RTL overrides, etc.)
187
+ const cleaned = stripControlChars(text);
188
+ if (cleaned !== text) {
189
+ text = cleaned;
190
+ modified = true;
191
+ auditLog("CONTROL_CHARS_STRIPPED", name, args, {
192
+ removedCount: text.length - cleaned.length,
193
+ });
194
+ }
195
+
196
+ // 3. Escape tool_result delimiters in output to prevent delimiter injection
197
+ const delimRe = /<\/?tool_result(\s|>|_)/gi;
198
+ if (delimRe.test(text)) {
199
+ text = text.replace(/<(\/?)tool_result/gi, "<$1tool_result_escaped");
200
+ modified = true;
201
+ }
202
+
203
+ // Also escape the session-specific delimiter if it appears in output
204
+ if (delimiter && text.includes(delimiter)) {
205
+ text = text.replaceAll(delimiter, delimiter + "_escaped");
206
+ modified = true;
207
+ }
208
+
209
+ // 4. Redact secrets
210
+ if (policy.secretPatterns) {
211
+ for (const pattern of policy.secretPatterns) {
212
+ const re = new RegExp(pattern.source, pattern.flags);
213
+ const matches = text.match(re);
214
+ if (matches) {
215
+ for (const match of matches) {
216
+ const prefix = match.slice(0, Math.min(4, match.length));
217
+ const replacement = prefix + "*".repeat(Math.min(match.length - 4, 20));
218
+ text = text.replace(match, replacement);
219
+ modified = true;
220
+ auditLog("SECRET_REDACTED", name, args, {
221
+ pattern: pattern.source.slice(0, 30),
222
+ prefix,
223
+ });
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ // 5. Injection detection — log always, block high-confidence in blocking mode
230
+ const injection = detectInjection(text);
231
+ if (injection.detected) {
232
+ auditLog("INJECTION_ATTEMPT", name, args, {
233
+ score: injection.score,
234
+ categories: [...new Set(injection.matches.map((m) => m.category))],
235
+ snippet: text.slice(0, 100),
236
+ });
237
+ // Block any detected injection when blocking is enabled, on content
238
+ // that came from outside. Local files and local commands are logged
239
+ // but not blocked: an agent repairing its own code reads its own prompt
240
+ // text, and on 2026-09-26 the gate blinded it to agent.js mid-repair.
241
+ // Owner's decision: the gate is for the web, mail and MCP.
242
+ if (blockInjections && injection.score >= 2 && !LOCAL_SOURCE_TOOLS.has(name)) {
243
+ text = `[Content Gate: prompt injection detected (score ${injection.score}) in ${name} output — content blocked]`;
244
+ modified = true;
245
+ }
246
+ }
247
+
248
+ return modified ? text : null;
249
+ };
250
+ }