cashclaw 1.6.2 → 1.7.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.
@@ -0,0 +1,280 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CashClaw Guard - CLI commands
5
+ * v1.7.0
6
+ *
7
+ * cashclaw guard init # write guard-policy.yaml
8
+ * cashclaw guard status # show active policy + recent events
9
+ * cashclaw guard test # dry-run 8 enforcement scenarios
10
+ * cashclaw guard kill <id> # signal kill for running agent
11
+ * cashclaw guard logs # print recent Guard events
12
+ * cashclaw guard reload # reload policy from disk
13
+ */
14
+
15
+ import { Command } from 'commander';
16
+ import chalk from 'chalk';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { loadConfig, saveConfig } from '../utils/config.js';
22
+ import { Policy } from '../../guard/policy.js';
23
+ import { llm as guardLlm, tool as guardTool, wrap as guardWrap, getRuntime, setRuntime, GuardRuntime } from '../../guard/decorator.js';
24
+ import { BudgetExceeded, RecursionKilled, ToolDenied, RateLimitExceeded } from '../../guard/exceptions.js';
25
+
26
+ const guard = { llm: guardLlm, tool: guardTool, wrap: guardWrap };
27
+
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const __dirname = path.dirname(__filename);
30
+
31
+ const TEMPLATE_PATH = path.resolve(__dirname, '../../../templates/guard-policy.yaml');
32
+ const HOME_POLICY_PATH = path.join(os.homedir(), '.cashclaw', 'guard-policy.yaml');
33
+
34
+ function header() {
35
+ console.log('');
36
+ console.log(chalk.bold.hex('#7B2CBF')(' CashClaw Guard') + chalk.dim(' v1.7.0'));
37
+ console.log(chalk.dim(' ──────────────────────────────'));
38
+ }
39
+
40
+ export function createGuardCommand() {
41
+ const cmd = new Command('guard')
42
+ .description('Agent runtime protection (cost cap, recursion kill, tool firewall)');
43
+
44
+ cmd
45
+ .command('init')
46
+ .description('Create guard-policy.yaml in ~/.cashclaw/')
47
+ .option('--force', 'Overwrite existing policy')
48
+ .option('--path <file>', 'Custom output path', HOME_POLICY_PATH)
49
+ .action(async (opts) => {
50
+ header();
51
+ const out = path.resolve(opts.path);
52
+ const dir = path.dirname(out);
53
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
54
+ if (fs.existsSync(out) && !opts.force) {
55
+ console.log(chalk.yellow(` ! Already exists: ${out}`));
56
+ console.log(chalk.dim(' Use --force to overwrite, or --path to write elsewhere.'));
57
+ return;
58
+ }
59
+ if (!fs.existsSync(TEMPLATE_PATH)) {
60
+ console.log(chalk.red(` ! Template missing: ${TEMPLATE_PATH}`));
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ fs.copyFileSync(TEMPLATE_PATH, out);
65
+ const cfg = (await loadConfig()) || {};
66
+ cfg.guard = { ...(cfg.guard || {}), policy_path: out, enabled: true };
67
+ await saveConfig(cfg);
68
+ console.log(chalk.green(` ✓ Policy written to ${out}`));
69
+ console.log(chalk.dim(' Edit the YAML and run `cashclaw guard reload`.'));
70
+ });
71
+
72
+ cmd
73
+ .command('status')
74
+ .description('Show active policy + recent Guard events')
75
+ .action(async () => {
76
+ header();
77
+ const cfg = await loadConfig();
78
+ const policyPath = cfg?.guard?.policy_path || HOME_POLICY_PATH;
79
+ let policy;
80
+ try {
81
+ policy = fs.existsSync(policyPath) ? Policy.fromFile(policyPath) : Policy.default();
82
+ } catch (err) {
83
+ console.log(chalk.red(` ! Failed to load policy: ${err.message}`));
84
+ return;
85
+ }
86
+
87
+ console.log(` Policy: ${policy.sourcePath || chalk.dim('built-in default')}`);
88
+ console.log(` Cost/day: ${chalk.cyan('$' + policy.limits.cost_usd_per_day)}`);
89
+ console.log(` Cost/call: ${chalk.cyan('$' + policy.limits.cost_usd_per_call)}`);
90
+ console.log(` Max tokens/call: ${chalk.cyan(policy.limits.max_tokens_per_call)}`);
91
+ console.log(` Max recursion: ${chalk.cyan(policy.limits.max_recursion_depth)}`);
92
+ console.log(` Tool denylist: ${chalk.yellow(policy.tools.denylist.join(', ') || 'none')}`);
93
+ console.log(` Allowlist: ${policy.tools.allowlist.length === 0 ? chalk.dim('(deny-by-denylist mode)') : chalk.green(policy.tools.allowlist.join(', '))}`);
94
+
95
+ const enabled = ['telegram', 'slack', 'discord', 'generic']
96
+ .filter(c => policy.webhook[c]?.enabled);
97
+ console.log(` Webhooks: ${enabled.length === 0 ? chalk.dim('none') : chalk.green(enabled.join(', '))}`);
98
+
99
+ const rt = getRuntime();
100
+ const events = rt.events.slice(-10);
101
+ console.log('');
102
+ console.log(chalk.bold(` Recent events (last ${events.length}):`));
103
+ if (events.length === 0) {
104
+ console.log(chalk.dim(' (none yet — Guard is quiet)'));
105
+ } else {
106
+ for (const ev of events) {
107
+ console.log(` [${chalk.dim(ev.at)}] ${chalk.red(ev.type)} ${ev.error?.message || ''}`);
108
+ }
109
+ }
110
+ });
111
+
112
+ cmd
113
+ .command('test')
114
+ .description('Dry-run 8 enforcement scenarios with mock LLM calls')
115
+ .action(async () => {
116
+ header();
117
+ const policy = Policy.default();
118
+ const rt = new GuardRuntime({ policy });
119
+ setRuntime(rt);
120
+
121
+ const scenarios = [
122
+ {
123
+ name: 'Per-call cost cap',
124
+ run: async () => {
125
+ const fn = guard.llm({ maxCostUsd: 0.01, maxTokens: 5_000_000, model: 'gpt-5.5', agentId: 'test-1' })(
126
+ async () => ({ usage: { prompt_tokens: 100_000, completion_tokens: 100_000 } })
127
+ );
128
+ await fn('hello world');
129
+ },
130
+ expect: BudgetExceeded,
131
+ },
132
+ {
133
+ name: 'Daily cost cap',
134
+ run: async () => {
135
+ // Daily cap = $1 baseline, single call cost ~$1.50 via gpt-5-mini
136
+ policy.data.limits.cost_usd_per_day = 1;
137
+ rt.reloadPolicy(policy);
138
+ const fn = guard.llm({ scope: 'daily-test', maxCostUsd: 1000, maxTokens: 5_000_000, model: 'gpt-5-mini' })(
139
+ async () => ({ usage: { prompt_tokens: 1_000_000, completion_tokens: 1_000_000 } })
140
+ );
141
+ await fn('hi');
142
+ },
143
+ expect: BudgetExceeded,
144
+ },
145
+ {
146
+ name: 'Recursion kill',
147
+ run: async () => {
148
+ const fn = guard.llm({ scope: 'rec-test', label: 'echo' })(async (p) => ({ usage: { prompt_tokens: 1, completion_tokens: 1 } }));
149
+ for (let i = 0; i < 10; i++) await fn('same-prompt-same-prompt');
150
+ },
151
+ expect: RecursionKilled,
152
+ },
153
+ {
154
+ name: 'Tool denylist',
155
+ run: async () => { guard.tool('shell', { agentId: 'test-tool', args: { command: 'ls' } }); },
156
+ expect: ToolDenied,
157
+ },
158
+ {
159
+ name: 'Tool allowlist miss',
160
+ run: async () => {
161
+ const p = Policy.default();
162
+ p.data.tools.allowlist = ['openai.chat.completions.create'];
163
+ const rt2 = new GuardRuntime({ policy: p });
164
+ guard.tool('weather.lookup', { runtime: rt2, agentId: 'test-aw' });
165
+ },
166
+ expect: ToolDenied,
167
+ },
168
+ {
169
+ name: 'Rate limit',
170
+ run: async () => {
171
+ const p = Policy.default();
172
+ p.data.tools.rate_limits = { 'slack.send': { max_per_minute: 2 } };
173
+ const rt2 = new GuardRuntime({ policy: p });
174
+ guard.tool('slack.send', { runtime: rt2, agentId: 't' });
175
+ guard.tool('slack.send', { runtime: rt2, agentId: 't' });
176
+ guard.tool('slack.send', { runtime: rt2, agentId: 't' });
177
+ },
178
+ expect: RateLimitExceeded,
179
+ },
180
+ {
181
+ name: 'YAML policy load',
182
+ run: async () => {
183
+ const p = Policy.fromYaml('limits:\n cost_usd_per_call: 0.001\n');
184
+ if (p.limits.cost_usd_per_call !== 0.001) throw new Error('YAML did not parse');
185
+ },
186
+ expect: null, // should succeed
187
+ },
188
+ {
189
+ name: 'Default pricing fallback',
190
+ run: async () => {
191
+ const fn = guard.llm({ scope: 'pricing-test', maxCostUsd: 100, model: 'unknown-model-xyz' })(
192
+ async () => ({ usage: { prompt_tokens: 1, completion_tokens: 1 }, model: 'unknown-model-xyz' })
193
+ );
194
+ const r = await fn('hi');
195
+ if (!r) throw new Error('no result');
196
+ },
197
+ expect: null,
198
+ },
199
+ ];
200
+
201
+ let pass = 0, fail = 0;
202
+ for (const s of scenarios) {
203
+ try {
204
+ await s.run();
205
+ if (s.expect) {
206
+ console.log(` ${chalk.red('✗')} ${s.name} ${chalk.dim('(expected ' + s.expect.name + ')')}`);
207
+ fail++;
208
+ } else {
209
+ console.log(` ${chalk.green('✓')} ${s.name}`);
210
+ pass++;
211
+ }
212
+ } catch (err) {
213
+ if (s.expect && err instanceof s.expect) {
214
+ console.log(` ${chalk.green('✓')} ${s.name} ${chalk.dim('(' + err.name + ')')}`);
215
+ pass++;
216
+ } else {
217
+ console.log(` ${chalk.red('✗')} ${s.name}: ${err.message}`);
218
+ fail++;
219
+ }
220
+ }
221
+ }
222
+
223
+ console.log('');
224
+ console.log(` ${chalk.bold('Summary:')} ${chalk.green(pass + ' pass')} / ${chalk.red(fail + ' fail')}`);
225
+ if (fail > 0) process.exitCode = 1;
226
+ });
227
+
228
+ cmd
229
+ .command('kill <agentId>')
230
+ .description('Emit a kill signal for a running agent (writes flag file)')
231
+ .action((agentId) => {
232
+ header();
233
+ const dir = path.join(os.homedir(), '.cashclaw', 'guard');
234
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
235
+ const flag = path.join(dir, `kill-${agentId}.flag`);
236
+ fs.writeFileSync(flag, JSON.stringify({ agentId, at: new Date().toISOString() }), 'utf-8');
237
+ console.log(chalk.yellow(` Kill flag written: ${flag}`));
238
+ console.log(chalk.dim(' Agents check this file at the start of each Guard step.'));
239
+ });
240
+
241
+ cmd
242
+ .command('logs')
243
+ .description('Print recent Guard events (in-process ring buffer)')
244
+ .option('--limit <n>', 'How many events to show', '50')
245
+ .action((opts) => {
246
+ header();
247
+ const rt = getRuntime();
248
+ const limit = parseInt(opts.limit, 10) || 50;
249
+ const events = rt.events.slice(-limit);
250
+ if (events.length === 0) {
251
+ console.log(chalk.dim(' (no events recorded in this process)'));
252
+ return;
253
+ }
254
+ for (const ev of events) {
255
+ console.log(` [${chalk.dim(ev.at)}] ${chalk.red(ev.type)} scope=${ev.scope || '-'} ${ev.error?.message || ''}`);
256
+ }
257
+ });
258
+
259
+ cmd
260
+ .command('reload')
261
+ .description('Reload the YAML policy from disk into the current runtime')
262
+ .action(async () => {
263
+ header();
264
+ const cfg = await loadConfig();
265
+ const policyPath = cfg?.guard?.policy_path || HOME_POLICY_PATH;
266
+ if (!fs.existsSync(policyPath)) {
267
+ console.log(chalk.red(` ! No policy at ${policyPath}. Run \`cashclaw guard init\` first.`));
268
+ process.exitCode = 1;
269
+ return;
270
+ }
271
+ const policy = Policy.fromFile(policyPath);
272
+ const rt = getRuntime();
273
+ rt.reloadPolicy(policy);
274
+ console.log(chalk.green(' ✓ Policy reloaded into runtime.'));
275
+ });
276
+
277
+ return cmd;
278
+ }
279
+
280
+ export default createGuardCommand;
package/src/cli/index.js CHANGED
@@ -10,6 +10,7 @@ import { listMissions, createMission, startMission, completeMission, cancelMissi
10
10
  import { getTotal, getMonthly, getWeekly, getToday, getHistory, getByService } from '../engine/earnings-tracker.js';
11
11
  import { listInstalledSkills, listAvailableSkills, installSkills } from '../integrations/openclaw-bridge.js';
12
12
  import { createHyrveCommand } from './commands/hyrve.js';
13
+ import { createGuardCommand } from './commands/guard.js';
13
14
  import Table from 'cli-table3';
14
15
  import fs from 'fs-extra';
15
16
  import path from 'path';
@@ -568,6 +569,9 @@ program
568
569
  // ─── cashclaw hyrve ───────────────────────────────────────────────────
569
570
  program.addCommand(createHyrveCommand());
570
571
 
572
+ // ─── cashclaw guard ───────────────────────────────────────────────────
573
+ program.addCommand(createGuardCommand());
574
+
571
575
  // ─── Default action (no command) ───────────────────────────────────────
572
576
  program.action(() => {
573
577
  showBanner();
@@ -13,7 +13,7 @@
13
13
  <header class="header">
14
14
  <div class="header-left">
15
15
  <span class="logo">CashClaw</span>
16
- <span class="version" id="appVersion">v1.6.1</span>
16
+ <span class="version" id="appVersion">v1.7.0</span>
17
17
  </div>
18
18
  <div class="header-right">
19
19
  <span class="agent-name" id="agentName">Loading...</span>
@@ -164,7 +164,7 @@
164
164
 
165
165
  <!-- Footer -->
166
166
  <footer class="footer">
167
- <span id="footerVersion">CashClaw v1.6.1</span>
167
+ <span id="footerVersion">CashClaw v1.7.0</span>
168
168
  <span class="footer-sep">|</span>
169
169
  <a href="https://cashclawai.com" target="_blank">cashclawai.com</a>
170
170
  <span class="footer-sep">|</span>
@@ -0,0 +1,138 @@
1
+ /**
2
+ * CashClaw Guard - Cost tracking
3
+ * v1.7.0
4
+ *
5
+ * Tracks token usage and converts to USD using a pricing table.
6
+ * Holds per-agent/per-scope counters in memory and persists them
7
+ * via the optional `persistFn` callback (typically writes to disk).
8
+ *
9
+ * Daily counters reset at UTC midnight.
10
+ */
11
+
12
+ import { BudgetExceeded, TokenLimitExceeded } from './exceptions.js';
13
+
14
+ // USD per 1M tokens. Prices reflect public 2026-05 list rates and are
15
+ // editable via policy.pricing override.
16
+ export const DEFAULT_PRICING = {
17
+ // OpenAI
18
+ 'gpt-5.5': { input: 5.00, output: 15.00 },
19
+ 'gpt-5.5-pro': { input: 30.00, output: 90.00 },
20
+ 'gpt-5': { input: 2.50, output: 10.00 },
21
+ 'gpt-5-mini': { input: 0.30, output: 1.20 },
22
+ 'gpt-5-nano': { input: 0.10, output: 0.40 },
23
+ 'gpt-4o': { input: 2.50, output: 10.00 },
24
+ 'gpt-4o-mini': { input: 0.15, output: 0.60 },
25
+ // Anthropic
26
+ 'claude-opus-4-7': { input: 15.00, output: 75.00 },
27
+ 'claude-opus-4-6': { input: 15.00, output: 75.00 },
28
+ 'claude-sonnet-4-6':{ input: 3.00, output: 15.00 },
29
+ 'claude-haiku-4-5': { input: 0.80, output: 4.00 },
30
+ // Google
31
+ 'gemini-3.1-pro': { input: 1.25, output: 5.00 },
32
+ 'gemini-3-flash': { input: 0.075, output: 0.30 },
33
+ // Moonshot Kimi
34
+ 'kimi-k2.6': { input: 0.60, output: 2.50 },
35
+ 'kimi-k2.5': { input: 0.60, output: 2.50 },
36
+ // Fallback
37
+ 'default': { input: 2.00, output: 8.00 },
38
+ };
39
+
40
+ function utcDayKey(date = new Date()) {
41
+ return date.toISOString().slice(0, 10); // YYYY-MM-DD
42
+ }
43
+
44
+ export class CostTracker {
45
+ constructor({ pricing = DEFAULT_PRICING, persistFn = null, now = () => new Date() } = {}) {
46
+ this.pricing = { ...DEFAULT_PRICING, ...pricing };
47
+ this.persistFn = persistFn;
48
+ this.now = now;
49
+ // counters[scope][day] = { usd, tokens, calls }
50
+ this.counters = {};
51
+ }
52
+
53
+ _bucket(scope) {
54
+ const day = utcDayKey(this.now());
55
+ if (!this.counters[scope]) this.counters[scope] = {};
56
+ if (!this.counters[scope][day]) {
57
+ this.counters[scope][day] = { usd: 0, tokens: 0, calls: 0 };
58
+ }
59
+ return this.counters[scope][day];
60
+ }
61
+
62
+ /**
63
+ * Estimate cost of a single LLM call.
64
+ * @param {object} usage { model, inputTokens, outputTokens }
65
+ * @returns {number} USD
66
+ */
67
+ estimate({ model = 'default', inputTokens = 0, outputTokens = 0 }) {
68
+ const price = this.pricing[model] || this.pricing.default;
69
+ const inUsd = (inputTokens / 1_000_000) * price.input;
70
+ const outUsd = (outputTokens / 1_000_000) * price.output;
71
+ return inUsd + outUsd;
72
+ }
73
+
74
+ /**
75
+ * Record an LLM call and enforce caps.
76
+ * Throws BudgetExceeded or TokenLimitExceeded when limits hit.
77
+ * @param {object} opts
78
+ * - scope: counter key (agentId, workflowId, "global", etc.)
79
+ * - usage: { model, inputTokens, outputTokens }
80
+ * - limits: { costUsdPerDay, costUsdPerCall, maxTokensPerCall }
81
+ * - agentId: for error reporting
82
+ * @returns {{ usd, tokens, scopeTotal }}
83
+ */
84
+ record({ scope, usage, limits = {}, agentId = scope }) {
85
+ const usd = this.estimate(usage);
86
+ const tokens = (usage.inputTokens || 0) + (usage.outputTokens || 0);
87
+
88
+ if (limits.maxTokensPerCall && tokens > limits.maxTokensPerCall) {
89
+ throw new TokenLimitExceeded({ tokens, limit: limits.maxTokensPerCall, agentId });
90
+ }
91
+ if (limits.costUsdPerCall && usd > limits.costUsdPerCall) {
92
+ throw new BudgetExceeded({
93
+ spent: usd.toFixed(4),
94
+ limit: limits.costUsdPerCall,
95
+ agentId,
96
+ scope: 'per-call',
97
+ });
98
+ }
99
+
100
+ const bucket = this._bucket(scope);
101
+ const projected = bucket.usd + usd;
102
+
103
+ if (limits.costUsdPerDay && projected > limits.costUsdPerDay) {
104
+ throw new BudgetExceeded({
105
+ spent: projected.toFixed(4),
106
+ limit: limits.costUsdPerDay,
107
+ agentId,
108
+ scope: `daily:${scope}`,
109
+ });
110
+ }
111
+
112
+ bucket.usd = projected;
113
+ bucket.tokens += tokens;
114
+ bucket.calls += 1;
115
+
116
+ if (this.persistFn) {
117
+ try { this.persistFn(this.counters); } catch { /* ignore persistence errors */ }
118
+ }
119
+
120
+ return { usd, tokens, scopeTotal: bucket };
121
+ }
122
+
123
+ /**
124
+ * Get current spend snapshot.
125
+ */
126
+ snapshot(scope) {
127
+ return this._bucket(scope);
128
+ }
129
+
130
+ /**
131
+ * Reset all counters (useful for tests).
132
+ */
133
+ reset() {
134
+ this.counters = {};
135
+ }
136
+ }
137
+
138
+ export default CostTracker;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * CashClaw Guard - SDK surface
3
+ * v1.7.0
4
+ *
5
+ * Higher-order wrappers used by application code:
6
+ * - guard.llm({ ... })(fn) → wraps a single LLM call
7
+ * - guard.tool(name, args) → tool firewall check
8
+ * - guard.wrap(fn, options) → one-shot inline wrap
9
+ *
10
+ * Each helper uses a shared Guard runtime instance so policy
11
+ * counters are aggregated across the whole process.
12
+ */
13
+
14
+ import { Policy } from './policy.js';
15
+ import { CostTracker } from './cost-tracker.js';
16
+ import { RecursionDetector } from './recursion-detector.js';
17
+ import { ToolFirewall } from './tool-firewall.js';
18
+ import { WebhookDispatcher } from './webhook.js';
19
+ import { GuardError } from './exceptions.js';
20
+
21
+ export class GuardRuntime {
22
+ constructor({ policy = Policy.default(), logger = null } = {}) {
23
+ this.policy = policy;
24
+ this.logger = logger || (() => {});
25
+ this.cost = new CostTracker({ pricing: policy.pricing });
26
+ this.recursion = new RecursionDetector({
27
+ fingerprintWindowSeconds: policy.recursion.fingerprint_window_seconds,
28
+ killAfterRepeats: policy.recursion.kill_after_repeats,
29
+ maxRecursionDepth: policy.limits.max_recursion_depth,
30
+ });
31
+ this.firewall = new ToolFirewall({
32
+ allowlist: policy.tools.allowlist,
33
+ denylist: policy.tools.denylist,
34
+ rateLimits: policy.tools.rate_limits,
35
+ });
36
+ this.webhook = new WebhookDispatcher({ config: policy.webhook, logger });
37
+ this.events = []; // recent events ring buffer
38
+ }
39
+
40
+ reloadPolicy(policy) {
41
+ this.policy = policy;
42
+ this.cost.pricing = { ...this.cost.pricing, ...(policy.pricing || {}) };
43
+ this.recursion = new RecursionDetector({
44
+ fingerprintWindowSeconds: policy.recursion.fingerprint_window_seconds,
45
+ killAfterRepeats: policy.recursion.kill_after_repeats,
46
+ maxRecursionDepth: policy.limits.max_recursion_depth,
47
+ });
48
+ this.firewall.update({
49
+ allowlist: policy.tools.allowlist,
50
+ denylist: policy.tools.denylist,
51
+ rateLimits: policy.tools.rate_limits,
52
+ });
53
+ this.webhook.update(policy.webhook);
54
+ }
55
+
56
+ _eventTypeFromError(err) {
57
+ if (err?.code === 'BUDGET_EXCEEDED') return 'budget_exceeded';
58
+ if (err?.code === 'TOKEN_LIMIT_EXCEEDED') return 'token_limit_exceeded';
59
+ if (err?.code === 'RECURSION_KILLED') return 'recursion_killed';
60
+ if (err?.code === 'TOOL_DENIED') return 'tool_denied';
61
+ if (err?.code === 'RATE_LIMIT_EXCEEDED') return 'rate_limit_exceeded';
62
+ return 'guard_error';
63
+ }
64
+
65
+ async _fireEvent(err, { scope, agentId } = {}) {
66
+ const event = {
67
+ type: this._eventTypeFromError(err),
68
+ error: err instanceof GuardError ? err.toJSON() : { message: err?.message, code: err?.code },
69
+ scope,
70
+ agentId,
71
+ metadata: {},
72
+ };
73
+ this.events.push({ ...event, at: new Date().toISOString() });
74
+ if (this.events.length > 500) this.events.shift();
75
+ try { await this.webhook.dispatch(event); }
76
+ catch (e) { this.logger({ level: 'warn', msg: 'webhook dispatch failed', err: e.message }); }
77
+ }
78
+ }
79
+
80
+ let _defaultRuntime = null;
81
+ export function getRuntime() {
82
+ if (!_defaultRuntime) _defaultRuntime = new GuardRuntime();
83
+ return _defaultRuntime;
84
+ }
85
+ export function setRuntime(rt) { _defaultRuntime = rt; }
86
+ export function resetRuntime() { _defaultRuntime = null; }
87
+
88
+ /**
89
+ * Wrap an LLM-calling function with cost/recursion enforcement.
90
+ *
91
+ * @param {object} opts
92
+ * - maxCostUsd hard per-call cost cap (overrides policy.cost_usd_per_call)
93
+ * - maxTokens hard per-call token cap
94
+ * - maxRecursion max times this signature can repeat in window
95
+ * - agentId scope/agent id for counters & alerts
96
+ * - scope counter scope (defaults to agentId)
97
+ * - model model id for cost estimation
98
+ * - label extra fingerprint label
99
+ * - usageOf function(result) → { inputTokens, outputTokens, model }
100
+ * - runtime explicit GuardRuntime (defaults to module singleton)
101
+ */
102
+ export function llm(opts = {}) {
103
+ const rt = opts.runtime || getRuntime();
104
+
105
+ return function decorate(fn) {
106
+ return async function guardedLlm(...args) {
107
+ const scope = opts.scope || opts.agentId || 'default';
108
+ const sig = {
109
+ model: opts.model,
110
+ label: opts.label || fn.name || 'llm',
111
+ prompt: args[0],
112
+ };
113
+
114
+ try {
115
+ rt.recursion.track(sig, opts.agentId || scope);
116
+ } catch (err) {
117
+ await rt._fireEvent(err, { scope, agentId: opts.agentId });
118
+ throw err;
119
+ }
120
+
121
+ const result = await fn(...args);
122
+
123
+ // Extract usage. The caller can provide a custom extractor; otherwise
124
+ // we assume the result already exposes `.usage` like OpenAI/Anthropic.
125
+ let usage = { model: opts.model || sig.model, inputTokens: 0, outputTokens: 0 };
126
+ if (typeof opts.usageOf === 'function') {
127
+ usage = { ...usage, ...(opts.usageOf(result) || {}) };
128
+ } else if (result && result.usage) {
129
+ usage.inputTokens = result.usage.prompt_tokens || result.usage.input_tokens || 0;
130
+ usage.outputTokens = result.usage.completion_tokens || result.usage.output_tokens || 0;
131
+ if (result.model) usage.model = result.model;
132
+ }
133
+
134
+ const limits = {
135
+ costUsdPerDay: opts.policy?.limits?.cost_usd_per_day || rt.policy.limits.cost_usd_per_day,
136
+ costUsdPerCall: opts.maxCostUsd ?? rt.policy.limits.cost_usd_per_call,
137
+ maxTokensPerCall: opts.maxTokens ?? rt.policy.limits.max_tokens_per_call,
138
+ };
139
+
140
+ try {
141
+ rt.cost.record({ scope, usage, limits, agentId: opts.agentId || scope });
142
+ } catch (err) {
143
+ await rt._fireEvent(err, { scope, agentId: opts.agentId });
144
+ throw err;
145
+ }
146
+
147
+ return result;
148
+ };
149
+ };
150
+ }
151
+
152
+ /**
153
+ * One-shot inline wrap: runs `fn`, records usage, throws if limits crossed.
154
+ * Returns the function result on success.
155
+ */
156
+ export async function wrap(fn, opts = {}) {
157
+ const wrapped = llm(opts)(fn);
158
+ return wrapped();
159
+ }
160
+
161
+ /**
162
+ * Check a tool invocation against the firewall.
163
+ * Throws ToolDenied or RateLimitExceeded.
164
+ */
165
+ export function tool(name, ctx = {}) {
166
+ const rt = ctx.runtime || getRuntime();
167
+ try {
168
+ return rt.firewall.check(name, ctx);
169
+ } catch (err) {
170
+ rt._fireEvent(err, { scope: ctx.scope, agentId: ctx.agentId });
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ export default { llm, wrap, tool, GuardRuntime, getRuntime, setRuntime, resetRuntime };