@bouncesecurity/aghast 0.5.0 → 0.6.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.
Files changed (61) hide show
  1. package/README.md +8 -3
  2. package/config/pricing.json +42 -0
  3. package/config/prompts/false-positive-validation.md +1 -0
  4. package/config/prompts/general-vuln-discovery.md +1 -0
  5. package/config/prompts/generic-instructions.md +3 -2
  6. package/dist/budget.d.ts +62 -0
  7. package/dist/budget.d.ts.map +1 -0
  8. package/dist/budget.js +137 -0
  9. package/dist/budget.js.map +1 -0
  10. package/dist/check-library.d.ts +1 -0
  11. package/dist/check-library.d.ts.map +1 -1
  12. package/dist/check-library.js +22 -6
  13. package/dist/check-library.js.map +1 -1
  14. package/dist/claude-code-provider.d.ts +0 -2
  15. package/dist/claude-code-provider.d.ts.map +1 -1
  16. package/dist/claude-code-provider.js +66 -16
  17. package/dist/claude-code-provider.js.map +1 -1
  18. package/dist/cli.js +11 -1
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cost-calculator.d.ts +80 -0
  21. package/dist/cost-calculator.d.ts.map +1 -0
  22. package/dist/cost-calculator.js +226 -0
  23. package/dist/cost-calculator.js.map +1 -0
  24. package/dist/error-codes.d.ts +1 -0
  25. package/dist/error-codes.d.ts.map +1 -1
  26. package/dist/error-codes.js +2 -0
  27. package/dist/error-codes.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +172 -10
  30. package/dist/index.js.map +1 -1
  31. package/dist/logging.d.ts +1 -1
  32. package/dist/logging.d.ts.map +1 -1
  33. package/dist/logging.js +25 -33
  34. package/dist/logging.js.map +1 -1
  35. package/dist/mock-agent-provider.d.ts +6 -3
  36. package/dist/mock-agent-provider.d.ts.map +1 -1
  37. package/dist/mock-agent-provider.js +12 -5
  38. package/dist/mock-agent-provider.js.map +1 -1
  39. package/dist/opencode-provider.d.ts +0 -2
  40. package/dist/opencode-provider.d.ts.map +1 -1
  41. package/dist/opencode-provider.js +90 -17
  42. package/dist/opencode-provider.js.map +1 -1
  43. package/dist/runtime-config.d.ts.map +1 -1
  44. package/dist/runtime-config.js +92 -0
  45. package/dist/runtime-config.js.map +1 -1
  46. package/dist/scan-history.d.ts +82 -0
  47. package/dist/scan-history.d.ts.map +1 -0
  48. package/dist/scan-history.js +127 -0
  49. package/dist/scan-history.js.map +1 -0
  50. package/dist/scan-runner.d.ts +64 -1
  51. package/dist/scan-runner.d.ts.map +1 -1
  52. package/dist/scan-runner.js +173 -14
  53. package/dist/scan-runner.js.map +1 -1
  54. package/dist/stats.d.ts +11 -0
  55. package/dist/stats.d.ts.map +1 -0
  56. package/dist/stats.js +197 -0
  57. package/dist/stats.js.map +1 -0
  58. package/dist/types.d.ts +40 -1
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js.map +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Scan history: persisted record of completed scans for cost dashboards
3
+ * and budget controls.
4
+ *
5
+ * Storage: `~/.aghast/history.json` by default (one file per user). When the
6
+ * home directory cannot be resolved, falls back to project-local
7
+ * `.aghast-history.json`. The path can be overridden in tests via the
8
+ * `AGHAST_HISTORY_FILE` env var or by passing `historyFile` to the helpers.
9
+ *
10
+ * Format: a single JSON document `{ "records": [...] }`. We keep this simple
11
+ * (no SQLite) so the file is human-readable and can be edited / pruned by
12
+ * hand. Corrupt files are logged and rebuilt to avoid blocking scans on a
13
+ * malformed history file.
14
+ */
15
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
16
+ import { homedir } from 'node:os';
17
+ import { resolve, dirname } from 'node:path';
18
+ import { logProgress } from './logging.js';
19
+ const TAG = 'scan-history';
20
+ const DEFAULT_FILENAME = 'history.json';
21
+ const FALLBACK_FILENAME = '.aghast-history.json';
22
+ const SCHEMA_VERSION = 1;
23
+ /**
24
+ * Resolve the history file path.
25
+ *
26
+ * Precedence:
27
+ * 1. explicit `historyFile` argument
28
+ * 2. AGHAST_HISTORY_FILE env var
29
+ * 3. ~/.aghast/history.json when homedir is available
30
+ * 4. project-local `.aghast-history.json`
31
+ */
32
+ export function resolveHistoryFilePath(historyFile) {
33
+ if (historyFile)
34
+ return resolve(historyFile);
35
+ const envOverride = process.env.AGHAST_HISTORY_FILE;
36
+ if (envOverride && envOverride.length > 0)
37
+ return resolve(envOverride);
38
+ let home;
39
+ try {
40
+ home = homedir();
41
+ }
42
+ catch {
43
+ home = undefined;
44
+ }
45
+ if (home && home.length > 0) {
46
+ return resolve(home, '.aghast', DEFAULT_FILENAME);
47
+ }
48
+ return resolve(process.cwd(), FALLBACK_FILENAME);
49
+ }
50
+ async function readHistoryFile(path) {
51
+ let content;
52
+ try {
53
+ content = await readFile(path, 'utf-8');
54
+ }
55
+ catch (err) {
56
+ if (err.code === 'ENOENT') {
57
+ return { version: SCHEMA_VERSION, records: [] };
58
+ }
59
+ throw err;
60
+ }
61
+ try {
62
+ const parsed = JSON.parse(content);
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ throw new Error('not an object');
65
+ }
66
+ const obj = parsed;
67
+ const records = Array.isArray(obj.records) ? obj.records : [];
68
+ const version = typeof obj.version === 'number' ? obj.version : SCHEMA_VERSION;
69
+ return { version, records };
70
+ }
71
+ catch (err) {
72
+ // Corrupt history: log and rebuild, never block a scan.
73
+ logProgress(TAG, `History file at ${path} is corrupt (${err instanceof Error ? err.message : String(err)}); rebuilding.`);
74
+ return { version: SCHEMA_VERSION, records: [] };
75
+ }
76
+ }
77
+ async function writeHistoryFile(path, file) {
78
+ await mkdir(dirname(path), { recursive: true });
79
+ await writeFile(path, JSON.stringify(file, null, 2) + '\n', 'utf-8');
80
+ }
81
+ /**
82
+ * Append a record to the scan history, deduplicating by scanId.
83
+ * If a record with the same scanId already exists, it is replaced.
84
+ */
85
+ export async function saveScanRecord(record, options = {}) {
86
+ const path = resolveHistoryFilePath(options.historyFile);
87
+ const file = await readHistoryFile(path);
88
+ const existingIdx = file.records.findIndex((r) => r.scanId === record.scanId);
89
+ if (existingIdx >= 0) {
90
+ file.records[existingIdx] = record;
91
+ }
92
+ else {
93
+ file.records.push(record);
94
+ }
95
+ await writeHistoryFile(path, file);
96
+ }
97
+ /**
98
+ * Load all scan records, applying optional filters.
99
+ * Records are returned newest-first (descending startedAt).
100
+ */
101
+ export async function queryScanHistory(filters = {}, options = {}) {
102
+ const path = resolveHistoryFilePath(options.historyFile);
103
+ const file = await readHistoryFile(path);
104
+ let out = file.records.slice();
105
+ if (filters.repository) {
106
+ const needle = filters.repository;
107
+ out = out.filter((r) => r.repository === needle ||
108
+ r.repositoryUrl === needle ||
109
+ r.repository.includes(needle) ||
110
+ (r.repositoryUrl ?? '').includes(needle));
111
+ }
112
+ if (filters.model) {
113
+ const needle = filters.model;
114
+ out = out.filter((r) => r.models.some((m) => m.includes(needle)));
115
+ }
116
+ if (filters.since) {
117
+ const since = filters.since;
118
+ out = out.filter((r) => r.startedAt >= since);
119
+ }
120
+ if (filters.until) {
121
+ const until = filters.until;
122
+ out = out.filter((r) => r.startedAt <= until);
123
+ }
124
+ out.sort((a, b) => (a.startedAt < b.startedAt ? 1 : a.startedAt > b.startedAt ? -1 : 0));
125
+ return out;
126
+ }
127
+ //# sourceMappingURL=scan-history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scan-history.js","sourceRoot":"","sources":["../src/scan-history.ts"],"names":[],"mappings":"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"}
@@ -3,7 +3,19 @@
3
3
  * Runs security checks against a repository and produces ScanResults.
