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,140 @@
|
|
|
1
|
+
// Path guard — beforeHook that blocks access to critical paths and detects secret files
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const AGENT_SRC_DIR = path.resolve(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
// Credential stores under the home directory. Guarded on their own, not via the
|
|
12
|
+
// denylist: .kube and .docker are not on it, and the denylist is configurable.
|
|
13
|
+
const SENSITIVE_DIRS = [".ssh", ".gnupg", ".aws", ".kube", ".docker"];
|
|
14
|
+
|
|
15
|
+
// Tools that access file paths
|
|
16
|
+
const PATH_TOOLS = new Set([
|
|
17
|
+
"read_file", "write_file", "edit_file", "delete_file",
|
|
18
|
+
"copy_file", "move_file", "create_directory",
|
|
19
|
+
"list_directory", "glob", "search_in_files", "view_image",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a path, following symlinks to get the real target.
|
|
24
|
+
* Falls back to path.resolve if realpathSync fails (file doesn't exist yet).
|
|
25
|
+
*/
|
|
26
|
+
function resolveReal(filePath) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.realpathSync.native(filePath);
|
|
29
|
+
} catch {
|
|
30
|
+
return path.resolve(filePath);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract file paths from tool args.
|
|
36
|
+
*/
|
|
37
|
+
function extractPaths(name, args) {
|
|
38
|
+
const paths = [];
|
|
39
|
+
if (args.path) paths.push(args.path);
|
|
40
|
+
if (args.source) paths.push(args.source);
|
|
41
|
+
if (args.destination) paths.push(args.destination);
|
|
42
|
+
return paths;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a path matches a denylist entry.
|
|
47
|
+
*/
|
|
48
|
+
function isDenied(resolved, denylist) {
|
|
49
|
+
// Windows paths are case-insensitive: c:\WINDOWS is C:\Windows.
|
|
50
|
+
const fold = process.platform === "win32" ? (p) => p.toLowerCase() : (p) => p;
|
|
51
|
+
const r = fold(resolved);
|
|
52
|
+
for (const denied of denylist) {
|
|
53
|
+
const deniedResolved = fold(path.resolve(denied));
|
|
54
|
+
if (r === deniedResolved || r.startsWith(deniedResolved + path.sep)) {
|
|
55
|
+
return denied;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if a filename matches secret file patterns.
|
|
63
|
+
*/
|
|
64
|
+
function isSecretFile(filePath, patterns) {
|
|
65
|
+
const basename = path.basename(filePath);
|
|
66
|
+
return patterns.some((re) => re.test(basename));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a path-guard beforeHook.
|
|
71
|
+
* @param {object} policy - Security policy from policies.js
|
|
72
|
+
* @returns {Function} beforeHook(name, args)
|
|
73
|
+
*/
|
|
74
|
+
export function createPathGuardHook(policy) {
|
|
75
|
+
// Combine critical denylist + agent src dir + extra deny paths
|
|
76
|
+
const denylist = [
|
|
77
|
+
AGENT_SRC_DIR,
|
|
78
|
+
...policy.criticalDenylist,
|
|
79
|
+
...( policy.extraDenyPaths || []),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
return function pathGuardHook(name, args) {
|
|
83
|
+
if (!PATH_TOOLS.has(name)) return null;
|
|
84
|
+
|
|
85
|
+
const rawPaths = extractPaths(name, args);
|
|
86
|
+
if (!rawPaths.length) return null;
|
|
87
|
+
|
|
88
|
+
for (const rawPath of rawPaths) {
|
|
89
|
+
const resolved = resolveReal(rawPath);
|
|
90
|
+
|
|
91
|
+
// os.homedir(), not process.env.HOME: HOME is unset on Windows, and
|
|
92
|
+
// path.resolve(path.join("", ".ssh")) silently guards <cwd>\.ssh instead.
|
|
93
|
+
// Read per call rather than once at module load so tests can repoint the home dir.
|
|
94
|
+
const homeDir = os.homedir();
|
|
95
|
+
const isSensitive = SENSITIVE_DIRS.some((dir) => {
|
|
96
|
+
const dirPath = path.resolve(path.join(homeDir, dir));
|
|
97
|
+
return resolved === dirPath || resolved.startsWith(dirPath + path.sep);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Check critical denylist
|
|
101
|
+
const deniedBy = isDenied(resolved, denylist);
|
|
102
|
+
if (deniedBy) {
|
|
103
|
+
// For read operations, allow listing but block writes to agent src
|
|
104
|
+
const isRead = name === "read_file" || name === "list_directory" ||
|
|
105
|
+
name === "glob" || name === "search_in_files" || name === "view_image";
|
|
106
|
+
|
|
107
|
+
// Always deny writing to critical paths
|
|
108
|
+
if (!isRead) {
|
|
109
|
+
return { deny: true, reason: `path "${resolved}" is protected (matches ${deniedBy})` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// For reads of agent source, allow but log
|
|
113
|
+
if (resolved.startsWith(AGENT_SRC_DIR)) {
|
|
114
|
+
// Allow reading own source (useful for debugging) — no action needed
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Reading a credential store asks for confirmation whether or not the denylist
|
|
120
|
+
// covers it. Previously this sat inside the denylist branch, so .kube and
|
|
121
|
+
// .docker were never reached.
|
|
122
|
+
// `key` is what an "always" answer may be recorded under: this tool
|
|
123
|
+
// for this exact file, nothing wider.
|
|
124
|
+
if (isSensitive) {
|
|
125
|
+
return { confirm: true, reason: `it is a sensitive path ("${resolved}")`, key: `${name}:${resolved}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check for secret files — force confirm even if permission is "allow"
|
|
129
|
+
if (policy.secretFilePatterns && isSecretFile(resolved, policy.secretFilePatterns)) {
|
|
130
|
+
return {
|
|
131
|
+
confirm: true,
|
|
132
|
+
reason: `"${path.basename(resolved)}" matches the secret-file patterns and may contain secrets`,
|
|
133
|
+
key: `${name}:${resolved}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Layer 4 — Output persona validation after-hook
|
|
2
|
+
// Detects if the agent's response indicates persona hijacking
|
|
3
|
+
|
|
4
|
+
const ROLEPLAY_MARKERS = [
|
|
5
|
+
// Pirate
|
|
6
|
+
/\bahoy\b/i, /\bmatey\b/i, /\bsavvy\b/i, /\bshiver\s+me\s+timbers\b/i,
|
|
7
|
+
/\bwalkin.?\s+the\s+plank\b/i, /🏴☠️/, /🦜/, /⚓/,
|
|
8
|
+
// Animal roleplay
|
|
9
|
+
/\bmeow\b/i, /\bwoof\b/i, /\bbark\b/i, /\bhiss\b/i, /\bpurr\b/i, /\bnyan\b/i,
|
|
10
|
+
// Robot / AI persona
|
|
11
|
+
/\bbeep\s*boop\b/i, /\bexterminate\b/i,
|
|
12
|
+
// Fantasy / medieval
|
|
13
|
+
/\bforsooth\b/i, /\bthee\b/i, /\bthou\b/i, /\bhearken\b/i, /\bprithee\b/i,
|
|
14
|
+
// Generic roleplay narration
|
|
15
|
+
/\*[^*]{5,}\*/, // excessive *action* text
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Repeated sound pattern: detects "Meow." appearing 2+ times in response
|
|
19
|
+
// Note: "arr" removed — too many false positives in code (arr.push, const arr = [])
|
|
20
|
+
const REPEATED_SOUND_RE = /\b(meow|woof|yarr|nya|beep|moo|oink|quack|hiss|roar|growl)\b/gi;
|
|
21
|
+
|
|
22
|
+
const EXCESSIVE_EMOJI_RE = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu;
|
|
23
|
+
const MAX_EMOJI_RATIO = 0.03; // tightened from 5% to 3%
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if agent response shows signs of persona hijacking.
|
|
27
|
+
* @param {string} text - Agent response text
|
|
28
|
+
* @returns {{ hijacked: boolean, signals: string[] }}
|
|
29
|
+
*/
|
|
30
|
+
export function detectPersonaHijack(text) {
|
|
31
|
+
if (!text || typeof text !== "string") return { hijacked: false, signals: [] };
|
|
32
|
+
|
|
33
|
+
// Strip code blocks — code content should not trigger persona detection
|
|
34
|
+
const textWithoutCode = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "");
|
|
35
|
+
|
|
36
|
+
const signals = [];
|
|
37
|
+
|
|
38
|
+
// Check roleplay markers (on text without code blocks)
|
|
39
|
+
for (const re of ROLEPLAY_MARKERS) {
|
|
40
|
+
if (re.test(textWithoutCode)) {
|
|
41
|
+
signals.push(`roleplay marker: ${re.source}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check repeated animal/character sounds
|
|
46
|
+
const soundMatches = textWithoutCode.match(REPEATED_SOUND_RE);
|
|
47
|
+
if (soundMatches && soundMatches.length >= 2) {
|
|
48
|
+
signals.push(`repeated sounds: ${soundMatches.join(", ")} (${soundMatches.length}x)`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check excessive emoji usage (on text without code)
|
|
52
|
+
const emojiMatches = textWithoutCode.match(EXCESSIVE_EMOJI_RE);
|
|
53
|
+
if (emojiMatches) {
|
|
54
|
+
const ratio = emojiMatches.length / text.length;
|
|
55
|
+
if (ratio > MAX_EMOJI_RATIO) {
|
|
56
|
+
signals.push(`excessive emoji: ${emojiMatches.length} (${(ratio * 100).toFixed(1)}%)`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Single strong signal is enough (e.g. 3+ meows)
|
|
61
|
+
const strongSignal = !!(soundMatches && soundMatches.length >= 3);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
hijacked: signals.length >= 2 || strongSignal,
|
|
65
|
+
signals,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Security policies — three profiles: strict / normal / permissive
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
const homeDir = os.homedir();
|
|
7
|
+
|
|
8
|
+
// Paths that must NEVER be written to regardless of policy
|
|
9
|
+
const CRITICAL_DENYLIST = [
|
|
10
|
+
".permissions.json",
|
|
11
|
+
path.join(homeDir, ".ssh"),
|
|
12
|
+
path.join(homeDir, ".gnupg"),
|
|
13
|
+
path.join(homeDir, ".aws"),
|
|
14
|
+
...systemFolders(),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// The operating system's own folders (2026-10-02). Run as administrator,
|
|
18
|
+
// Flint wrote into C:\Windows\System32: only the OS had ever refused, and at
|
|
19
|
+
// the normal care level file writes do not ask. Writes only; reads are fine.
|
|
20
|
+
function systemFolders() {
|
|
21
|
+
if (process.platform === "win32") {
|
|
22
|
+
const env = process.env;
|
|
23
|
+
return [
|
|
24
|
+
env.SystemRoot || env.windir || "C:\\Windows",
|
|
25
|
+
env.ProgramFiles || "C:\\Program Files",
|
|
26
|
+
env["ProgramFiles(x86)"] || "C:\\Program Files (x86)",
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
return ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/System"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Patterns that indicate secret content
|
|
33
|
+
const SECRET_PATTERNS = [
|
|
34
|
+
/sk-[a-zA-Z0-9]{20,}/g, // OpenAI / Stripe keys
|
|
35
|
+
/ghp_[a-zA-Z0-9]{36,}/g, // GitHub PATs
|
|
36
|
+
/gho_[a-zA-Z0-9]{36,}/g, // GitHub OAuth tokens
|
|
37
|
+
/github_pat_[a-zA-Z0-9_]{22,}/g, // GitHub fine-grained PATs
|
|
38
|
+
/AKIA[A-Z0-9]{16}/g, // AWS access key IDs
|
|
39
|
+
/-----BEGIN\s+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g, // Private keys
|
|
40
|
+
/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, // JWTs
|
|
41
|
+
/xox[bpas]-[a-zA-Z0-9-]{10,}/g, // Slack tokens
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Files that likely contain secrets
|
|
45
|
+
// ── Security levels ──
|
|
46
|
+
//
|
|
47
|
+
// Three explicit answers to "how careful should this be?", chosen once by an
|
|
48
|
+
// onboarding question and saved. Each level decides two things that used to be
|
|
49
|
+
// fixed by hardcoding: which tools prompt, and which dangerous-command patterns
|
|
50
|
+
// ask at all.
|
|
51
|
+
//
|
|
52
|
+
// The three are, in order of how much they trust a model to be careful:
|
|
53
|
+
//
|
|
54
|
+
// safe every tool that can change something asks; every dangerous
|
|
55
|
+
// command pattern asks, including the merely unusual ones.
|
|
56
|
+
// normal the default. The destructive patterns ask; a command that is
|
|
57
|
+
// merely unusual does not.
|
|
58
|
+
// permissive the agent has been told it may act without asking. Nothing
|
|
59
|
+
// prompts except what is not a judgement about risk at all.
|
|
60
|
+
//
|
|
61
|
+
// A level has to change something, or it is a label. The count of dangerous
|
|
62
|
+
// patterns that ask is strictly decreasing across the three, which is what
|
|
63
|
+
// makes "I chose permissive" mean something.
|
|
64
|
+
//
|
|
65
|
+
// What none of the three does is unblock a hard block. HARD_BLOCKED_TOOLS
|
|
66
|
+
// stays blocked everywhere, deliberately: a level is a judgement about ordinary
|
|
67
|
+
// risk, and letting the most permissive answer remove the one protection that
|
|
68
|
+
// is not about risk would make "permissive" mean "no longer safe" — the level
|
|
69
|
+
// would be doing two contradictory jobs at once.
|
|
70
|
+
|
|
71
|
+
export const LEVELS = ["safe", "normal", "permissive"];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The level in force until the onboarding question has been answered.
|
|
75
|
+
*
|
|
76
|
+
* Lives here rather than in security/index.js, which is where it was, because
|
|
77
|
+
* two things now have to agree on it: the guard that applies the level, and
|
|
78
|
+
* the first-run menu that marks one option as the current answer. A menu that
|
|
79
|
+
* highlights `safe` while the guard runs `normal` until you answer is a lie
|
|
80
|
+
* about what you are choosing, and the two constants would drift apart exactly
|
|
81
|
+
* the way the prompt and the parser once did.
|
|
82
|
+
*
|
|
83
|
+
* Named, not inlined, because "normal" appears in this codebase for two
|
|
84
|
+
* different reasons and only one of them is the default: the security
|
|
85
|
+
* *profile* is also allowed to be "normal" (loadPolicy defaults to it).
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_ONBOARDING_LEVEL = "normal";
|
|
88
|
+
|
|
89
|
+
// What each level actually does, in the operator's words.
|
|
90
|
+
//
|
|
91
|
+
// Kept beside LEVELS because these strings and these three names describe one
|
|
92
|
+
// thing. The onboarding prompt is assembled from both, and an answer is matched
|
|
93
|
+
// against both — so a level added without a description, or a description that
|
|
94
|
+
// no longer describes what the code does, shows up as a prompt that offers a
|
|
95
|
+
// choice it cannot record.
|
|
96
|
+
export const LEVEL_DESCRIPTIONS = {
|
|
97
|
+
safe: "asks before every change to files and every command",
|
|
98
|
+
normal: "asks before anything irreversible or that leaves this machine",
|
|
99
|
+
permissive: "does not ask — hard blocks still apply",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The prompt's options, derived from LEVELS — never written out separately.
|
|
104
|
+
*
|
|
105
|
+
* The key is the first letter, which is what "[n]ormal" tells the operator to
|
|
106
|
+
* type. Deriving it means the bracket and the accepted key cannot disagree:
|
|
107
|
+
* a level whose key is undefined below is one the prompt would offer and the
|
|
108
|
+
* parser could not record, which is the exact bug this replaced (the
|
|
109
|
+
* prompt said "pick s, n or p" and only the full words were accepted, so typing
|
|
110
|
+
* `n` was silently discarded and the question came back on every start).
|
|
111
|
+
*/
|
|
112
|
+
export function levelOptions() {
|
|
113
|
+
return LEVELS.map((level) => {
|
|
114
|
+
const description = LEVEL_DESCRIPTIONS[level];
|
|
115
|
+
if (!description) {
|
|
116
|
+
throw new Error(`Level "${level}" has no description in LEVEL_DESCRIPTIONS; it would be offered and then not recorded`);
|
|
117
|
+
}
|
|
118
|
+
return { level, key: level.slice(0, 1), description };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read an answer to the onboarding question, in any spelling it offers.
|
|
124
|
+
*
|
|
125
|
+
* Accepts the full word (the canonical spelling, and what is stored) or the
|
|
126
|
+
* single letter the prompt shows in brackets. Case-insensitive and whitespace-
|
|
127
|
+
* tolerant, because it is typed once, in a hurry, at first launch.
|
|
128
|
+
*
|
|
129
|
+
* Returns null for anything else. Refusing an unrecognised answer is the safe
|
|
130
|
+
* direction: the question is asked again next start and the documented default
|
|
131
|
+
* applies meanwhile, rather than a posture nobody chose being recorded as if
|
|
132
|
+
* they had.
|
|
133
|
+
*
|
|
134
|
+
* @param {string|null|undefined} answer
|
|
135
|
+
* @returns {string|null} a member of LEVELS, or null
|
|
136
|
+
*/
|
|
137
|
+
export function parseLevelAnswer(answer) {
|
|
138
|
+
if (answer == null) return null;
|
|
139
|
+
const text = String(answer).trim().toLowerCase();
|
|
140
|
+
if (!text) return null;
|
|
141
|
+
if (LEVELS.includes(text)) return text;
|
|
142
|
+
// More than one level sharing a first letter makes the letter ambiguous, and
|
|
143
|
+
// guessing which one was meant would silently pick a posture. Fail to null
|
|
144
|
+
// instead — the operator is asked again rather than answered wrongly.
|
|
145
|
+
const matches = levelOptions().filter((o) => o.key === text);
|
|
146
|
+
return matches.length === 1 ? matches[0].level : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Which dangerous-command patterns ask, by level.
|
|
151
|
+
*
|
|
152
|
+
* The list is indexed rather than copied three times: three copies of one list
|
|
153
|
+
* would drift, and a drift here means the level silently does not do what the
|
|
154
|
+
* operator chose.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} level
|
|
157
|
+
* @returns {RegExp[]}
|
|
158
|
+
* @throws if the level is not one of LEVELS — a typo must not fall back to a
|
|
159
|
+
* posture nobody chose
|
|
160
|
+
*/
|
|
161
|
+
export function dangerousPatternsThatAsk(level) {
|
|
162
|
+
if (!LEVELS.includes(level)) {
|
|
163
|
+
throw new Error(`Unknown security level: ${level}. Expected one of: ${LEVELS.join(", ")}`);
|
|
164
|
+
}
|
|
165
|
+
if (level === "permissive") return [];
|
|
166
|
+
|
|
167
|
+
// safe asks about EVERY command, including `ls -la`.
|
|
168
|
+
//
|
|
169
|
+
// Not a pattern list but a marker: any command is a "pattern that asks" when
|
|
170
|
+
// the operator has said they want to approve every action. That is what the
|
|
171
|
+
// level means in the onboarding question — "before every change to files and
|
|
172
|
+
// every command" — and expressing it as a list of patterns could only ever be
|
|
173
|
+
// a list of the commands somebody thought of in advance. `ls -la` is the
|
|
174
|
+
// plainest way to see a level that claims to be safe while quietly permitting
|
|
175
|
+
// whatever nobody wrote down.
|
|
176
|
+
if (level === "safe") return [ASK_EVERY_COMMAND];
|
|
177
|
+
|
|
178
|
+
// normal: the destructive half, plus everything that is a one-way door.
|
|
179
|
+
//
|
|
180
|
+
// The destructive flag says the worst case is unrecoverable work. On its own
|
|
181
|
+
// it was too narrow, and the gap was real: a plain `git push` destroys
|
|
182
|
+
// nothing locally, and a `mail` command destroys nothing at all — but both
|
|
183
|
+
// are irreversible in the sense the operator cares about, which is that other
|
|
184
|
+
// people now have it, or it has left this machine, and there is no undo for
|
|
185
|
+
// either. The default level asks about anything irreversible OR that leaves
|
|
186
|
+
// this machine, so both lists belong here.
|
|
187
|
+
//
|
|
188
|
+
// One list entry can match twice (npm publish is both destructive and
|
|
189
|
+
// one-way); harmless for a `test()` probe, and de-duplicating here would be
|
|
190
|
+
// bookkeeping for its own sake.
|
|
191
|
+
return [
|
|
192
|
+
...DANGEROUS_COMMAND_PATTERNS.filter((e) => e.destructive).map((e) => e.re),
|
|
193
|
+
...ONE_WAY_PATTERNS,
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Tools the `normal` level runs without asking, when their default is to ask.
|
|
199
|
+
*
|
|
200
|
+
* Local changes that can be undone (/rewind restores files) and commands. A
|
|
201
|
+
* command is not exempt from judgement here: the command guard still asks about
|
|
202
|
+
* every destructive or one-way pattern at this level (git push, rm -rf, mail).
|
|
203
|
+
* Deleting a file, starting another agent, mail, plugins and any tool nobody
|
|
204
|
+
* has classified (MCP) still ask.
|
|
205
|
+
*/
|
|
206
|
+
const ALWAYS_ASKS = new Set(["delete_file", "reconnect_mcp"]);
|
|
207
|
+
|
|
208
|
+
const NORMAL_RUNS_WITHOUT_ASKING = new Set([
|
|
209
|
+
"write_file", "edit_file", "copy_file", "move_file", "create_directory",
|
|
210
|
+
"run_command", "run_background_command", "kill_process",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* What a tool's default permission becomes at a level.
|
|
215
|
+
*
|
|
216
|
+
* Owner, 2026-10-01: the three levels changed only which command patterns ask,
|
|
217
|
+
* so every run_command and every write asked at every level, permissive
|
|
218
|
+
* included, although the onboarding question promised otherwise. The level
|
|
219
|
+
* now decides the tool defaults too. Only a default of "confirm" is relaxed; an
|
|
220
|
+
* explicit per-tool setting (/allow, /deny, /confirm) is applied before this,
|
|
221
|
+
* and the forced prompts (secret files, plugins, dangerous commands) are hooks
|
|
222
|
+
* this does not touch.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} level - one of LEVELS
|
|
225
|
+
* @param {string} name - tool name
|
|
226
|
+
* @param {string} base - the tool's default: "allow" | "confirm" | "deny"
|
|
227
|
+
* @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
export function toolPermissionAtLevel(level, name, base) {
|
|
230
|
+
if (base !== "confirm") return base;
|
|
231
|
+
// Asks at every level: deleting a file cannot be undone, and reconnect_mcp
|
|
232
|
+
// acts on a server name that came out of an error message (FLINT.md).
|
|
233
|
+
if (ALWAYS_ASKS.has(name)) return "confirm";
|
|
234
|
+
if (level === "permissive") return "allow";
|
|
235
|
+
if (level === "normal" && NORMAL_RUNS_WITHOUT_ASKING.has(name)) return "allow";
|
|
236
|
+
return "confirm";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The pattern that means "any command at all" — the `safe` level, spelled out.
|
|
241
|
+
*
|
|
242
|
+
* Exported so the tests and the status line can name the same thing, and so a
|
|
243
|
+
* caller can tell "asks about everything" apart from "asks about a list" without
|
|
244
|
+
* matching on the level string.
|
|
245
|
+
*/
|
|
246
|
+
export const ASK_EVERY_COMMAND = /[\s\S]*/;
|
|
247
|
+
|
|
248
|
+
export const SECRET_FILE_PATTERNS = [
|
|
249
|
+
/\.env$/i,
|
|
250
|
+
/\.env\..+$/i,
|
|
251
|
+
/credentials\.json$/i,
|
|
252
|
+
/\.pem$/i,
|
|
253
|
+
/\.key$/i,
|
|
254
|
+
/id_rsa$/i,
|
|
255
|
+
/id_ed25519$/i,
|
|
256
|
+
/id_ecdsa$/i,
|
|
257
|
+
/\.pgpass$/i,
|
|
258
|
+
/\.netrc$/i,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
// Command deny patterns — dangerous shell commands
|
|
262
|
+
export const COMMAND_DENY_PATTERNS = [
|
|
263
|
+
/rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?\//, // rm -rf /
|
|
264
|
+
/rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?\//, // rm -fr /
|
|
265
|
+
/:\(\)\s*\{\s*:\|:\s*&\s*\}\s*;/, // fork bomb :(){ :|:& };
|
|
266
|
+
/\|\s*(ba)?sh\b/, // curl | bash
|
|
267
|
+
/\|\s*bash\b/, // pipe to bash
|
|
268
|
+
/powershell\s+.*-[eE]nc/, // powershell -enc (obfuscated)
|
|
269
|
+
/\bformat\s+[a-zA-Z]:\s*/i, // format C:
|
|
270
|
+
/\bdd\s+.*of=\/dev\//, // dd of=/dev/
|
|
271
|
+
/\bmkfs\b/, // mkfs
|
|
272
|
+
// cmd.exe has no backslash escape: `"C:\"` is the quoted drive, so allow the quote.
|
|
273
|
+
/\bdel\s+\/[sS]\s+\/[qQ]\s+['"]?[a-zA-Z]:\\/i, // del /s /q C:\
|
|
274
|
+
// The Windows ways to the same damage (owner, 2026-10-02): a model refused
|
|
275
|
+
// `format C:` can reach for these. Whole disks and drive roots only; a
|
|
276
|
+
// recursive delete of a folder stays a question, not a block.
|
|
277
|
+
/\bFormat-Volume\b/i, // Format-Volume -DriveLetter C
|
|
278
|
+
/\bClear-Disk\b/i, // Clear-Disk -RemoveData
|
|
279
|
+
/\bdiskpart\b/i, // diskpart (clean, format, delete partition)
|
|
280
|
+
/\b(rd|rmdir)\s+(\/[sq]\s+){2}['"]?[a-zA-Z]:\\?['"]?(\s|$)/i, // rd /s /q C:\
|
|
281
|
+
/\bRemove-Item\b(?=[^|;&]*\s-Recurse)(?=[^|;&]*\s['"]?[a-zA-Z]:\\?\*?['"]?(\s|$|"))/i, // Remove-Item -Recurse C:\
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
// Commands that need forced confirmation even when permission is "allow".
|
|
285
|
+
//
|
|
286
|
+
// `destructive: true` marks the ones whose worst case is unrecoverable work —
|
|
287
|
+
// a recursive delete, a lost commit, a published package, a pruned cluster.
|
|
288
|
+
// Those still ask at the normal level. chmod 777 and the rest are ordinary
|
|
289
|
+
// things a person does on purpose while working, and only ask if the operator
|
|
290
|
+
// chose `safe`.
|
|
291
|
+
//
|
|
292
|
+
// The flag lives here rather than in a parallel list beside this one, because
|
|
293
|
+
// two lists drift and a drift means a level silently stops doing what the
|
|
294
|
+
// operator chose. Note it cannot be derived from the pattern's source: the
|
|
295
|
+
// source of /\brm\s+-r/ is the *string* "\brm\s+-r", so a filter regex
|
|
296
|
+
// expecting real whitespace after "rm" can never match it. That was tried, and
|
|
297
|
+
// it selected zero patterns — precisely the near-silent failure a parallel list
|
|
298
|
+
// exists to prevent.
|
|
299
|
+
export const DANGEROUS_COMMAND_PATTERNS = [
|
|
300
|
+
{ re: /\brm\s+-[a-zA-Z]*r/, destructive: true }, // rm -r (recursive delete)
|
|
301
|
+
{ re: /\bgit\s+push\s+.*--force/, destructive: true }, // git push --force
|
|
302
|
+
{ re: /\bgit\s+reset\s+--hard/, destructive: true }, // git reset --hard
|
|
303
|
+
{ re: /\bgit\s+clean\s+-[a-zA-Z]*f/, destructive: true }, // git clean -f
|
|
304
|
+
{ re: /\bchmod\s+777\b/, destructive: false }, // chmod 777
|
|
305
|
+
{ re: /\bnpm\s+publish\b/, destructive: true }, // npm publish
|
|
306
|
+
{ re: /\bdocker\s+rm\b/, destructive: true }, // docker rm
|
|
307
|
+
{ re: /\bdocker\s+system\s+prune/, destructive: true }, // docker system prune
|
|
308
|
+
{ re: /\bkubectl\s+delete\b/, destructive: true }, // kubectl delete
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
// Commands that are irreversible, or that send something off this machine.
|
|
312
|
+
//
|
|
313
|
+
// Two kinds, and the distinction is the spec's: a command that destroys work
|
|
314
|
+
// nobody can get back, and a command that puts data somewhere else. Both are
|
|
315
|
+
// one-way doors — there is no "un-push", no "un-send", and no getting the
|
|
316
|
+
// branch back once someone else has pulled.
|
|
317
|
+
//
|
|
318
|
+
// Kept beside DANGEROUS_COMMAND_PATTERNS rather than merged into it. That list
|
|
319
|
+
// is the guard's allow/deny vocabulary; this one is the question of *whether to
|
|
320
|
+
// ask*, which is a level's job. A pattern in both is not a duplicate — the
|
|
321
|
+
// guard refuses it outright, and a level still needs to know it is a one-way
|
|
322
|
+
// door.
|
|
323
|
+
export const ONE_WAY_PATTERNS = [
|
|
324
|
+
/\bgit\s+push\b/, // a plain push, not just --force:
|
|
325
|
+
// other people have the branch now
|
|
326
|
+
/\bnpm\s+publish\b/, // cannot be un-published
|
|
327
|
+
/\bdocker\s+push\b/,
|
|
328
|
+
/\bkubectl\s+(apply|delete)\b/,
|
|
329
|
+
/\bgh\s+(pr\s+merge|release\s+create)\b/,
|
|
330
|
+
// Leaves this machine, carrying the operator's own data out.
|
|
331
|
+
//
|
|
332
|
+
// Deliberately NOT a bare `\bcurl\b`. A first draft listed every curl and
|
|
333
|
+
// every wget here, on the reasoning that "curl talks to the network". That
|
|
334
|
+
// made the default level ask about `curl https://api.example.com/v1/x` — a
|
|
335
|
+
// read, the same class of thing as read_file, which criterion 1 says must not
|
|
336
|
+
// ask. Asking about every fetch is the prompt-fatigue failure the whole
|
|
337
|
+
// change exists to fix, and it is the failure the owner actually hit on
|
|
338
|
+
// 2026-09-29: prompts that stopped being decisions.
|
|
339
|
+
//
|
|
340
|
+
// So what asks is a request that carries something out — a body, a file, a
|
|
341
|
+
// non-idempotent method. A GET that returns nothing the operator already has
|
|
342
|
+
// discloses nothing and is not a one-way door. `curl -d`, `curl -F` and
|
|
343
|
+
// `curl -T` are.
|
|
344
|
+
/\bcurl\b[^\n|]*\s(?:-d\b|-F\b|-T\b|--data[\w-]*\b|--form\b|--upload-file\b|-X\s*(?:POST|PUT|PATCH|DELETE)\b|-XPOST\b|-XPUT\b|-XPATCH\b|-XDELETE\b)/,
|
|
345
|
+
/\bwget\b[^\n]*\s(?:--post-data\b|--post-file\b|--body-data\b)/,
|
|
346
|
+
// Sending mail. Anchored to the command position, because a bare `\bmail\b`
|
|
347
|
+
// also matches `grep bob@mail.example.com` — an address inside a search, not
|
|
348
|
+
// a message being sent.
|
|
349
|
+
/^\s*(?:sudo\s+)?(?:mail|mailx|sendmail|mutt)\b/,
|
|
350
|
+
/\bsendmail\b/,
|
|
351
|
+
// File transfer off this machine. rsync is listed plainly rather than keyed
|
|
352
|
+
// on a flag: the earlier `\brsync\b.*\s-\S*e\b` keyed on `-e` (the remote
|
|
353
|
+
// shell flag, not an upload flag) and so missed the ordinary `rsync -a
|
|
354
|
+
// ./a host:/b`, which is the case that matters.
|
|
355
|
+
/\bscp\b/,
|
|
356
|
+
/\brsync\b/,
|
|
357
|
+
/\bsftp\b/,
|
|
358
|
+
/\bnc\b|\btelnet\b|\bftp\b/, // opens a channel
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
const PROFILES = {
|
|
362
|
+
strict: {
|
|
363
|
+
name: "strict",
|
|
364
|
+
criticalDenylist: CRITICAL_DENYLIST,
|
|
365
|
+
secretPatterns: SECRET_PATTERNS,
|
|
366
|
+
secretFilePatterns: SECRET_FILE_PATTERNS,
|
|
367
|
+
commandDenyPatterns: COMMAND_DENY_PATTERNS,
|
|
368
|
+
// The guard (src/security/command-guard.js) matches with `pattern.test(...)`,
|
|
369
|
+
// so it takes the regexes, not the tagged entries. The `destructive` flag
|
|
370
|
+
// exists for the level split, and unwrapping here keeps the guard's
|
|
371
|
+
// interface unchanged rather than teaching two shapes to one consumer.
|
|
372
|
+
dangerousCommandPatterns: DANGEROUS_COMMAND_PATTERNS.map((e) => e.re),
|
|
373
|
+
network: {
|
|
374
|
+
blockPrivateIPs: true,
|
|
375
|
+
blockNonHttpProtocols: true,
|
|
376
|
+
rateLimit: { maxPerMinute: 30, windowMs: 60000 },
|
|
377
|
+
},
|
|
378
|
+
child: {
|
|
379
|
+
maxDepth: 2,
|
|
380
|
+
},
|
|
381
|
+
// Strict: also deny writing to home directory root files
|
|
382
|
+
extraDenyPaths: [
|
|
383
|
+
path.join(homeDir, ".bashrc"),
|
|
384
|
+
path.join(homeDir, ".bash_profile"),
|
|
385
|
+
path.join(homeDir, ".profile"),
|
|
386
|
+
path.join(homeDir, ".zshrc"),
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
normal: {
|
|
391
|
+
name: "normal",
|
|
392
|
+
criticalDenylist: CRITICAL_DENYLIST,
|
|
393
|
+
secretPatterns: SECRET_PATTERNS,
|
|
394
|
+
secretFilePatterns: SECRET_FILE_PATTERNS,
|
|
395
|
+
commandDenyPatterns: COMMAND_DENY_PATTERNS,
|
|
396
|
+
dangerousCommandPatterns: DANGEROUS_COMMAND_PATTERNS.map((e) => e.re),
|
|
397
|
+
network: {
|
|
398
|
+
blockPrivateIPs: true,
|
|
399
|
+
blockNonHttpProtocols: true,
|
|
400
|
+
rateLimit: { maxPerMinute: 60, windowMs: 60000 },
|
|
401
|
+
},
|
|
402
|
+
child: {
|
|
403
|
+
maxDepth: 5,
|
|
404
|
+
},
|
|
405
|
+
extraDenyPaths: [],
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
permissive: {
|
|
409
|
+
name: "permissive",
|
|
410
|
+
criticalDenylist: CRITICAL_DENYLIST,
|
|
411
|
+
secretPatterns: SECRET_PATTERNS,
|
|
412
|
+
secretFilePatterns: SECRET_FILE_PATTERNS,
|
|
413
|
+
commandDenyPatterns: COMMAND_DENY_PATTERNS,
|
|
414
|
+
dangerousCommandPatterns: [], // no forced confirm for dangerous commands
|
|
415
|
+
network: {
|
|
416
|
+
blockPrivateIPs: false,
|
|
417
|
+
blockNonHttpProtocols: true,
|
|
418
|
+
rateLimit: { maxPerMinute: 120, windowMs: 60000 },
|
|
419
|
+
},
|
|
420
|
+
child: {
|
|
421
|
+
maxDepth: 10,
|
|
422
|
+
},
|
|
423
|
+
extraDenyPaths: [],
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Load security policy based on config or env var.
|
|
429
|
+
* @param {object} config - App config (must have securityPolicy field)
|
|
430
|
+
* @returns {object} policy object
|
|
431
|
+
*/
|
|
432
|
+
export function loadPolicy(config) {
|
|
433
|
+
const name = config.securityPolicy || process.env.AGENT_SECURITY_POLICY || "normal";
|
|
434
|
+
const policy = PROFILES[name];
|
|
435
|
+
if (!policy) {
|
|
436
|
+
console.error(`[security] Unknown policy "${name}", falling back to "normal"`);
|
|
437
|
+
return PROFILES.normal;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Enforce minimum safety floor — policy can restrict but not weaken core protections
|
|
441
|
+
if (!policy.criticalDenylist?.length) {
|
|
442
|
+
policy.criticalDenylist = CRITICAL_DENYLIST;
|
|
443
|
+
}
|
|
444
|
+
if (!policy.secretPatterns?.length) {
|
|
445
|
+
policy.secretPatterns = SECRET_PATTERNS;
|
|
446
|
+
}
|
|
447
|
+
if (!policy.commandDenyPatterns?.length) {
|
|
448
|
+
policy.commandDenyPatterns = COMMAND_DENY_PATTERNS;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return policy;
|
|
452
|
+
}
|