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,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
+ }