4
4
  * Implements the core workflow from spec Section 2.2.
5
5
  */
6
- import { type AgentProvider, type RepositoryInfo, type CheckDetails, type SecurityCheck, type ScanResults } from './types.js';
6
+ import { type AgentProvider, type RepositoryInfo, type CheckDetails, type SecurityCheck, type ScanResults, type TokenUsage } from './types.js';
7
+ import { type PricingConfig, type CostBreakdown } from './cost-calculator.js';
8
+ import { type BudgetLimits } from './budget.js';
9
+ import type { ScanRecord } from './scan-history.js';
10
+ /**
11
+ * Sum multiple TokenUsage values into one aggregate.
12
+ * Returns undefined if no inputs have token usage.
13
+ *
14
+ * reportedCost is aggregated only when every contributing call has it — a
15
+ * single missing cost means we cannot produce an accurate total, so we fall
16
+ * back to undefined (which triggers rate-based estimation later).
17
+ */
18
+ export declare function sumTokenUsage(usages: (TokenUsage | undefined)[]): TokenUsage | undefined;
7
19
  export interface MultiScanOptions {
8
20
  repositoryPath: string;
9
21
  checks: Array<{
@@ -17,13 +29,64 @@ export interface MultiScanOptions {
17
29
  repositoryInfo?: RepositoryInfo;
18
30
  configDir?: string;
19
31
  genericPrompt?: string;
32
+ /** Pricing config for cost calculations. */
33
+ pricing?: PricingConfig;
34
+ /** Optional budget limits enforced before each AI call. */
35
+ budgetLimits?: BudgetLimits;
36
+ /** Pre-loaded scan history (for period budget checks). */
37
+ scanHistory?: ScanRecord[];
38
+ /** true when AGHAST_LOCAL_CLAUDE=true — triggers budget warning if limits are also set */
39
+ isLocalClaude?: boolean;
40
+ }
41
+ /**
42
+ * Tracks accumulated tokens/cost across a scan so the budget can be evaluated
43
+ * before each AI call. Mutated in place by AI invocations.
44
+ */
45
+ export interface ScanCostTracker {
46
+ pricing?: PricingConfig;
47
+ budgetLimits?: BudgetLimits;
48
+ scanHistory?: ScanRecord[];
49
+ totalTokens: number;
50
+ totalCostUsd: number;
51
+ /** Cost source from the last recorded AI call. Used for banner labelling. */
52
+ lastCostSource?: CostBreakdown['source'];
53
+ lastCostReportedBy?: CostBreakdown['reportedBy'];
54
+ lastCostCoveredBySubscription?: boolean;
55
+ /** Set true after the first warn so we don't log it repeatedly. */
56
+ warned: boolean;
57
+ /** Most recent budget action returned to the runner. */
58
+ lastAction: 'continue' | 'warn' | 'abort';
59
+ /** Reason from the most recent non-continue check. */
60
+ lastReason?: string;
20
61
  }
21
62
  /**
22
63
  * Generate a scanId in the format: scan-<timestamp>-<hash>
23
64
  */
24
65
  export declare function generateScanId(): string;
66
+ /** Aggregated ScanResults plus computed cost summary. */
67
+ export interface MultiScanOutcome {
68
+ results: ScanResults;
69
+ totalCostUsd: number;
70
+ currency: string;
71
+ models: string[];
72
+ /** How the cost was determined (for banner/stats labelling). */
73
+ costSource?: CostBreakdown['source'];
74
+ /** Which provider reported the cost when costSource === 'reported'. */
75
+ costReportedBy?: CostBreakdown['reportedBy'];
76
+ /** true when AGHAST_LOCAL_CLAUDE=true — amount is API-equivalent, not billed */
77
+ costCoveredBySubscription?: boolean;
78
+ /** True when the scan was halted by a budget abort. */
79
+ budgetAborted?: boolean;
80
+ /** Reason from the budget abort, when budgetAborted is true. */
81
+ budgetAbortReason?: string;
82
+ }
25
83
  /**
26
84
  * Run multiple security checks and return aggregated ScanResults.
27
85
  */
28
86
  export declare function runMultiScan(options: MultiScanOptions): Promise<ScanResults>;
87
+ /**
88
+ * Same as runMultiScan but also returns the computed cost summary.
89
+ * Used by the CLI to record scan history.
90
+ */
91
+ export declare function runMultiScanWithCost(options: MultiScanOptions): Promise<MultiScanOutcome>;
29
92
  //# sourceMappingURL=scan-runner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scan-runner.d.ts","sourceRoot":"","sources":["../src/scan-runner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAkBH,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,cAAc,EAInB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,WAAW,EAGjB,MAAM,YAAY,CAAC;AAwFpB,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,aAAa,CAAC;QAAC,OAAO,EAAE,YAAY,CAAA;KAAE,CAAC,CAAC;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAKvC;AAylBD;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,WAAW,CAAC,CA6HlF"}
1
+ {"version":3,"file":"scan-runner.d.ts","sourceRoot":"","sources":["../src/scan-runner.ts"],"names":[],"mappings":"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"}
@@ -11,7 +11,7 @@ import { buildPrompt } from './prompt-template.js';
11
11
  import { parseAgentResponse } from './response-parser.js';
12
12
  import { extractSnippet } from './snippet-extractor.js';
13
13
  import { analyzeRepository } from './repository-analyzer.js';
14
- import { logProgress, logDebug, createTimer } from './logging.js';
14
+ import { logProgress, logDebug, logWarn, createTimer } from './logging.js';
15
15
  import { CHECK_TYPE } from './check-types.js';
16
16
  import { getDiscovery, registerDiscovery } from './discovery.js';
17
17
  import { DEFAULT_PROVIDER_NAME } from './provider-registry.js';
@@ -19,9 +19,14 @@ import { semgrepDiscovery } from './discoveries/semgrep-discovery.js';
19
19
  import { openantDiscovery } from './discoveries/openant-discovery.js';
20
20
  import { sarifDiscovery } from './discoveries/sarif-discovery.js';
21
21
  import { DEFAULT_MODEL, FatalProviderError, } from './types.js';
22
+ import { calculateCost } from './cost-calculator.js';
23
+ import { checkBudget, BudgetExceededError } from './budget.js';
22
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
25
  const TAG = 'scan';
24
26
  const DEFAULT_CONCURRENCY = 5;
27
+ // Per-target AI call timeout. Without this, a single hung provider request stalls
28
+ // the worker indefinitely; with concurrency=N hangs, the entire check freezes.
29
+ const DEFAULT_TARGET_TIMEOUT_MS = 5 * 60 * 1000;
25
30
  // --- Register built-in discovery implementations ---
26
31
  registerDiscovery(semgrepDiscovery);
27
32
  registerDiscovery(openantDiscovery);
@@ -29,15 +34,34 @@ registerDiscovery(sarifDiscovery);
29
34
  /**
30
35
  * Sum multiple TokenUsage values into one aggregate.
31
36
  * Returns undefined if no inputs have token usage.
37
+ *
38
+ * reportedCost is aggregated only when every contributing call has it — a
39
+ * single missing cost means we cannot produce an accurate total, so we fall
40
+ * back to undefined (which triggers rate-based estimation later).
32
41
  */
33
- function sumTokenUsage(usages) {
42
+ export function sumTokenUsage(usages) {
34
43
  const defined = usages.filter((u) => u !== undefined);
35
44
  if (defined.length === 0)
36
45
  return undefined;
46
+ // Optional fields: sum when at least one is present; preserve undefined when all absent.
47
+ const sumOptional = (getter) => {
48
+ const values = defined.map(getter).filter((v) => v !== undefined);
49
+ return values.length > 0 ? values.reduce((a, b) => a + b, 0) : undefined;
50
+ };
51
+ // reportedCost: only aggregate when every item has it (uniform provider, single source).
52
+ let reportedCost;
53
+ if (defined.every((u) => u.reportedCost !== undefined)) {
54
+ const total = defined.reduce((sum, u) => sum + u.reportedCost.amountUsd, 0);
55
+ reportedCost = { amountUsd: total, source: defined[0].reportedCost.source };
56
+ }
37
57
  return {
38
58
  inputTokens: defined.reduce((sum, u) => sum + u.inputTokens, 0),
39
59
  outputTokens: defined.reduce((sum, u) => sum + u.outputTokens, 0),
60
+ cacheCreationInputTokens: sumOptional((u) => u.cacheCreationInputTokens),
61
+ cacheReadInputTokens: sumOptional((u) => u.cacheReadInputTokens),
62
+ reasoningTokens: sumOptional((u) => u.reasoningTokens),
40
63
  totalTokens: defined.reduce((sum, u) => sum + u.totalTokens, 0),
64
+ ...(reportedCost !== undefined ? { reportedCost } : {}),
41
65
  };
42
66
  }
43
67
  /**
@@ -88,6 +112,54 @@ async function mapWithConcurrency(items, concurrency, fn, abortHandle) {
88
112
  }
89
113
  return results;
90
114
  }
115
+ function createCostTracker(options) {
116
+ return {
117
+ pricing: options.pricing,
118
+ budgetLimits: options.budgetLimits,
119
+ scanHistory: options.scanHistory,
120
+ totalTokens: 0,
121
+ totalCostUsd: 0,
122
+ warned: false,
123
+ lastAction: 'continue',
124
+ };
125
+ }
126
+ /**
127
+ * Record an AI call's token usage against the tracker. Called after each
128
+ * successful executeCheck().
129
+ */
130
+ function recordUsage(tracker, usage, model) {
131
+ if (!usage || !tracker.pricing)
132
+ return;
133
+ const cost = calculateCost(usage, model, tracker.pricing);
134
+ tracker.totalTokens += usage.totalTokens;
135
+ tracker.totalCostUsd += cost.totalCost;
136
+ tracker.lastCostSource = cost.source;
137
+ tracker.lastCostReportedBy = cost.reportedBy;
138
+ tracker.lastCostCoveredBySubscription = cost.coveredBySubscription;
139
+ }
140
+ /**
141
+ * Check the budget before an AI call. Logs a warning the first time the warn
142
+ * threshold is crossed; throws BudgetExceededError when the abort threshold is
143
+ * crossed.
144
+ */
145
+ function preflightBudget(tracker) {
146
+ if (!tracker.budgetLimits)
147
+ return;
148
+ const status = checkBudget({
149
+ currentScanCostUsd: tracker.totalCostUsd,
150
+ currentScanTokens: tracker.totalTokens,
151
+ history: tracker.scanHistory,
152
+ }, tracker.budgetLimits);
153
+ tracker.lastAction = status.action;
154
+ tracker.lastReason = status.reason;
155
+ if (status.action === 'abort') {
156
+ throw new BudgetExceededError(status.reason ?? 'Budget limit exceeded');
157
+ }
158
+ if (status.action === 'warn' && !tracker.warned) {
159
+ tracker.warned = true;
160
+ logProgress(TAG, `Budget warning: ${status.reason ?? 'approaching limit'}`);
161
+ }
162
+ }
91
163
  /**
92
164
  * Generate a scanId in the format: scan-<timestamp>-<hash>
93
165
  */
@@ -223,14 +295,14 @@ async function mapTargetToIssue(target, checkId, checkName, repositoryPath, chec
223
295
  * Execute a single check against a repository.
224
296
  * Routes to the appropriate execution path based on check type.
225
297
  */
226
- async function executeSingleCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, checkMetadata, concurrency, configDir, genericPrompt) {
298
+ async function executeSingleCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, costTracker, checkMetadata, concurrency, configDir, genericPrompt) {
227
299
  const checkId = check.id;
228
300
  // Route to targeted execution (discovery + AI analysis)
229
301
  if (check.checkTarget?.type === CHECK_TYPE.TARGETED) {
230
302
  if (!agentProvider) {
231
303
  throw new Error(`Check "${checkId}" requires an agent provider but none was configured`);
232
304
  }
233
- return executeTargetedCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, checkMetadata, concurrency, configDir, genericPrompt);
305
+ return executeTargetedCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, costTracker, checkMetadata, concurrency, configDir, genericPrompt);
234
306
  }
235
307
  // Route to static execution (discovery + direct mapping, no AI)
236
308
  if (check.checkTarget?.type === CHECK_TYPE.STATIC) {
@@ -247,7 +319,10 @@ async function executeSingleCheck(check, checkName, checkInstructions, repositor
247
319
  let summary;
248
320
  const checkTimer = createTimer();
249
321
  try {
322
+ preflightBudget(costTracker);
250
323
  const agentResponse = await agentProvider.executeCheck(prompt, repositoryPath);
324
+ const model = agentProvider.getModelName?.() ?? DEFAULT_MODEL;
325
+ recordUsage(costTracker, agentResponse.tokenUsage, model);
251
326
  const executionTime = checkTimer.elapsed();
252
327
  logDebug(TAG, `Agent response: ${agentResponse.raw.length} chars`);
253
328
  const parsed = agentResponse.parsed ?? parseAgentResponse(agentResponse.raw);
@@ -300,8 +375,8 @@ async function executeSingleCheck(check, checkName, checkInstructions, repositor
300
375
  }
301
376
  }
302
377
  catch (err) {
303
- // Fatal errors must propagate up to abort the entire scan
304
- if (err instanceof FatalProviderError) {
378
+ // Fatal errors and budget aborts must propagate up to stop the scan
379
+ if (err instanceof FatalProviderError || err instanceof BudgetExceededError) {
305
380
  throw err;
306
381
  }
307
382
  const executionTime = checkTimer.elapsed();
@@ -323,7 +398,7 @@ async function executeSingleCheck(check, checkName, checkInstructions, repositor
323
398
  * The execution pipeline is generic — all discovery-specific behavior is
324
399
  * encapsulated in the DiscoveredTarget data from the discovery implementation.
325
400
  */
326
- async function executeTargetedCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, checkMetadata, optionsConcurrency, configDir, genericPromptOverride) {
401
+ async function executeTargetedCheck(check, checkName, checkInstructions, repositoryPath, agentProvider, costTracker, checkMetadata, optionsConcurrency, configDir, genericPromptOverride) {
327
402
  const checkId = check.id;
328
403
  const checkTarget = check.checkTarget;
329
404
  const discoveryName = checkTarget.discovery;
@@ -387,9 +462,31 @@ async function executeTargetedCheck(check, checkName, checkInstructions, reposit
387
462
  targetResults = await mapWithConcurrency(targets, effectiveConcurrency, async (target, _idx) => {
388
463
  inProgressCount++;
389
464
  try {
465
+ try {
466
+ preflightBudget(costTracker);
467
+ }
468
+ catch (budgetErr) {
469
+ if (budgetErr instanceof BudgetExceededError) {
470
+ abortHandle.aborted = true;
471
+ abortHandle.reason = budgetErr;
472
+ throw budgetErr;
473
+ }
474
+ throw budgetErr;
475
+ }
390
476
  const prompt = basePrompt + (target.promptEnrichment ?? '');
391
477
  logDebug(TAG, `${target.label} Analyzing: ${target.file}:${target.startLine}-${target.endLine}`);
392
- const agentResponse = await agentProvider.executeCheck(prompt, repositoryPath, target.label, target.agentOptions);
478
+ let timeoutHandle;
479
+ const agentResponse = await Promise.race([
480
+ agentProvider.executeCheck(prompt, repositoryPath, target.label, target.agentOptions),
481
+ new Promise((_, reject) => {
482
+ timeoutHandle = setTimeout(() => reject(new Error(`Agent provider timed out after ${DEFAULT_TARGET_TIMEOUT_MS / 1000}s on target ${target.label}`)), DEFAULT_TARGET_TIMEOUT_MS);
483
+ }),
484
+ ]).finally(() => {
485
+ if (timeoutHandle)
486
+ clearTimeout(timeoutHandle);
487
+ });
488
+ const model = agentProvider.getModelName?.() ?? DEFAULT_MODEL;
489
+ recordUsage(costTracker, agentResponse.tokenUsage, model);
393
490
  const parsed = agentResponse.parsed ?? parseAgentResponse(agentResponse.raw);
394
491
  if (!parsed) {
395
492
  logDebug(TAG, `${target.label} Returned malformed response`);
@@ -409,8 +506,8 @@ async function executeTargetedCheck(check, checkName, checkInstructions, reposit
409
506
  return { issues, error: false, flagged: parsed.flagged === true, tokenUsage: agentResponse.tokenUsage };
410
507
  }
411
508
  catch (err) {
412
- // Fatal errors: signal abort and re-throw to stop other workers
413
- if (err instanceof FatalProviderError) {
509
+ // Fatal errors and budget aborts: signal abort and re-throw to stop other workers
510
+ if (err instanceof FatalProviderError || err instanceof BudgetExceededError) {
414
511
  abortHandle.aborted = true;
415
512
  abortHandle.reason = err;
416
513
  throw err;
@@ -472,8 +569,8 @@ async function executeTargetedCheck(check, checkName, checkInstructions, reposit
472
569
  };
473
570
  }
474
571
  catch (err) {
475
- // Fatal errors must propagate up to abort the entire scan
476
- if (err instanceof FatalProviderError) {
572
+ // Fatal errors and budget aborts must propagate up to stop the scan
573
+ if (err instanceof FatalProviderError || err instanceof BudgetExceededError) {
477
574
  throw err;
478
575
  }
479
576
  const executionTime = checkTimer.elapsed();
@@ -564,6 +661,14 @@ async function executeStaticCheck(check, checkName, repositoryPath, checkMetadat
564
661
  * Run multiple security checks and return aggregated ScanResults.
565
662
  */
566
663
  export async function runMultiScan(options) {
664
+ const outcome = await runMultiScanWithCost(options);
665
+ return outcome.results;
666
+ }
667
+ /**
668
+ * Same as runMultiScan but also returns the computed cost summary.
669
+ * Used by the CLI to record scan history.
670
+ */
671
+ export async function runMultiScanWithCost(options) {
567
672
  const { repositoryPath, checks, agentProvider, modelName, agentProviderName, concurrency, configDir, genericPrompt } = options;
568
673
  const scanTimer = createTimer();
569
674
  const scanId = generateScanId();
@@ -571,6 +676,9 @@ export async function runMultiScan(options) {
571
676
  const version = await getVersion();
572
677
  logProgress(TAG, `Starting scan ${scanId} (${checks.length} ${checks.length === 1 ? 'check' : 'checks'})`);
573
678
  logDebug(TAG, `Repository: ${repositoryPath}`);
679
+ if (options.isLocalClaude && options.budgetLimits) {
680
+ logWarn(TAG, 'Budget limits in local-Claude mode apply to equivalent API cost, not subscription usage.');
681
+ }
574
682
  // Use pre-analyzed repository info if provided, otherwise analyze here
575
683
  let repositoryInfo;
576
684
  if (options.repositoryInfo) {
@@ -582,6 +690,10 @@ export async function runMultiScan(options) {
582
690
  }
583
691
  const allCheckSummaries = [];
584
692
  const allIssues = [];
693
+ let budgetAborted = false;
694
+ let budgetAbortReason;
695
+ // Cost / budget tracking spans all checks in the scan.
696
+ const costTracker = createCostTracker(options);
585
697
  // Track all models used during the scan
586
698
  const modelsUsed = new Set();
587
699
  if (modelName)
@@ -598,11 +710,38 @@ export async function runMultiScan(options) {
598
710
  if (check.model)
599
711
  modelsUsed.add(check.model);
600
712
  try {
601
- const { summary: checkSummary, issues } = await executeSingleCheck(check, details.name, details.content, repositoryPath, agentProvider, checkMetadata, concurrency, configDir, genericPrompt);
713
+ const { summary: checkSummary, issues } = await executeSingleCheck(check, details.name, details.content, repositoryPath, agentProvider, costTracker, checkMetadata, concurrency, configDir, genericPrompt);
602
714
  allCheckSummaries.push(checkSummary);
603
715
  allIssues.push(...issues);
604
716
  }
605
717
  catch (err) {
718
+ if (err instanceof BudgetExceededError) {
719
+ budgetAborted = true;
720
+ budgetAbortReason = err.message;
721
+ logProgress(TAG, `Budget exceeded during check "${check.id}": ${err.message}`);
722
+ allCheckSummaries.push({
723
+ checkId: check.id,
724
+ checkName: details.name,
725
+ status: 'ERROR',
726
+ issuesFound: 0,
727
+ executionTime: 0,
728
+ error: `Budget exceeded: ${err.message}`,
729
+ });
730
+ for (let ri = ci + 1; ri < checks.length; ri++) {
731
+ const remaining = checks[ri];
732
+ logProgress(TAG, `Skipping check "${remaining.check.id}" due to budget abort`);
733
+ allCheckSummaries.push({
734
+ checkId: remaining.check.id,
735
+ checkName: remaining.details.name,
736
+ status: 'ERROR',
737
+ issuesFound: 0,
738
+ executionTime: 0,
739
+ error: `Scan aborted: budget limit exceeded`,
740
+ });
741
+ }
742
+ restoreModel(agentProvider, previousModel);
743
+ break;
744
+ }
606
745
  if (err instanceof FatalProviderError) {
607
746
  // Record the failing check as ERROR
608
747
  logProgress(TAG, `Fatal error during check "${check.id}": ${err.message}`);
@@ -668,6 +807,26 @@ export async function runMultiScan(options) {
668
807
  : { name: 'none', models: [] },
669
808
  tokenUsage: aggregateTokenUsage,
670
809
  };
671
- return results;
810
+ // Attach cost metadata when pricing was provided.
811
+ if (options.pricing) {
812
+ results.metadata = {
813
+ ...(results.metadata ?? {}),
814
+ cost: {
815
+ totalCostUsd: costTracker.totalCostUsd,
816
+ currency: options.pricing.currency ?? 'USD',
817
+ },
818
+ };
819
+ }
820
+ return {
821
+ results,
822
+ totalCostUsd: costTracker.totalCostUsd,
823
+ currency: options.pricing?.currency ?? 'USD',
824
+ models: [...modelsUsed],
825
+ costSource: costTracker.lastCostSource,
826
+ costReportedBy: costTracker.lastCostReportedBy,
827
+ costCoveredBySubscription: costTracker.lastCostCoveredBySubscription,
828
+ budgetAborted,
829
+ budgetAbortReason,
830
+ };
672
831
  }
673
832
  //# sourceMappingURL=scan-runner.js.map