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.
- package/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- 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
|
+
}
|