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