@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/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
+ };