@jamaynor/hal-config 1.0.1
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/CLAUDE.md +84 -0
- package/index.js +3 -0
- package/lib/config.js +675 -0
- package/package.json +23 -0
- package/publish.ps1 +30 -0
- package/security/access-control.js +308 -0
- package/security/governor.js +313 -0
- package/security/index.js +31 -0
- package/security/redactor.js +129 -0
- package/security/sanitizer.js +571 -0
- package/test/config-io.test.js +326 -0
- package/test/security.test.js +488 -0
- package/test/test-utils.test.js +360 -0
- package/test/test.js +586 -0
- package/test-utils.js +255 -0
package/publish.ps1
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
|
|
3
|
+
Write-Host "Publishing @jamaynor/hal-config from this directory..."
|
|
4
|
+
|
|
5
|
+
# Prompt for token without echoing it to the console.
|
|
6
|
+
$secure = Read-Host -Prompt "Paste npm automation token (input hidden)" -AsSecureString
|
|
7
|
+
$token = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
8
|
+
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
if (-not $token -or -not $token.Trim()) {
|
|
12
|
+
throw "No token provided."
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
$env:NPM_TOKEN = $token.Trim()
|
|
17
|
+
|
|
18
|
+
Write-Host ""
|
|
19
|
+
Write-Host "npm whoami:"
|
|
20
|
+
npm whoami
|
|
21
|
+
|
|
22
|
+
Write-Host ""
|
|
23
|
+
Write-Host "npm publish:"
|
|
24
|
+
npm publish
|
|
25
|
+
} finally {
|
|
26
|
+
Remove-Item Env:NPM_TOKEN -ErrorAction SilentlyContinue
|
|
27
|
+
# Reduce lifetime of plaintext token in memory.
|
|
28
|
+
$token = $null
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security/access-control.js
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Gate every file-path and URL against security rules, blocking
|
|
5
|
+
* directory traversal, sensitive filename/extension access, and outbound requests
|
|
6
|
+
* to private/reserved network ranges.
|
|
7
|
+
*
|
|
8
|
+
* Memory-file protection notice:
|
|
9
|
+
* SOUL.md and AGENTS.md are on the filename deny list. Code must NEVER
|
|
10
|
+
* write to those files or to any OpenClaw config files under any circumstances.
|
|
11
|
+
*
|
|
12
|
+
* All file paths are assumed to be POSIX-style (skills run inside a Linux
|
|
13
|
+
* Docker container). path/posix is used explicitly for consistent normalization
|
|
14
|
+
* across Linux (production) and Windows (local development/testing).
|
|
15
|
+
*
|
|
16
|
+
* Public Interface:
|
|
17
|
+
* access-control
|
|
18
|
+
* ├── validatePath(filePath, allowedDirs) → { allowed: true }
|
|
19
|
+
* │ | { allowed: false, reason: string }
|
|
20
|
+
* ├── validateUrl(url, resolver?) → Promise<{ allowed: true }>
|
|
21
|
+
* │ | Promise<{ allowed: false, reason: string }>
|
|
22
|
+
* └── validateFilename(name) → { allowed: true }
|
|
23
|
+
* | { allowed: false, reason: string }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { realpathSync } from 'node:fs';
|
|
27
|
+
import { posix } from 'node:path';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
import { lookup } from 'node:dns';
|
|
30
|
+
|
|
31
|
+
const dnsLookup = promisify(lookup);
|
|
32
|
+
|
|
33
|
+
// Use POSIX path semantics throughout — all skill paths are Unix-style.
|
|
34
|
+
const { normalize: posixNormalize, basename, extname } = posix;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Deny lists
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
// Sensitive filenames that must never be accessed regardless of directory.
|
|
41
|
+
// SOUL.md and AGENTS.md are included to prevent memory-file poisoning attacks.
|
|
42
|
+
const DENIED_FILENAMES = new Set([
|
|
43
|
+
'.env',
|
|
44
|
+
'credentials.json',
|
|
45
|
+
'id_rsa',
|
|
46
|
+
'id_rsa.pub',
|
|
47
|
+
'id_ed25519',
|
|
48
|
+
'id_ed25519.pub',
|
|
49
|
+
'id_ecdsa',
|
|
50
|
+
'id_ecdsa.pub',
|
|
51
|
+
'id_dsa',
|
|
52
|
+
'id_dsa.pub',
|
|
53
|
+
// OpenClaw memory files — writing to these creates a permanent backdoor
|
|
54
|
+
'SOUL.md',
|
|
55
|
+
'AGENTS.md',
|
|
56
|
+
// OpenClaw config filenames
|
|
57
|
+
'system-config.json',
|
|
58
|
+
'hal-skills.json',
|
|
59
|
+
'security-checksums.json',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Sensitive file extensions that must never be accessed.
|
|
63
|
+
const DENIED_EXTENSIONS = new Set([
|
|
64
|
+
'.pem',
|
|
65
|
+
'.key',
|
|
66
|
+
'.p12',
|
|
67
|
+
'.pfx',
|
|
68
|
+
'.cer',
|
|
69
|
+
'.crt',
|
|
70
|
+
'.der',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Private IP range detection helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a dotted-decimal IPv4 address string into its four octets.
|
|
79
|
+
* Returns null if the string is not a valid IPv4 address.
|
|
80
|
+
* @param {string} ip
|
|
81
|
+
* @returns {number[] | null}
|
|
82
|
+
*/
|
|
83
|
+
function parseIPv4Octets(ip) {
|
|
84
|
+
const parts = ip.split('.');
|
|
85
|
+
if (parts.length !== 4) return null;
|
|
86
|
+
const octets = parts.map(Number);
|
|
87
|
+
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null;
|
|
88
|
+
return octets;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Return a reserved-range descriptor if the IP string falls within a reserved or
|
|
93
|
+
* private range, or { reserved: false } if the IP is publicly routable.
|
|
94
|
+
* @param {string} ip
|
|
95
|
+
* @returns {{ reserved: true, reason: string } | { reserved: false }}
|
|
96
|
+
*/
|
|
97
|
+
function checkReservedIP(ip) {
|
|
98
|
+
const octets = parseIPv4Octets(ip);
|
|
99
|
+
if (!octets) {
|
|
100
|
+
return { reserved: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [a, b] = octets;
|
|
104
|
+
|
|
105
|
+
if (a === 127) {
|
|
106
|
+
return { reserved: true, reason: `loopback address (${ip})` };
|
|
107
|
+
}
|
|
108
|
+
if (a === 10) {
|
|
109
|
+
return { reserved: true, reason: `private RFC 1918 address (${ip}) — 10.x range` };
|
|
110
|
+
}
|
|
111
|
+
if (a === 172 && b >= 16 && b <= 31) {
|
|
112
|
+
return { reserved: true, reason: `private RFC 1918 address (${ip}) — 172.16-31.x range` };
|
|
113
|
+
}
|
|
114
|
+
if (a === 192 && b === 168) {
|
|
115
|
+
return { reserved: true, reason: `private RFC 1918 address (${ip}) — 192.168.x range` };
|
|
116
|
+
}
|
|
117
|
+
if (a === 169 && b === 254) {
|
|
118
|
+
return { reserved: true, reason: `link-local address (${ip})` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { reserved: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Patterns that identify private/reserved IP literals embedded in a hostname string.
|
|
125
|
+
const EMBEDDED_PRIVATE_IP_PATTERNS = [
|
|
126
|
+
/(?:^|\.)127\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:\.|$)/, // loopback embedded
|
|
127
|
+
/(?:^|\.)10\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:\.|$)/, // RFC 1918 10.x
|
|
128
|
+
/(?:^|\.)192\.168\.\d{1,3}\.\d{1,3}(?:\.|$)/, // RFC 1918 192.168.x
|
|
129
|
+
/(?:^|\.)172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}(?:\.|$)/, // RFC 1918 172.16-31
|
|
130
|
+
/(?:^|\.)169\.254\.\d{1,3}\.\d{1,3}(?:\.|$)/, // link-local
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Return true if a hostname string contains an embedded private IP literal.
|
|
135
|
+
* @param {string} hostname
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
function containsEmbeddedPrivateIP(hostname) {
|
|
139
|
+
return EMBEDDED_PRIVATE_IP_PATTERNS.some((re) => re.test(hostname));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Path normalization helpers
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Normalize a POSIX-style file path string, collapsing any `..` traversal sequences.
|
|
148
|
+
* @param {string} filePath
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function normalizePosixPath(filePath) {
|
|
152
|
+
const abs = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
|
153
|
+
return posixNormalize(abs);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// validateFilename
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validate that a filename (basename only, no directory) is safe to access.
|
|
162
|
+
* Checks against the filename deny list and the extension deny list.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} name — the filename (basename) to validate
|
|
165
|
+
* @returns {{ allowed: true } | { allowed: false, reason: string }}
|
|
166
|
+
*/
|
|
167
|
+
export function validateFilename(name) {
|
|
168
|
+
const lower = name.toLowerCase();
|
|
169
|
+
if (DENIED_FILENAMES.has(name) || DENIED_FILENAMES.has(lower)) {
|
|
170
|
+
return { allowed: false, reason: `access denied: filename '${name}' is on the security deny list` };
|
|
171
|
+
}
|
|
172
|
+
const ext = lower.slice(lower.lastIndexOf('.'));
|
|
173
|
+
if (ext && ext !== lower && DENIED_EXTENSIONS.has(ext)) {
|
|
174
|
+
return { allowed: false, reason: `access denied: extension '${ext}' is on the security deny list` };
|
|
175
|
+
}
|
|
176
|
+
return { allowed: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// validatePath
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate that a file path is safe to access.
|
|
185
|
+
*
|
|
186
|
+
* Resolution order:
|
|
187
|
+
* 1. Attempt to resolve symlinks via fs.realpathSync.
|
|
188
|
+
* If the path does not exist, fall back to posix normalize.
|
|
189
|
+
* 2. Check the basename against the filename deny list and extension deny list
|
|
190
|
+
* via validateFilename().
|
|
191
|
+
* 3. Check that the resolved path starts with one of the allowedDirs.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} filePath — the POSIX file path to validate
|
|
194
|
+
* @param {string[]} allowedDirs — list of allowed workspace directory prefixes (POSIX)
|
|
195
|
+
* @returns {{ allowed: true } | { allowed: false, reason: string }}
|
|
196
|
+
*/
|
|
197
|
+
export function validatePath(filePath, allowedDirs = []) {
|
|
198
|
+
// Step 1 — Resolve the canonical path.
|
|
199
|
+
let resolvedPath;
|
|
200
|
+
try {
|
|
201
|
+
resolvedPath = realpathSync(filePath);
|
|
202
|
+
// On Windows, realpathSync returns a Windows path — convert to POSIX.
|
|
203
|
+
resolvedPath = resolvedPath.replace(/\\/g, '/');
|
|
204
|
+
// Strip any Windows drive prefix (e.g. "C:") so POSIX path comparison is clean.
|
|
205
|
+
resolvedPath = resolvedPath.replace(/^[A-Za-z]:/, '');
|
|
206
|
+
} catch {
|
|
207
|
+
resolvedPath = normalizePosixPath(filePath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Step 2 — Filename and extension deny-list check via validateFilename.
|
|
211
|
+
const name = basename(resolvedPath);
|
|
212
|
+
const filenameCheck = validateFilename(name);
|
|
213
|
+
if (!filenameCheck.allowed) {
|
|
214
|
+
return filenameCheck;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Step 3 — Verify the resolved path is within one of the allowed directories.
|
|
218
|
+
if (allowedDirs.length > 0) {
|
|
219
|
+
const withinAllowed = allowedDirs.some((dir) => {
|
|
220
|
+
const normalizedDir = posixNormalize(dir);
|
|
221
|
+
return (
|
|
222
|
+
resolvedPath === normalizedDir ||
|
|
223
|
+
resolvedPath.startsWith(normalizedDir + '/')
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!withinAllowed) {
|
|
228
|
+
return {
|
|
229
|
+
allowed: false,
|
|
230
|
+
reason: `access denied: path '${resolvedPath}' is outside the allowed workspace directories`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { allowed: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// validateUrl
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validate that a URL is safe to request.
|
|
244
|
+
*
|
|
245
|
+
* Checks performed:
|
|
246
|
+
* 1. Only http and https schemes are permitted.
|
|
247
|
+
* 2. The hostname is inspected for embedded private IP literals.
|
|
248
|
+
* 3. The hostname is resolved and checked against reserved ranges.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} url — the URL to validate
|
|
251
|
+
* @param {((hostname: string) => Promise<string>) | undefined} [resolver]
|
|
252
|
+
* Optional DNS resolver for dependency injection in tests.
|
|
253
|
+
* @returns {Promise<{ allowed: true } | { allowed: false, reason: string }>}
|
|
254
|
+
*/
|
|
255
|
+
export async function validateUrl(url, resolver) {
|
|
256
|
+
// Step 1 — Parse the URL; reject malformed URLs.
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = new URL(url);
|
|
260
|
+
} catch {
|
|
261
|
+
return { allowed: false, reason: `access denied: URL '${url}' could not be parsed` };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Step 2 — Scheme check: only http and https are permitted.
|
|
265
|
+
const scheme = parsed.protocol.replace(/:$/, '');
|
|
266
|
+
if (scheme !== 'http' && scheme !== 'https') {
|
|
267
|
+
return {
|
|
268
|
+
allowed: false,
|
|
269
|
+
reason: `access denied: URL scheme '${scheme}' is not allowed; only http and https are permitted`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const hostname = parsed.hostname;
|
|
274
|
+
|
|
275
|
+
// Step 3 — DNS rebinding bypass: reject hostnames containing embedded private IP literals.
|
|
276
|
+
if (containsEmbeddedPrivateIP(hostname)) {
|
|
277
|
+
return {
|
|
278
|
+
allowed: false,
|
|
279
|
+
reason: `access denied: hostname '${hostname}' contains an embedded private or reserved IP address (DNS rebinding bypass pattern)`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Step 4 — DNS resolution: resolve the hostname and check the IP against reserved ranges.
|
|
284
|
+
let resolvedIP;
|
|
285
|
+
try {
|
|
286
|
+
if (typeof resolver === 'function') {
|
|
287
|
+
resolvedIP = await resolver(hostname);
|
|
288
|
+
} else {
|
|
289
|
+
const result = await dnsLookup(hostname, { family: 4 });
|
|
290
|
+
resolvedIP = result.address;
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return {
|
|
294
|
+
allowed: false,
|
|
295
|
+
reason: `access denied: DNS resolution failed for '${hostname}': ${err.message}`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const ipCheck = checkReservedIP(resolvedIP);
|
|
300
|
+
if (ipCheck.reserved) {
|
|
301
|
+
return {
|
|
302
|
+
allowed: false,
|
|
303
|
+
reason: `access denied: URL resolves to a reserved address — ${ipCheck.reason}`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { allowed: true };
|
|
308
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security/governor.js
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Runtime governor class — wraps LLM calls with spend limits,
|
|
5
|
+
* volume limits, a lifetime counter, and duplicate-prompt detection.
|
|
6
|
+
* All state is in-memory; no persistence across process restarts.
|
|
7
|
+
* Exports the class and defaults so each skill instantiates its own governor.
|
|
8
|
+
*
|
|
9
|
+
* Public Interface:
|
|
10
|
+
* governor
|
|
11
|
+
* ├── Governor — class; construct with optional config overrides
|
|
12
|
+
* │ ├── register(callerName, overrides) → void
|
|
13
|
+
* │ ├── wrap(callerName, callFn, opts) → Promise<any>
|
|
14
|
+
* │ ├── stats() → GovernorStats
|
|
15
|
+
* │ └── resetForTesting(cfg?) → void
|
|
16
|
+
* ├── GovernorError — Error subclass with .code property
|
|
17
|
+
* └── DEFAULTS — default config object (callers: {} — skills register own)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Default configuration — all values are overridable via skill config
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export const DEFAULTS = {
|
|
26
|
+
spendWarnThreshold: 5.00, // USD per window before warning
|
|
27
|
+
spendHardCapThreshold: 15.00, // USD per window before hard cap
|
|
28
|
+
spendWindowMinutes: 5, // rolling window duration
|
|
29
|
+
volumeGlobalCap: 200, // global call count per window
|
|
30
|
+
volumeWindowMinutes: 10, // rolling window duration for volume
|
|
31
|
+
lifetimeCap: 300, // absolute call count for this process
|
|
32
|
+
duplicateCacheTtlSeconds: 300, // TTL for duplicate-prompt cache entries
|
|
33
|
+
callers: {}, // empty — skills register their own callers
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// GovernorError — thrown by wrap() when a limit is exceeded.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
export class GovernorError extends Error {
|
|
40
|
+
constructor(message, code) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'GovernorError';
|
|
43
|
+
this.code = code;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Governor — in-memory class that tracks spend, volume, and prompt hashes.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
export class Governor {
|
|
51
|
+
#cfg;
|
|
52
|
+
|
|
53
|
+
// Spend tracking: array of { timestampMs, costUsd }
|
|
54
|
+
#spendWindow = [];
|
|
55
|
+
|
|
56
|
+
// Volume tracking: global window and per-caller windows
|
|
57
|
+
// Each entry: { timestampMs }
|
|
58
|
+
#globalVolumeWindow = [];
|
|
59
|
+
#callerVolumeWindows = new Map(); // callerName → [{ timestampMs }]
|
|
60
|
+
|
|
61
|
+
// Lifetime counter
|
|
62
|
+
#lifetimeCount = 0;
|
|
63
|
+
|
|
64
|
+
// Per-caller registered overrides (from register())
|
|
65
|
+
#callerOverrides = new Map();
|
|
66
|
+
|
|
67
|
+
// Duplicate-detection: promptHash → { response, expiresMs }
|
|
68
|
+
#promptCache = new Map();
|
|
69
|
+
|
|
70
|
+
// Per-caller aggregate stats (total calls, all-time)
|
|
71
|
+
#callerStats = new Map();
|
|
72
|
+
|
|
73
|
+
constructor(cfg = {}) {
|
|
74
|
+
this.#cfg = this.#mergeConfig(cfg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// -------------------------------------------------------------------------
|
|
78
|
+
// register(callerName, overrides)
|
|
79
|
+
// Registers a named caller with per-caller config overrides.
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
register(callerName, overrides = {}) {
|
|
82
|
+
const existing = this.#callerOverrides.get(callerName) ?? {};
|
|
83
|
+
this.#callerOverrides.set(callerName, { ...existing, ...overrides });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// wrap(callerName, callFn, opts) → Promise<any>
|
|
88
|
+
// Applies all governor checks before calling callFn.
|
|
89
|
+
//
|
|
90
|
+
// opts: {
|
|
91
|
+
// promptText?: string, // text to hash for duplicate detection
|
|
92
|
+
// estimatedCost?: number, // USD to debit against the spend window
|
|
93
|
+
// bypassCache?: boolean, // skip duplicate detection
|
|
94
|
+
// }
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
async wrap(callerName, callFn, opts = {}) {
|
|
97
|
+
this.#pruneWindows();
|
|
98
|
+
|
|
99
|
+
// 1. Lifetime cap
|
|
100
|
+
this.#checkLifetimeCap();
|
|
101
|
+
|
|
102
|
+
// 2. Spend hard cap
|
|
103
|
+
this.#checkSpendCap();
|
|
104
|
+
|
|
105
|
+
// 3. Volume cap — global then per-caller
|
|
106
|
+
this.#checkGlobalVolumeCap();
|
|
107
|
+
this.#checkCallerVolumeCap(callerName);
|
|
108
|
+
|
|
109
|
+
// 4. Duplicate detection
|
|
110
|
+
if (!opts.bypassCache && opts.promptText !== undefined) {
|
|
111
|
+
const cached = this.#getCached(opts.promptText);
|
|
112
|
+
if (cached !== undefined) {
|
|
113
|
+
return cached;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 5. Execute the call
|
|
118
|
+
const nowMs = Date.now();
|
|
119
|
+
const result = await callFn();
|
|
120
|
+
|
|
121
|
+
// 6. Record the call
|
|
122
|
+
this.#lifetimeCount += 1;
|
|
123
|
+
|
|
124
|
+
const cost = typeof opts.estimatedCost === 'number' ? opts.estimatedCost : 0;
|
|
125
|
+
this.#spendWindow.push({ timestampMs: nowMs, costUsd: cost });
|
|
126
|
+
this.#globalVolumeWindow.push({ timestampMs: nowMs });
|
|
127
|
+
this.#recordCallerVolume(callerName, nowMs);
|
|
128
|
+
this.#updateCallerStats(callerName);
|
|
129
|
+
|
|
130
|
+
// Warn on spend threshold
|
|
131
|
+
const windowSpend = this.#currentWindowSpend();
|
|
132
|
+
if (windowSpend > this.#cfg.spendWarnThreshold) {
|
|
133
|
+
console.warn(
|
|
134
|
+
`[governor] spend warning: $${windowSpend.toFixed(4)} in last ` +
|
|
135
|
+
`${this.#cfg.spendWindowMinutes} min (threshold $${this.#cfg.spendWarnThreshold})`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 7. Cache the response for duplicate detection
|
|
140
|
+
if (!opts.bypassCache && opts.promptText !== undefined) {
|
|
141
|
+
this.#cacheResponse(opts.promptText, result);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -------------------------------------------------------------------------
|
|
148
|
+
// stats() → GovernorStats
|
|
149
|
+
// Returns a snapshot of the current governor state.
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
stats() {
|
|
152
|
+
this.#pruneWindows();
|
|
153
|
+
|
|
154
|
+
const callers = {};
|
|
155
|
+
for (const [name, entry] of this.#callerStats.entries()) {
|
|
156
|
+
const windowEntries = this.#callerVolumeWindows.get(name) ?? [];
|
|
157
|
+
callers[name] = {
|
|
158
|
+
count: entry.count,
|
|
159
|
+
windowCount: windowEntries.length,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
windowSpendUsd: this.#currentWindowSpend(),
|
|
165
|
+
lifetimeCount: this.#lifetimeCount,
|
|
166
|
+
callers,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -------------------------------------------------------------------------
|
|
171
|
+
// resetForTesting() — resets all in-memory state.
|
|
172
|
+
// Not part of the production API; exposed for test isolation only.
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
resetForTesting(cfg = {}) {
|
|
175
|
+
this.#cfg = this.#mergeConfig(cfg);
|
|
176
|
+
this.#spendWindow = [];
|
|
177
|
+
this.#globalVolumeWindow = [];
|
|
178
|
+
this.#callerVolumeWindows.clear();
|
|
179
|
+
this.#callerOverrides.clear();
|
|
180
|
+
this.#promptCache.clear();
|
|
181
|
+
this.#callerStats.clear();
|
|
182
|
+
this.#lifetimeCount = 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// =========================================================================
|
|
186
|
+
// Private helpers
|
|
187
|
+
// =========================================================================
|
|
188
|
+
|
|
189
|
+
#mergeConfig(overrides) {
|
|
190
|
+
const govOverrides = overrides?.governor ?? overrides ?? {};
|
|
191
|
+
return {
|
|
192
|
+
spendWarnThreshold: govOverrides.spendWarnThreshold ?? DEFAULTS.spendWarnThreshold,
|
|
193
|
+
spendHardCapThreshold: govOverrides.spendHardCapThreshold ?? DEFAULTS.spendHardCapThreshold,
|
|
194
|
+
spendWindowMinutes: govOverrides.spendWindowMinutes ?? DEFAULTS.spendWindowMinutes,
|
|
195
|
+
volumeGlobalCap: govOverrides.volumeGlobalCap ?? DEFAULTS.volumeGlobalCap,
|
|
196
|
+
volumeWindowMinutes: govOverrides.volumeWindowMinutes ?? DEFAULTS.volumeWindowMinutes,
|
|
197
|
+
lifetimeCap: govOverrides.lifetimeCap ?? DEFAULTS.lifetimeCap,
|
|
198
|
+
duplicateCacheTtlSeconds: govOverrides.duplicateCacheTtlSeconds ?? DEFAULTS.duplicateCacheTtlSeconds,
|
|
199
|
+
callers: {
|
|
200
|
+
...DEFAULTS.callers,
|
|
201
|
+
...(govOverrides.callers ?? {}),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Remove expired entries from all sliding windows.
|
|
207
|
+
#pruneWindows() {
|
|
208
|
+
const nowMs = Date.now();
|
|
209
|
+
const spendCutoff = nowMs - this.#cfg.spendWindowMinutes * 60_000;
|
|
210
|
+
const volCutoff = nowMs - this.#cfg.volumeWindowMinutes * 60_000;
|
|
211
|
+
|
|
212
|
+
this.#spendWindow = this.#spendWindow.filter(e => e.timestampMs >= spendCutoff);
|
|
213
|
+
this.#globalVolumeWindow = this.#globalVolumeWindow.filter(e => e.timestampMs >= volCutoff);
|
|
214
|
+
|
|
215
|
+
for (const [name, window] of this.#callerVolumeWindows.entries()) {
|
|
216
|
+
this.#callerVolumeWindows.set(name, window.filter(e => e.timestampMs >= volCutoff));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Evict expired prompt cache entries.
|
|
220
|
+
for (const [hash, entry] of this.#promptCache.entries()) {
|
|
221
|
+
if (entry.expiresMs <= nowMs) {
|
|
222
|
+
this.#promptCache.delete(hash);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#currentWindowSpend() {
|
|
228
|
+
return this.#spendWindow.reduce((sum, e) => sum + e.costUsd, 0);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#checkLifetimeCap() {
|
|
232
|
+
if (this.#lifetimeCount >= this.#cfg.lifetimeCap) {
|
|
233
|
+
throw new GovernorError(
|
|
234
|
+
`[governor] lifetime cap reached (${this.#lifetimeCount}/${this.#cfg.lifetimeCap} calls)`,
|
|
235
|
+
'LIFETIME_CAP'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#checkSpendCap() {
|
|
241
|
+
const spend = this.#currentWindowSpend();
|
|
242
|
+
if (spend >= this.#cfg.spendHardCapThreshold) {
|
|
243
|
+
throw new GovernorError(
|
|
244
|
+
`[governor] spend hard cap reached ($${spend.toFixed(4)} >= ` +
|
|
245
|
+
`$${this.#cfg.spendHardCapThreshold} in ${this.#cfg.spendWindowMinutes} min)`,
|
|
246
|
+
'SPEND_CAP'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#checkGlobalVolumeCap() {
|
|
252
|
+
if (this.#globalVolumeWindow.length >= this.#cfg.volumeGlobalCap) {
|
|
253
|
+
throw new GovernorError(
|
|
254
|
+
`[governor] global volume cap reached (${this.#globalVolumeWindow.length}/` +
|
|
255
|
+
`${this.#cfg.volumeGlobalCap} calls in ${this.#cfg.volumeWindowMinutes} min)`,
|
|
256
|
+
'VOLUME_CAP'
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#checkCallerVolumeCap(callerName) {
|
|
262
|
+
const callerCfg = this.#resolveCallerConfig(callerName);
|
|
263
|
+
const callerWindow = this.#callerVolumeWindows.get(callerName) ?? [];
|
|
264
|
+
|
|
265
|
+
if (callerCfg.volumeCap !== undefined && callerWindow.length >= callerCfg.volumeCap) {
|
|
266
|
+
throw new GovernorError(
|
|
267
|
+
`[governor] volume cap for '${callerName}' reached ` +
|
|
268
|
+
`(${callerWindow.length}/${callerCfg.volumeCap} calls in ${this.#cfg.volumeWindowMinutes} min)`,
|
|
269
|
+
'VOLUME_CAP'
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Merge per-caller config from: DEFAULTS.callers → cfg.callers → registered overrides.
|
|
275
|
+
#resolveCallerConfig(callerName) {
|
|
276
|
+
const fromDefaults = DEFAULTS.callers[callerName] ?? {};
|
|
277
|
+
const fromCfgCallers = this.#cfg.callers[callerName] ?? {};
|
|
278
|
+
const fromOverrides = this.#callerOverrides.get(callerName) ?? {};
|
|
279
|
+
return { ...fromDefaults, ...fromCfgCallers, ...fromOverrides };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#recordCallerVolume(callerName, timestampMs) {
|
|
283
|
+
const window = this.#callerVolumeWindows.get(callerName) ?? [];
|
|
284
|
+
window.push({ timestampMs });
|
|
285
|
+
this.#callerVolumeWindows.set(callerName, window);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#updateCallerStats(callerName) {
|
|
289
|
+
const entry = this.#callerStats.get(callerName) ?? { count: 0 };
|
|
290
|
+
entry.count += 1;
|
|
291
|
+
this.#callerStats.set(callerName, entry);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// SHA-256 hash of the prompt string for cache key.
|
|
295
|
+
#hashPrompt(promptText) {
|
|
296
|
+
return createHash('sha256').update(promptText).digest('hex');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#getCached(promptText) {
|
|
300
|
+
const hash = this.#hashPrompt(promptText);
|
|
301
|
+
const entry = this.#promptCache.get(hash);
|
|
302
|
+
if (entry && entry.expiresMs > Date.now()) {
|
|
303
|
+
return entry.response;
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#cacheResponse(promptText, response) {
|
|
309
|
+
const hash = this.#hashPrompt(promptText);
|
|
310
|
+
const expiresMs = Date.now() + this.#cfg.duplicateCacheTtlSeconds * 1000;
|
|
311
|
+
this.#promptCache.set(hash, { response, expiresMs });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security/index.js
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: Barrel file — re-exports all security modules under a
|
|
5
|
+
* structured namespace for consumers that want a single require point.
|
|
6
|
+
*
|
|
7
|
+
* Public Interface:
|
|
8
|
+
* security
|
|
9
|
+
* ├── sanitize.text(text, opts?) → { cleaned, stats }
|
|
10
|
+
* ├── redact.secrets(text) → string
|
|
11
|
+
* ├── redact.pii(text, config) → string
|
|
12
|
+
* ├── redact.all(text, config) → string
|
|
13
|
+
* ├── validate.path(filePath, allowedDirs) → { allowed, reason? }
|
|
14
|
+
* ├── validate.url(url, resolver?) → Promise<{ allowed, reason? }>
|
|
15
|
+
* ├── validate.filename(name) → { allowed, reason? }
|
|
16
|
+
* ├── governor.create(opts?) → Governor instance
|
|
17
|
+
* ├── governor.GovernorError → GovernorError class
|
|
18
|
+
* └── governor.DEFAULTS → default config object
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { sanitize } from './sanitizer.js';
|
|
22
|
+
import { redactSecrets, redactPii, redactNotification } from './redactor.js';
|
|
23
|
+
import { validatePath, validateUrl, validateFilename } from './access-control.js';
|
|
24
|
+
import { Governor, GovernorError, DEFAULTS } from './governor.js';
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
sanitize: { text: sanitize },
|
|
28
|
+
redact: { secrets: redactSecrets, pii: redactPii, all: redactNotification },
|
|
29
|
+
validate: { path: validatePath, url: validateUrl, filename: validateFilename },
|
|
30
|
+
governor: { create: (opts) => new Governor(opts), GovernorError, DEFAULTS },
|
|
31
|
+
};
|