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,132 @@
1
+ // Content Validator — detects real content type by magic bytes
2
+ // Identifies images, audio, video, executables, archives, documents regardless of claimed type
3
+
4
+ // Magic byte signatures — [offset, bytes, type, subtype]
5
+ const SIGNATURES = [
6
+ // Images
7
+ [0, [0xFF, 0xD8, 0xFF], "image", "jpeg"],
8
+ [0, [0x89, 0x50, 0x4E, 0x47], "image", "png"],
9
+ [0, [0x47, 0x49, 0x46, 0x38], "image", "gif"],
10
+ [0, [0x42, 0x4D], "image", "bmp"],
11
+ [0, [0x49, 0x49, 0x2A, 0x00], "image", "tiff"],
12
+ [0, [0x4D, 0x4D, 0x00, 0x2A], "image", "tiff"],
13
+ [0, [0x52, 0x49, 0x46, 0x46], "container", "riff"], // WEBP, AVI, WAV
14
+ // PDF
15
+ [0, [0x25, 0x50, 0x44, 0x46], "document", "pdf"],
16
+ // Executables
17
+ [0, [0x4D, 0x5A], "executable", "pe"], // Windows PE (.exe, .dll)
18
+ [0, [0x7F, 0x45, 0x4C, 0x46], "executable", "elf"], // Linux ELF
19
+ [0, [0xCF, 0xFA, 0xED, 0xFE], "executable", "macho"], // macOS Mach-O
20
+ [0, [0xFE, 0xED, 0xFA, 0xCE], "executable", "macho"], // macOS Mach-O (reverse)
21
+ [0, [0xFE, 0xED, 0xFA, 0xCF], "executable", "macho64"],
22
+ // Archives
23
+ [0, [0x50, 0x4B, 0x03, 0x04], "archive", "zip"], // ZIP, DOCX, XLSX, JAR
24
+ [0, [0x1F, 0x8B], "archive", "gzip"],
25
+ [0, [0x42, 0x5A, 0x68], "archive", "bzip2"],
26
+ [0, [0xFD, 0x37, 0x7A, 0x58, 0x5A], "archive", "xz"],
27
+ [0, [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], "archive", "7z"],
28
+ [0, [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], "archive", "rar"],
29
+ // Audio
30
+ [0, [0x49, 0x44, 0x33], "audio", "mp3-id3"], // MP3 with ID3
31
+ [0, [0xFF, 0xFB], "audio", "mp3"],
32
+ [0, [0xFF, 0xF3], "audio", "mp3"],
33
+ [0, [0x66, 0x4C, 0x61, 0x43], "audio", "flac"],
34
+ [0, [0x4F, 0x67, 0x67, 0x53], "audio", "ogg"],
35
+ // Video
36
+ [4, [0x66, 0x74, 0x79, 0x70], "video", "mp4"], // MP4/MOV (ftyp at offset 4)
37
+ [0, [0x1A, 0x45, 0xDF, 0xA3], "video", "webm"], // WebM/MKV
38
+ [0, [0x00, 0x00, 0x01, 0xBA], "video", "mpeg"],
39
+ // Databases
40
+ [0, [0x53, 0x51, 0x4C, 0x69, 0x74, 0x65], "database", "sqlite"],
41
+ // Java
42
+ [0, [0xCA, 0xFE, 0xBA, 0xBE], "executable", "java-class"],
43
+ // WebAssembly
44
+ [0, [0x00, 0x61, 0x73, 0x6D], "executable", "wasm"],
45
+ ];
46
+
47
+ /**
48
+ * Detect content type from raw bytes (Buffer or Uint8Array).
49
+ * @param {Buffer|Uint8Array} data - Raw binary data (at least first 16 bytes)
50
+ * @returns {{ type: string, subtype: string } | null}
51
+ */
52
+ export function detectByMagicBytes(data) {
53
+ if (!data || data.length < 2) return null;
54
+
55
+ for (const [offset, bytes, type, subtype] of SIGNATURES) {
56
+ if (data.length < offset + bytes.length) continue;
57
+ let match = true;
58
+ for (let i = 0; i < bytes.length; i++) {
59
+ if (data[offset + i] !== bytes[i]) {
60
+ match = false;
61
+ break;
62
+ }
63
+ }
64
+ if (match) {
65
+ // RIFF container — check for WEBP/AVI/WAV
66
+ if (subtype === "riff" && data.length >= 12) {
67
+ const fourcc = String.fromCharCode(data[8], data[9], data[10], data[11]);
68
+ if (fourcc === "WEBP") return { type: "image", subtype: "webp" };
69
+ if (fourcc === "AVI ") return { type: "video", subtype: "avi" };
70
+ if (fourcc === "WAVE") return { type: "audio", subtype: "wav" };
71
+ return { type: "container", subtype: "riff-" + fourcc.trim() };
72
+ }
73
+ return { type, subtype };
74
+ }
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Check if content is likely text (not binary).
82
+ * @param {Buffer|Uint8Array} data
83
+ * @returns {boolean}
84
+ */
85
+ export function isLikelyText(data) {
86
+ if (!data || !data.length) return true;
87
+ const check = Math.min(data.length, 8192);
88
+ let nullCount = 0;
89
+ for (let i = 0; i < check; i++) {
90
+ if (data[i] === 0) nullCount++;
91
+ }
92
+ // More than 1% null bytes → binary
93
+ return nullCount / check < 0.01;
94
+ }
95
+
96
+ /**
97
+ * Check if a base64 string contains binary content.
98
+ * @param {string} b64 - Base64-encoded string
99
+ * @returns {{ isBinary: boolean, detected: { type: string, subtype: string } | null }}
100
+ */
101
+ export function detectBase64Content(b64) {
102
+ if (!b64 || typeof b64 !== "string") return { isBinary: false, detected: null };
103
+ try {
104
+ const buf = Buffer.from(b64, "base64");
105
+ const detected = detectByMagicBytes(buf);
106
+ return { isBinary: detected !== null, detected };
107
+ } catch {
108
+ return { isBinary: false, detected: null };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Validate claimed type matches actual content.
114
+ * @param {string} claimedType - MIME type or extension (e.g., "image/png", ".txt")
115
+ * @param {Buffer|Uint8Array} data - Raw data
116
+ * @returns {{ valid: boolean, actual: { type: string, subtype: string } | null, mismatch: boolean }}
117
+ */
118
+ export function validateContentType(claimedType, data) {
119
+ const actual = detectByMagicBytes(data);
120
+ if (!actual) return { valid: true, actual: null, mismatch: false };
121
+
122
+ const claimed = (claimedType || "").toLowerCase();
123
+
124
+ // Check for dangerous mismatches: claimed text/image but actually executable
125
+ if (actual.type === "executable") {
126
+ if (!claimed.includes("executable") && !claimed.includes("application/octet")) {
127
+ return { valid: false, actual, mismatch: true };
128
+ }
129
+ }
130
+
131
+ return { valid: true, actual, mismatch: false };
132
+ }
@@ -0,0 +1,143 @@
1
+ // Security module entry point — orchestrates all security hooks and services
2
+
3
+ import { loadPolicy, dangerousPatternsThatAsk, DEFAULT_ONBOARDING_LEVEL } from "./policies.js";
4
+ import { createPathGuardHook } from "./path-guard.js";
5
+ import { createContentFenceHook, generateSessionDelimiter } from "./content-fence.js";
6
+ import { createCommandGuardHook } from "./command-guard.js";
7
+ import { createNetworkGuardHook } from "./network-guard.js";
8
+ import { masterTokenIfEnabled, createAuthMiddleware } from "./api-auth.js";
9
+ import { initAudit, auditLog, createAuditBeforeHook, createAuditAfterHook } from "./audit.js";
10
+ import { startWatchdog } from "./watchdog.js";
11
+ import { ALLOW_SECURITY_DISABLE } from "./safety-constants.js";
12
+ import { createChildPolicyHook } from "./child-policy.js";
13
+ import { addBeforeHook, addAfterHook, getOnboardingAnswer } from "../tools/permissions.js";
14
+ import { getCommandApproval } from "../tools/command-approvals.js";
15
+
16
+ let securityApi = null;
17
+
18
+ /**
19
+ * The level used until the onboarding question has been answered.
20
+ *
21
+ * Now DEFAULT_ONBOARDING_LEVEL from policies.js, next to LEVELS and
22
+ * levelOptions(). It used to be a second copy of "normal" living in this file,
23
+ * which meant the guard and the first-run menu each named the default on their
24
+ * own and nothing checked that they still named the same one.
25
+ */
26
+ const DEFAULT_LEVEL = DEFAULT_ONBOARDING_LEVEL;
27
+
28
+ /**
29
+ * Initialize the security module.
30
+ * Registers hooks, starts watchdog, generates API token.
31
+ *
32
+ * @param {object} store - Zustand store
33
+ * @param {object} config - App config
34
+ * @returns {object} securityApi — { token, delimiter, authMiddleware, stopWatchdog, policy }
35
+ */
36
+ export function initSecurity(store, config) {
37
+ // Opt-out via env var — only allowed in test mode
38
+ if (process.env.AGENT_SECURITY_DISABLE === "1" && ALLOW_SECURITY_DISABLE) {
39
+ securityApi = {
40
+ token: null,
41
+ delimiter: null,
42
+ authMiddleware: null,
43
+ stopWatchdog: () => {},
44
+ policy: null,
45
+ disabled: true,
46
+ };
47
+ return securityApi;
48
+ }
49
+
50
+ try {
51
+ // Load policy
52
+ const policy = loadPolicy(config);
53
+
54
+ // Initialize audit logging
55
+ initAudit(config);
56
+
57
+ // Generate session-unique delimiter for content fencing
58
+ const delimiter = generateSessionDelimiter();
59
+
60
+ // The token-file key only with FLINT_API_TOKEN_FILE=1; otherwise null and
61
+ // programs get into the HTTP API by pairing (see masterTokenIfEnabled).
62
+ const token = masterTokenIfEnabled();
63
+ const authMiddleware = createAuthMiddleware(token);
64
+
65
+ // Register beforeHooks in order:
66
+ // 1. path-guard — blocks access to critical paths
67
+ addBeforeHook(createPathGuardHook(policy));
68
+
69
+ // 2. command-guard — blocks dangerous shell commands
70
+ //
71
+ // The patterns that ASK are the ones the chosen level names, not the ones
72
+ // the loaded profile carries. Those are two different axes that happen to
73
+ // share a word: loadPolicy keys on the security profile
74
+ // (strict/normal/permissive) while the onboarding question asks about the
75
+ // level (safe/normal/permissive). Wiring the guard to the profile meant the
76
+ // answer the operator gave had no effect on any command — the level
77
+ // decided prompts in tests and decided nothing in the app, which is exactly
78
+ // the shape of bug a suite full of direct unit calls cannot see.
79
+ //
80
+ // Unanswered falls back to normal, the documented default. Stated here
81
+ // rather than left implicit, because a guard handed no patterns at all is a
82
+ // guard that has silently stopped guarding.
83
+ // Read per call, not once at boot, so /careful takes effect at once.
84
+ const guardPolicy = { ...policy };
85
+ Object.defineProperty(guardPolicy, "dangerousCommandPatterns", {
86
+ enumerable: true,
87
+ get: () => dangerousPatternsThatAsk(getOnboardingAnswer() || DEFAULT_LEVEL),
88
+ });
89
+ addBeforeHook(createCommandGuardHook(Object.assign(guardPolicy, {
90
+ // The guard asks this rather than importing it, so the guard stays a pure
91
+ // function of (policy, command) and the file that owns persistence stays
92
+ // replaceable in a test.
93
+ // Named explicitly, not by shorthand: the policy field is called
94
+ // readCommandApproval and the function is called getCommandApproval, and
95
+ // a shorthand here made it a reference to an undefined binding — which
96
+ // initSecurity caught and turned into process.exit(78) at boot.
97
+ readCommandApproval: getCommandApproval,
98
+ })));
99
+
100
+ // 3. network-guard — blocks private IPs, rate limits
101
+ addBeforeHook(createNetworkGuardHook(policy, config.port));
102
+
103
+ // 4. child-policy — enforces max agent depth
104
+ addBeforeHook(createChildPolicyHook(policy));
105
+
106
+ // 5. audit before hook — logs all tool calls (last, so it logs even if others deny)
107
+ addBeforeHook(createAuditBeforeHook());
108
+
109
+ // Register afterHooks:
110
+ // 1. content-fence — escapes delimiters, redacts secrets, detects injections
111
+ addAfterHook(createContentFenceHook(delimiter, policy, { auditLog }));
112
+
113
+ // 2. audit after hook — logs tool results
114
+ addAfterHook(createAuditAfterHook());
115
+
116
+ // Start watchdog
117
+ const stopWatchdog = startWatchdog(store, config, { auditLog });
118
+
119
+ securityApi = {
120
+ token,
121
+ delimiter,
122
+ authMiddleware,
123
+ stopWatchdog,
124
+ policy,
125
+ disabled: false,
126
+ };
127
+
128
+ return securityApi;
129
+ } catch (err) {
130
+ // SECURITY: Never silently degrade — fail hard if security can't init
131
+ console.error(`[CRITICAL] Security init failed: ${err.message}`);
132
+ console.error("[CRITICAL] Agent cannot run without security hooks. Exiting.");
133
+ process.exit(78); // EX_CONFIG — configuration error
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Get the cached security API (after initSecurity has been called).
139
+ * @returns {object|null} securityApi
140
+ */
141
+ export function getSecurityApi() {
142
+ return securityApi;
143
+ }
@@ -0,0 +1,126 @@
1
+ // Network guard — beforeHook that blocks private IPs and rate-limits requests
2
+
3
+ /**
4
+ * Parse a URL and check if it targets a private/internal IP range.
5
+ */
6
+ function isPrivateIP(hostname) {
7
+ // Handle IPv4
8
+ const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
9
+ if (ipv4Match) {
10
+ const [, a, b, c, d] = ipv4Match.map(Number);
11
+ // 10.0.0.0/8
12
+ if (a === 10) return true;
13
+ // 172.16.0.0/12
14
+ if (a === 172 && b >= 16 && b <= 31) return true;
15
+ // 192.168.0.0/16
16
+ if (a === 192 && b === 168) return true;
17
+ // 127.0.0.0/8 (loopback)
18
+ if (a === 127) return true;
19
+ // 169.254.0.0/16 (link-local)
20
+ if (a === 169 && b === 254) return true;
21
+ // 0.0.0.0
22
+ if (a === 0 && b === 0 && c === 0 && d === 0) return true;
23
+ }
24
+
25
+ // Handle common hostnames that resolve to private IPs
26
+ if (hostname === "localhost") return true;
27
+
28
+ // IPv6 loopback
29
+ if (hostname === "::1" || hostname === "[::1]") return true;
30
+
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Sliding-window rate limiter.
36
+ */
37
+ function createRateLimiter(maxPerMinute, windowMs) {
38
+ const timestamps = [];
39
+
40
+ return function checkRate() {
41
+ const now = Date.now();
42
+ const cutoff = now - windowMs;
43
+
44
+ // Remove expired entries
45
+ while (timestamps.length > 0 && timestamps[0] < cutoff) {
46
+ timestamps.shift();
47
+ }
48
+
49
+ if (timestamps.length >= maxPerMinute) {
50
+ return false; // rate limited
51
+ }
52
+
53
+ timestamps.push(now);
54
+ return true; // allowed
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Create a network-guard beforeHook.
60
+ * @param {object} policy - Security policy from policies.js
61
+ * @param {number} [agentPort] - Agent's own port (excluded from private IP block)
62
+ * @returns {Function} beforeHook(name, args)
63
+ */
64
+ export function createNetworkGuardHook(policy, agentPort) {
65
+ const networkPolicy = policy.network || {};
66
+ const rateLimiter = networkPolicy.rateLimit
67
+ ? createRateLimiter(networkPolicy.rateLimit.maxPerMinute, networkPolicy.rateLimit.windowMs)
68
+ : null;
69
+
70
+ return function networkGuardHook(name, args) {
71
+ if (name !== "web_fetch" && name !== "web_search") return null;
72
+
73
+ // web_search doesn't have a user-provided URL to validate
74
+ if (name === "web_search") {
75
+ // Just apply rate limiting
76
+ if (rateLimiter && !rateLimiter()) {
77
+ return { deny: true, reason: "rate limited: too many network requests per minute" };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // web_fetch — validate URL
83
+ const url = args.url;
84
+ if (!url || typeof url !== "string") return null;
85
+
86
+ let parsed;
87
+ try {
88
+ parsed = new URL(url);
89
+ } catch {
90
+ return { deny: true, reason: `invalid URL: "${url}"` };
91
+ }
92
+
93
+ // Block non-http(s) protocols
94
+ if (networkPolicy.blockNonHttpProtocols) {
95
+ const protocol = parsed.protocol.toLowerCase();
96
+ if (protocol !== "http:" && protocol !== "https:") {
97
+ return { deny: true, reason: `blocked protocol: ${protocol} (only http/https allowed)` };
98
+ }
99
+ }
100
+
101
+ // Block private IPs
102
+ if (networkPolicy.blockPrivateIPs) {
103
+ const hostname = parsed.hostname;
104
+ if (isPrivateIP(hostname)) {
105
+ // Allow agent's own port on localhost
106
+ const port = parseInt(parsed.port, 10) || (parsed.protocol === "https:" ? 443 : 80);
107
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
108
+ if (agentPort && port === agentPort) {
109
+ // Allow self-requests (e.g., timer callbacks)
110
+ } else {
111
+ return { deny: true, reason: `blocked private IP: ${hostname}:${port}` };
112
+ }
113
+ } else {
114
+ return { deny: true, reason: `blocked private IP: ${hostname}` };
115
+ }
116
+ }
117
+ }
118
+
119
+ // Rate limiting
120
+ if (rateLimiter && !rateLimiter()) {
121
+ return { deny: true, reason: "rate limited: too many network requests per minute" };
122
+ }
123
+
124
+ return null;
125
+ };
126
+ }
@@ -0,0 +1,180 @@
1
+ // Agent pairing protocol — PIN-based authentication for peer agents
2
+
3
+ import crypto from "node:crypto";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ const MAX_ATTEMPTS = 3;
9
+ const MAX_SESSIONS = 5;
10
+ const PIN_EXPIRY_MS = 180_000; // 3 minutes
11
+
12
+ /** @type {Map<string, object>} */
13
+ const sessions = new Map();
14
+
15
+ // Paired programs, kept across restarts (owner, 2026-10-02): pairing is the
16
+ // only way into the HTTP API by default, so it has to be once per program,
17
+ // not once per run. Only a SHA-256 of each token is written, so the file
18
+ // grants nothing to whoever reads it. The path is resolved per call, so a
19
+ // test's sandboxed home is honoured.
20
+ const flintDir = () => join(homedir(), ".flint");
21
+ const pairedFile = () => join(flintDir(), "paired-clients.json");
22
+
23
+ /** @type {Array<{ hash: string, name: string, address: string, pairedAt: string }>|null} */
24
+ let clients = null;
25
+
26
+ const hashOf = (token) => crypto.createHash("sha256").update(String(token)).digest("hex");
27
+
28
+ function loadClients() {
29
+ if (clients) return clients;
30
+ try {
31
+ const raw = existsSync(pairedFile()) ? JSON.parse(readFileSync(pairedFile(), "utf-8")) : [];
32
+ clients = Array.isArray(raw) ? raw.filter((c) => c && typeof c.hash === "string") : [];
33
+ } catch {
34
+ clients = [];
35
+ }
36
+ return clients;
37
+ }
38
+
39
+ function saveClients() {
40
+ mkdirSync(flintDir(), { recursive: true });
41
+ const tmp = pairedFile() + ".tmp";
42
+ writeFileSync(tmp, JSON.stringify(clients, null, 2), { encoding: "utf-8", mode: 0o600 });
43
+ renameSync(tmp, pairedFile());
44
+ }
45
+
46
+ export function generatePin() {
47
+ return String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
48
+ }
49
+
50
+ export function createPairingSession(fromAddress) {
51
+ cleanExpiredSessions();
52
+ if (sessions.size >= MAX_SESSIONS) {
53
+ return { error: "Too many active pairing sessions" };
54
+ }
55
+
56
+ const sessionId = crypto.randomUUID();
57
+ const pin = generatePin();
58
+ const expiresAt = Date.now() + PIN_EXPIRY_MS;
59
+
60
+ sessions.set(sessionId, {
61
+ pin,
62
+ attempts: 0,
63
+ expiresAt,
64
+ fromAddress,
65
+ invalidated: false,
66
+ });
67
+
68
+ return { sessionId, pin, expiresAt };
69
+ }
70
+
71
+ /**
72
+ * @param {string} sessionId
73
+ * @param {string} candidatePin
74
+ * @param {{ name?: string, address?: string }} [who] - Kept with the pairing, for /paired
75
+ */
76
+ export function verifyPin(sessionId, candidatePin, who = {}) {
77
+ const session = sessions.get(sessionId);
78
+
79
+ if (!session || session.invalidated) {
80
+ return { valid: false, error: "Invalid or expired session" };
81
+ }
82
+
83
+ if (Date.now() > session.expiresAt) {
84
+ sessions.delete(sessionId);
85
+ return { valid: false, error: "Session expired" };
86
+ }
87
+
88
+ session.attempts++;
89
+
90
+ // Constant-time comparison
91
+ const expected = Buffer.from(session.pin, "utf-8");
92
+ const received = Buffer.from(String(candidatePin).padStart(6, "0"), "utf-8");
93
+ const match =
94
+ expected.length === received.length &&
95
+ crypto.timingSafeEqual(expected, received);
96
+
97
+ if (!match) {
98
+ if (session.attempts >= MAX_ATTEMPTS) {
99
+ session.invalidated = true;
100
+ sessions.delete(sessionId);
101
+ return { valid: false, error: "Max attempts exceeded, session invalidated" };
102
+ }
103
+ return {
104
+ valid: false,
105
+ error: `Invalid PIN (${MAX_ATTEMPTS - session.attempts} attempts remaining)`,
106
+ };
107
+ }
108
+
109
+ // Success — generate bearer token
110
+ const token = crypto.randomBytes(32).toString("hex");
111
+ loadClients().push({
112
+ hash: hashOf(token),
113
+ name: String(who.name || "unnamed").slice(0, 60),
114
+ address: String(who.address || session.fromAddress || "unknown"),
115
+ pairedAt: new Date().toISOString(),
116
+ });
117
+ saveClients();
118
+ sessions.delete(sessionId);
119
+ return { valid: true, token };
120
+ }
121
+
122
+ export function cleanExpiredSessions() {
123
+ const now = Date.now();
124
+ for (const [id, s] of sessions) {
125
+ if (now > s.expiresAt || s.invalidated) {
126
+ sessions.delete(id);
127
+ }
128
+ }
129
+ }
130
+
131
+ export function isPairedToken(token) {
132
+ if (!token) return false;
133
+ const h = Buffer.from(hashOf(token), "hex");
134
+ return loadClients().some((c) => {
135
+ const k = Buffer.from(c.hash, "hex");
136
+ return k.length === h.length && crypto.timingSafeEqual(k, h);
137
+ });
138
+ }
139
+
140
+ /** Paired programs, without their hashes. */
141
+ export function listPairedClients() {
142
+ return loadClients().map(({ name, address, pairedAt }) => ({ name, address, pairedAt }));
143
+ }
144
+
145
+ /**
146
+ * Revoke the pairings of one program by name, or every pairing with "all".
147
+ * @returns {number} How many were revoked
148
+ */
149
+ export function revokePairedClients(nameOrAll) {
150
+ const before = loadClients().length;
151
+ clients = nameOrAll === "all" ? [] : clients.filter((c) => c.name !== nameOrAll);
152
+ const n = before - clients.length;
153
+ if (n > 0 || nameOrAll === "all") saveClients();
154
+ return n;
155
+ }
156
+
157
+ export function revokePairedToken(token) {
158
+ const h = hashOf(token);
159
+ const before = loadClients().length;
160
+ clients = clients.filter((c) => c.hash !== h);
161
+ if (clients.length === before) return false;
162
+ saveClients();
163
+ return true;
164
+ }
165
+
166
+ export function revokeAllPairedTokens() {
167
+ revokePairedClients("all");
168
+ }
169
+
170
+ export function getActiveSessions() {
171
+ cleanExpiredSessions();
172
+ return sessions.size;
173
+ }
174
+
175
+ // For testing
176
+ export function _reset() {
177
+ sessions.clear();
178
+ clients = [];
179
+ saveClients();
180
+ }