@clear-capabilities/agentic-security-scanner 0.142.0 → 0.144.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +399 -0
- package/bin/agentic-security.js +530 -54
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +108 -17
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +165 -52
- package/dist/526.index.js +108 -17
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +1 -1
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +109 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +19 -11
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +575 -288
- package/src/fix/apply-fix-service.js +403 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/mcp/tools.js +157 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +91 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +101 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/accuracy-scorecard.js +96 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +217 -0
- package/src/posture/auditor-walkthrough.js +70 -8
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +314 -17
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +113 -19
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +56 -7
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +90 -0
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/privacy-framework.js +33 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/state-dir.js +36 -1
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +83 -18
- package/src/report/oscal.js +635 -0
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
|
@@ -65,7 +65,7 @@ import * as fs from 'node:fs';
|
|
|
65
65
|
import * as path from 'node:path';
|
|
66
66
|
import * as crypto from 'node:crypto';
|
|
67
67
|
import { ensureStateDir, safeWriteState, statePath } from '../posture/state-dir.js';
|
|
68
|
-
import {
|
|
68
|
+
import { redactPayload } from '../egress/redact.js';
|
|
69
69
|
import { signLastScan } from '../posture/integrity.js';
|
|
70
70
|
|
|
71
71
|
// Bump on every prompt change so the cache invalidates. Exported as a
|
|
@@ -74,6 +74,9 @@ import { signLastScan } from '../posture/integrity.js';
|
|
|
74
74
|
import { createCostLedger, parseCapUsd, renderCostCeiling } from './cost-ceiling.js';
|
|
75
75
|
import { localEndpointConfig } from './local-endpoint.js';
|
|
76
76
|
import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
|
|
77
|
+
import { evaluateEgress } from '../egress/policy.js';
|
|
78
|
+
import { recordEgressCall, payloadMetrics } from '../egress/audit.js';
|
|
79
|
+
import { MODEL_STATUS, summarizeModelStatus } from './model-status.js';
|
|
77
80
|
|
|
78
81
|
// The output cap we request. Shared with the cost estimate so the ceiling
|
|
79
82
|
// charges exactly what we permit the model to produce.
|
|
@@ -295,7 +298,7 @@ export function sanitizeReasoning(s) {
|
|
|
295
298
|
.slice(0, 280);
|
|
296
299
|
}
|
|
297
300
|
|
|
298
|
-
function renderPrompt(finding, fileContents, challenge, nonce) {
|
|
301
|
+
function renderPrompt(finding, fileContents, challenge, nonce, scanRoot) {
|
|
299
302
|
const code = fileContents?.[finding.file];
|
|
300
303
|
let context = '';
|
|
301
304
|
if (code && finding.line) {
|
|
@@ -315,12 +318,14 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
|
|
|
315
318
|
let sterileSnippet = String(finding.snippet || '')
|
|
316
319
|
.replace(/[\r\n]+/g, ' ')
|
|
317
320
|
.slice(0, 400);
|
|
318
|
-
// R10 — redact
|
|
319
|
-
// connection-string passwords,
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
321
|
+
// R10 / FR-603 — redact secrets (API keys, tokens, private keys,
|
|
322
|
+
// connection-string passwords), PII/PHI/PCI/FIN-shaped fields, operator-
|
|
323
|
+
// configured customer-data patterns, and whole-file content from an
|
|
324
|
+
// operator-configured proprietary path out of BOTH excerpts before
|
|
325
|
+
// anything leaves the machine. This is the last choke point before the
|
|
326
|
+
// prompt is assembled — everything downstream sees only redacted text.
|
|
327
|
+
sterileContext = redactPayload({ text: sterileContext, filePath: finding.file, scanRoot }).text;
|
|
328
|
+
sterileSnippet = redactPayload({ text: sterileSnippet, filePath: finding.file, scanRoot }).text;
|
|
324
329
|
return PROMPT_TEMPLATE
|
|
325
330
|
.replace(/\{\{nonce\}\}/g, nonce)
|
|
326
331
|
.replace(/\{\{challenge\}\}/g, challenge)
|
|
@@ -440,8 +445,35 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
|
|
|
440
445
|
if (!cfg) {
|
|
441
446
|
finding.validator_verdict = 'unvalidated';
|
|
442
447
|
finding.unvalidated = true;
|
|
448
|
+
// FR-606: distinguish "the local preset refused a configured but
|
|
449
|
+
// non-loopback endpoint" (a policy decision) from "nothing was
|
|
450
|
+
// configured at all" (the tier is simply off) — endpointConfig()
|
|
451
|
+
// leaves _localPresetRefusal set only for the former.
|
|
452
|
+
finding.llmValidationStatus = _localPresetRefusal ? MODEL_STATUS.POLICY_BLOCKED : MODEL_STATUS.DISABLED;
|
|
443
453
|
return { verdict: 'unvalidated' };
|
|
444
454
|
}
|
|
455
|
+
// FR-601: egress policy evaluated before anything about this finding is
|
|
456
|
+
// rendered into a prompt (renderPrompt runs later, at line ~486) and before
|
|
457
|
+
// callEndpoint's fetch. A denial short-circuits here — no prompt is ever
|
|
458
|
+
// built for a denied endpoint, and the finding gets a distinct, honest
|
|
459
|
+
// error tag rather than being silently lumped in with "no answer received".
|
|
460
|
+
// FR-602: role/model are already resolved above (resolveProvider's own
|
|
461
|
+
// 'validate' role, cfg.model) — threading them through here is what
|
|
462
|
+
// makes the model/role constraint dimensions genuinely enforceable for a
|
|
463
|
+
// real caller, not just a mechanism nothing exercises.
|
|
464
|
+
const egressDecision = evaluateEgress({ scanRoot, purpose: 'llm-validator', endpoint: cfg.endpoint, role: 'validate', model: cfg.model });
|
|
465
|
+
if (!egressDecision.allowed) {
|
|
466
|
+
finding.validator_verdict = 'unvalidated';
|
|
467
|
+
finding.unvalidated = true;
|
|
468
|
+
finding._validatorError = 'egress-policy-denied';
|
|
469
|
+
finding._egressDecision = egressDecision;
|
|
470
|
+
finding.llmValidationStatus = MODEL_STATUS.POLICY_BLOCKED;
|
|
471
|
+
// FR-604: a denied call still gets an audit entry — purpose/provider/
|
|
472
|
+
// model/policy/outcome are all known without ever building a prompt,
|
|
473
|
+
// so byte/token/hash fields are simply absent (nothing was constructed).
|
|
474
|
+
recordEgressCall({ scanRoot, decision: egressDecision, ctx: { model: cfg.model, region: null } });
|
|
475
|
+
return { verdict: 'unvalidated', error: 'egress-policy-denied', egressDecision };
|
|
476
|
+
}
|
|
445
477
|
// Pre-flight: refuse to validate location-less findings. Without a precise
|
|
446
478
|
// file:line, the response cross-check degenerates and the validator can be
|
|
447
479
|
// spoofed by trivially-true echoes.
|
|
@@ -479,11 +511,20 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
|
|
|
479
511
|
// against any future write-path regression that might cache un-sanitized text).
|
|
480
512
|
finding.validator_reasoning = sanitizeReasoning(cached.reasoning);
|
|
481
513
|
finding._validatorCache = 'hit';
|
|
514
|
+
// FR-606: a cache hit means the model DID answer at some point — not
|
|
515
|
+
// re-asking is an optimization, not a different outcome.
|
|
516
|
+
finding.llmValidationStatus = MODEL_STATUS.COMPLETED;
|
|
482
517
|
return cached;
|
|
483
518
|
}
|
|
484
519
|
const challenge = crypto.randomBytes(8).toString('hex');
|
|
485
520
|
const nonce = crypto.randomBytes(8).toString('hex');
|
|
486
|
-
const prompt = renderPrompt(finding, fileContents, challenge, nonce);
|
|
521
|
+
const prompt = renderPrompt(finding, fileContents, challenge, nonce, scanRoot);
|
|
522
|
+
// FR-604: audit the allowed call now that the final (redacted) outbound
|
|
523
|
+
// payload is known — byte/token counts and a content hash, never the
|
|
524
|
+
// payload text itself. Recorded even if the call below fails or is
|
|
525
|
+
// blocked by the cost ceiling: the egress DECISION was 'allow' and a
|
|
526
|
+
// real payload was built, which is the fact this entry attests to.
|
|
527
|
+
recordEgressCall({ scanRoot, decision: egressDecision, ctx: { model: cfg.model, region: null }, metrics: payloadMetrics(prompt) });
|
|
487
528
|
|
|
488
529
|
// R12 — the hard ceiling. Checked BEFORE the call, against a conservative
|
|
489
530
|
// estimate: input from the prompt we are about to send, output at the full
|
|
@@ -503,6 +544,9 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
|
|
|
503
544
|
finding.validator_verdict = 'unvalidated';
|
|
504
545
|
finding.unvalidated = true;
|
|
505
546
|
finding.validator_skipped_reason = afford.reason;
|
|
547
|
+
// FR-606: a cost ceiling is an operator-configured policy denying the
|
|
548
|
+
// call, the same bucket as an egress denial — not a network failure.
|
|
549
|
+
finding.llmValidationStatus = MODEL_STATUS.POLICY_BLOCKED;
|
|
506
550
|
return { verdict: 'unvalidated', error: 'cost-ceiling' };
|
|
507
551
|
}
|
|
508
552
|
}
|
|
@@ -522,6 +566,11 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
|
|
|
522
566
|
finding.validator_verdict = 'unvalidated';
|
|
523
567
|
finding.unvalidated = true;
|
|
524
568
|
finding._validatorError = resp.error;
|
|
569
|
+
// FR-606: the call was attempted and failed before any usable response
|
|
570
|
+
// arrived (non-2xx HTTP, or a fetch/network exception in callEndpoint)
|
|
571
|
+
// — the endpoint is unavailable right now, distinct from a policy
|
|
572
|
+
// refusal or a malformed reply.
|
|
573
|
+
finding.llmValidationStatus = MODEL_STATUS.UNAVAILABLE;
|
|
525
574
|
return { verdict: 'unvalidated', error: resp.error };
|
|
526
575
|
}
|
|
527
576
|
const obj = parseLastJsonObject(resp.text);
|
|
@@ -533,6 +582,11 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
|
|
|
533
582
|
finding._validatorError = `verify-failed:${v.reason}`;
|
|
534
583
|
finding.llm_confidence = 0.5;
|
|
535
584
|
finding.validator_reasoning = sanitizeReasoning(`escalate (verify-failed:${v.reason})`);
|
|
585
|
+
// FR-606: a response DID come back, but its content could not be
|
|
586
|
+
// parsed/verified into a usable answer — the model answered something,
|
|
587
|
+
// just not something usable. Distinct from UNAVAILABLE (no response at
|
|
588
|
+
// all) and from a real verdict.
|
|
589
|
+
finding.llmValidationStatus = MODEL_STATUS.MALFORMED;
|
|
536
590
|
return { verdict: 'escalate', error: v.reason };
|
|
537
591
|
}
|
|
538
592
|
const parsed = v.parsed;
|
|
@@ -541,6 +595,7 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
|
|
|
541
595
|
finding.llm_confidence = parsed.confidence;
|
|
542
596
|
finding.validator_reasoning = parsed.reasoning;
|
|
543
597
|
finding._validatorCache = 'miss';
|
|
598
|
+
finding.llmValidationStatus = MODEL_STATUS.COMPLETED;
|
|
544
599
|
return parsed;
|
|
545
600
|
}
|
|
546
601
|
|
|
@@ -563,12 +618,19 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
563
618
|
const optOut = process.env.AGENTIC_SECURITY_LLM_VALIDATE === '0';
|
|
564
619
|
const enabled = !!cfg && !optOut;
|
|
565
620
|
if (!enabled) {
|
|
621
|
+
// FR-606: same distinction as validateOne's own !cfg branch — a
|
|
622
|
+
// configured-but-refused endpoint (local preset, non-loopback) is
|
|
623
|
+
// policy-blocked; nothing configured, OR an explicit operator opt-out
|
|
624
|
+
// (AGENTIC_SECURITY_LLM_VALIDATE=0), is the tier simply being off.
|
|
625
|
+
const status = _localPresetRefusal ? MODEL_STATUS.POLICY_BLOCKED : MODEL_STATUS.DISABLED;
|
|
566
626
|
for (const f of findings) {
|
|
567
627
|
f.validator_verdict = 'unvalidated';
|
|
568
628
|
f.unvalidated = true;
|
|
569
629
|
if (_localPresetRefusal) f.validator_skipped_reason = _localPresetRefusal;
|
|
630
|
+
f.llmValidationStatus = status;
|
|
570
631
|
}
|
|
571
632
|
if (_localPresetRefusal) findings.localPathRefusal = _localPresetRefusal;
|
|
633
|
+
findings.llmValidatorStatus = summarizeModelStatus(findings);
|
|
572
634
|
return findings;
|
|
573
635
|
}
|
|
574
636
|
const candidates = findings.filter(f =>
|
|
@@ -593,7 +655,11 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
593
655
|
f.validator_verdict = 'unvalidated';
|
|
594
656
|
f.unvalidated = true;
|
|
595
657
|
f.validator_skipped_reason = e.message;
|
|
658
|
+
// FR-606: a malformed operator config is a policy problem, same
|
|
659
|
+
// bucket as a valid-but-exceeded cost ceiling — not a network failure.
|
|
660
|
+
f.llmValidationStatus = MODEL_STATUS.POLICY_BLOCKED;
|
|
596
661
|
}
|
|
662
|
+
findings.llmValidatorStatus = summarizeModelStatus(findings);
|
|
597
663
|
return findings;
|
|
598
664
|
}
|
|
599
665
|
|
|
@@ -606,6 +672,11 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
606
672
|
// FAIL-CLOSED on exception too.
|
|
607
673
|
candidates[idx].validator_verdict = 'escalate';
|
|
608
674
|
candidates[idx]._validatorError = e.message;
|
|
675
|
+
// FR-606: an unexpected exception during validation (not a
|
|
676
|
+
// network-layer failure — those are already caught inside
|
|
677
|
+
// callEndpoint/validateOne) still means no usable answer was
|
|
678
|
+
// produced; closest of the five to what actually happened.
|
|
679
|
+
candidates[idx].llmValidationStatus = MODEL_STATUS.UNAVAILABLE;
|
|
609
680
|
}
|
|
610
681
|
}
|
|
611
682
|
}
|
|
@@ -635,7 +706,13 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
635
706
|
if (f.validator_verdict) continue;
|
|
636
707
|
f.validator_verdict = 'unvalidated';
|
|
637
708
|
f.unvalidated = true;
|
|
709
|
+
// FR-606 defensive fallback: every real branch above already sets this;
|
|
710
|
+
// reaching here means a candidate was filtered out (not a model-tier
|
|
711
|
+
// pass at all) — DO NOT stamp llmValidationStatus for those, the same
|
|
712
|
+
// "not applicable" convention validateOne itself uses for SCA locators
|
|
713
|
+
// and no-precise-location findings.
|
|
638
714
|
}
|
|
715
|
+
findings.llmValidatorStatus = summarizeModelStatus(findings);
|
|
639
716
|
return findings;
|
|
640
717
|
}
|
|
641
718
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// FR-606 (assurance-hardening PRD): the 5-state model-status vocabulary
|
|
2
|
+
// reports must distinguish, so an LLM-backed feature never converts "the
|
|
3
|
+
// model didn't answer" into an undifferentiated clean result.
|
|
4
|
+
//
|
|
5
|
+
// Before this module, every degrade path in llm-validator/index.js set the
|
|
6
|
+
// SAME finding.validator_verdict ('unvalidated'), with the actual reason
|
|
7
|
+
// scattered across an inconsistently-populated free-text field
|
|
8
|
+
// (_validatorError: 'egress-policy-denied' | 'cost-ceiling' | 'HTTP 500' |
|
|
9
|
+
// an arbitrary exception message | ...unset entirely for the "nothing
|
|
10
|
+
// configured" case). A reader — human or downstream tool — had no reliable
|
|
11
|
+
// way to answer "did the model tier even run, and if not, why" from that
|
|
12
|
+
// shape alone.
|
|
13
|
+
//
|
|
14
|
+
// Attached to each finding llm-validator touches as
|
|
15
|
+
// finding.llmValidationStatus, carrying EXACTLY one of the five values
|
|
16
|
+
// below (never a sixth ad-hoc string), and aggregated into a scan-level
|
|
17
|
+
// summary via summarizeModelStatus so a reader gets the headline without
|
|
18
|
+
// inspecting every finding.
|
|
19
|
+
//
|
|
20
|
+
// Deliberately NOT stamped on findings the validator skips for reasons
|
|
21
|
+
// that have nothing to do with MODEL availability (an SCA locator with no
|
|
22
|
+
// line number, a finding with no precise file:line at all) — those already
|
|
23
|
+
// have their own honest verdict ('not-applicable') and status; forcing
|
|
24
|
+
// them into this taxonomy would misrepresent a finding-suitability
|
|
25
|
+
// question as a model-availability one.
|
|
26
|
+
|
|
27
|
+
export const MODEL_STATUS = Object.freeze({
|
|
28
|
+
// No endpoint resolved at all — no API key, no BYO endpoint, nothing
|
|
29
|
+
// configured. The tier is off, not broken; nothing was attempted.
|
|
30
|
+
DISABLED: 'model-disabled',
|
|
31
|
+
// Something WAS configured, but a policy refused to use it before any
|
|
32
|
+
// network call was attempted — the egress policy's mode:deny/local-only/
|
|
33
|
+
// provider allow-deny lists, the local preset's own loopback-only
|
|
34
|
+
// refusal, or an internal cost-ceiling cap.
|
|
35
|
+
POLICY_BLOCKED: 'policy-blocked',
|
|
36
|
+
// The call was attempted and failed before a usable response arrived —
|
|
37
|
+
// a network error, a timeout, or a non-2xx HTTP status.
|
|
38
|
+
UNAVAILABLE: 'unavailable',
|
|
39
|
+
// A response DID come back, but its content could not be parsed into a
|
|
40
|
+
// usable answer, or failed the challenge/nonce cross-check — the model
|
|
41
|
+
// answered something, just not something usable.
|
|
42
|
+
MALFORMED: 'malformed',
|
|
43
|
+
// A real, validated verdict was produced (including a cache hit of one
|
|
44
|
+
// produced earlier — the model answered at some point; not re-asking is
|
|
45
|
+
// an optimization, not a different outcome).
|
|
46
|
+
COMPLETED: 'completed',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const ALL_STATUSES = Object.values(MODEL_STATUS);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Aggregate the per-finding llmValidationStatus values into scan-level
|
|
53
|
+
* counts. Findings with no llmValidationStatus at all (not applicable to
|
|
54
|
+
* model availability — see the module header) are counted separately and
|
|
55
|
+
* never silently folded into one of the five buckets.
|
|
56
|
+
*/
|
|
57
|
+
export function summarizeModelStatus(findings) {
|
|
58
|
+
const counts = Object.fromEntries(ALL_STATUSES.map(s => [s, 0]));
|
|
59
|
+
let notApplicable = 0;
|
|
60
|
+
for (const f of findings || []) {
|
|
61
|
+
const s = f?.llmValidationStatus;
|
|
62
|
+
if (s && Object.prototype.hasOwnProperty.call(counts, s)) counts[s]++;
|
|
63
|
+
else notApplicable++;
|
|
64
|
+
}
|
|
65
|
+
return { counts, notApplicable, total: (findings || []).length };
|
|
66
|
+
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -16,12 +16,16 @@ import * as fs from 'node:fs';
|
|
|
16
16
|
import * as fsp from 'node:fs/promises';
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import * as crypto from 'node:crypto';
|
|
19
|
-
import { applyFix as applyFixHistory, fixAcceptanceRate } from '../posture/fix-history.js';
|
|
19
|
+
import { applyFix as applyFixHistory, fixAcceptanceRate, revertEntryById as revertFixEntry } from '../posture/fix-history.js';
|
|
20
|
+
import { applyVerifiedFix } from '../fix/apply-fix-service.js';
|
|
21
|
+
import { classifyFixMaterialRisk } from '../posture/material-change.js';
|
|
22
|
+
import { loadApproverRegistry, verifyApprover, requiredRolesFor, checkSeparationOfDuties } from '../fix/approver-registry.js';
|
|
20
23
|
import { synthesizeDeterministicPatch } from '../posture/deterministic-fix.js';
|
|
21
24
|
import { verifyLastScan } from '../posture/integrity.js';
|
|
25
|
+
import { withStateWritesDisabled } from '../posture/state-dir.js';
|
|
22
26
|
import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
|
|
23
27
|
import { redactString, redactFinding } from './redact.js';
|
|
24
|
-
import { _remediationOf } from '../report/index.js';
|
|
28
|
+
import { _remediationOf, normalizeFindings } from '../report/index.js';
|
|
25
29
|
|
|
26
30
|
// Lazy-loaded: these transitively pull in npm packages (@babel/core and
|
|
27
31
|
// friends) that aren't available in the plugin-cache install path
|
|
@@ -387,26 +391,44 @@ export const scan_diff = {
|
|
|
387
391
|
// / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
|
|
388
392
|
// full-project deep scan).
|
|
389
393
|
const runScan = await getRunScan();
|
|
390
|
-
|
|
394
|
+
// FR-704 (assurance-hardening PRD): this tool's own description promises
|
|
395
|
+
// "runs scan in memory" — without this, runFullScan's own state writers
|
|
396
|
+
// (dpia.md, ropa.md, privacy-framework.json, threat-model.json, and
|
|
397
|
+
// others) fire unconditionally on every call, silently mutating the
|
|
398
|
+
// user's real project on every pre-write self-correction scan. Confirmed
|
|
399
|
+
// by direct execution before this fix (11 state artifacts written by a
|
|
400
|
+
// single scan_diff-shaped call).
|
|
401
|
+
const result = await withStateWritesDisabled(() =>
|
|
402
|
+
runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true }));
|
|
391
403
|
const wantSet = new Set(Object.keys(fileContents));
|
|
392
404
|
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
393
405
|
const min = sevRank[severity] ?? 0;
|
|
394
|
-
// Stage 6 correctness audit: this only
|
|
395
|
-
// (the SAST channel) — scan.secrets and
|
|
396
|
-
// arrays on the raw runScan() result
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// the
|
|
404
|
-
|
|
406
|
+
// Stage 6 correctness audit (historical): this used to only read
|
|
407
|
+
// result.scan.findings (the SAST channel) — scan.secrets and
|
|
408
|
+
// scan.logicVulns are separate arrays on the raw runScan() result, and a
|
|
409
|
+
// hand-rolled 3-channel concat here was a second, divergent copy of the
|
|
410
|
+
// merge report/index.js's normalizeFindings() already does (four
|
|
411
|
+
// channels, plus per-channel defaulting and remediation-string
|
|
412
|
+
// resolution the old concat re-implemented separately and could drift
|
|
413
|
+
// from). Assurance-hardening PRD FR-105 ("JSON, SARIF, HTML, CSV, JUnit,
|
|
414
|
+
// and MCP outputs derive from the same validated object"): route through
|
|
415
|
+
// the same canonical merge every other output format uses.
|
|
416
|
+
//
|
|
417
|
+
// This closes the field-mapping/dedup divergence, but does NOT make
|
|
418
|
+
// scan_diff surface SCA/supply-chain findings end to end: this handler
|
|
419
|
+
// never builds a `depFileContents` map (everything a caller passes in
|
|
420
|
+
// `files`, manifests included, lands in `fileContents`), and manifest-
|
|
421
|
+
// based supply-chain detection in engine.js reads only `depFileContents`
|
|
422
|
+
// — so `result.scan.supplyChain` is always empty for this tool today
|
|
423
|
+
// regardless of this fix. That is a separate, real limitation (scan_diff
|
|
424
|
+
// was designed for pre-write code self-correction, not manifest
|
|
425
|
+
// scanning), left as-is rather than silently claimed fixed here.
|
|
426
|
+
const findings = normalizeFindings(result.scan)
|
|
405
427
|
.filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
|
|
406
428
|
.map(f => redactFinding({
|
|
407
429
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
408
|
-
title: f.
|
|
409
|
-
description: f.description, remediation:
|
|
430
|
+
title: f.vuln, cwe: f.cwe,
|
|
431
|
+
description: f.description, remediation: f.remediation,
|
|
410
432
|
}));
|
|
411
433
|
// Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
|
|
412
434
|
// The agent gets a head+tail preview plus a path it can page through;
|
|
@@ -556,7 +578,7 @@ export const explain_finding = {
|
|
|
556
578
|
// ─── apply_fix ───────────────────────────────────────────────────────────────
|
|
557
579
|
export const apply_fix = {
|
|
558
580
|
name: 'apply_fix',
|
|
559
|
-
description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
|
|
581
|
+
description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
|
|
560
582
|
inputSchema: {
|
|
561
583
|
type: 'object',
|
|
562
584
|
additionalProperties: false,
|
|
@@ -597,6 +619,27 @@ export const apply_fix = {
|
|
|
597
619
|
partialSanitization: { type: 'boolean' },
|
|
598
620
|
},
|
|
599
621
|
},
|
|
622
|
+
// FR-307/FR-1002: this schema had `additionalProperties: false`
|
|
623
|
+
// and never declared `approval` — the property apply-fix-
|
|
624
|
+
// service.js's high-impact-change gate has required since FR-307
|
|
625
|
+
// was built. A real MCP caller supplying fixMeta.approval was
|
|
626
|
+
// rejected by validate.js at the schema layer before the handler
|
|
627
|
+
// ever ran, silently making the approval gate (and FR-1002's
|
|
628
|
+
// identity check layered on it) unreachable from this tool's
|
|
629
|
+
// only real production entry point. See D-0024.
|
|
630
|
+
approval: {
|
|
631
|
+
type: 'object',
|
|
632
|
+
additionalProperties: false,
|
|
633
|
+
properties: {
|
|
634
|
+
approvedBy: { type: 'string', minLength: 1, maxLength: 200 },
|
|
635
|
+
reason: { type: 'string', minLength: 1, maxLength: 1000 },
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
// FR-1003: separation-of-duties. Self-reported the same way
|
|
639
|
+
// approvedBy is — this tool has no way to determine who actually
|
|
640
|
+
// wrote a patch, so `author` is a claim, checked against a
|
|
641
|
+
// configurable policy the same way `approval` is.
|
|
642
|
+
author: { type: 'string', minLength: 1, maxLength: 200 },
|
|
600
643
|
},
|
|
601
644
|
},
|
|
602
645
|
},
|
|
@@ -671,20 +714,78 @@ export const apply_fix = {
|
|
|
671
714
|
verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
|
|
672
715
|
};
|
|
673
716
|
}
|
|
717
|
+
// FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
|
|
718
|
+
// applyFixHistory() directly and never called applyVerifiedFix() — so
|
|
719
|
+
// the high-impact-change approval gate (auth/authZ/crypto/PII/schema/
|
|
720
|
+
// infra-privilege/public-API) built for the OTHER apply_fix branch
|
|
721
|
+
// (stored fix.replacement) never ran here at all, for any input. Since
|
|
722
|
+
// this is the branch the tool's own description calls the one that
|
|
723
|
+
// covers "~100% of findings that ship only a template," that gap was
|
|
724
|
+
// the larger of the two found this cycle. Same before/after content
|
|
725
|
+
// shape `apply-fix-service.js` already uses — read-first-in-try/catch
|
|
726
|
+
// (D-0012), never existsSync-then-readFileSync.
|
|
727
|
+
const filesForMaterialClassification = {};
|
|
728
|
+
for (const [rel, v] of Object.entries(confinedAbs)) {
|
|
729
|
+
let before = '';
|
|
730
|
+
try { before = await fsp.readFile(v.abs, 'utf8'); } catch { /* new file — before stays '' */ }
|
|
731
|
+
filesForMaterialClassification[rel] = { before, after: v.content };
|
|
732
|
+
}
|
|
733
|
+
const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
|
|
674
734
|
if (dry_run) {
|
|
675
|
-
return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary };
|
|
735
|
+
return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
|
|
736
|
+
}
|
|
737
|
+
if (materialClassification.highImpactCategories.length) {
|
|
738
|
+
const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
|
|
739
|
+
const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
|
|
740
|
+
typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
|
|
741
|
+
typeof approval.reason === 'string' && approval.reason.trim().length > 0);
|
|
742
|
+
if (!hasApprovalEvidence) {
|
|
743
|
+
return {
|
|
744
|
+
_meta: META, applied: false,
|
|
745
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
|
|
746
|
+
materialClassification,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
|
|
750
|
+
const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
|
|
751
|
+
const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
|
|
752
|
+
if (!identityCheck.verified) {
|
|
753
|
+
return {
|
|
754
|
+
_meta: META, applied: false,
|
|
755
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
|
|
756
|
+
materialClassification,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
// FR-1003: separation-of-duties, same no-op-unless-configured gate
|
|
760
|
+
// as apply-fix-service.js's own copy — see approver-registry.js.
|
|
761
|
+
const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
|
|
762
|
+
if (!sodCheck.ok) {
|
|
763
|
+
return {
|
|
764
|
+
_meta: META, applied: false,
|
|
765
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
|
|
766
|
+
materialClassification,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
676
769
|
}
|
|
677
770
|
const written = [];
|
|
678
771
|
try {
|
|
679
772
|
for (const [rel, v] of Object.entries(confinedAbs)) {
|
|
680
|
-
const
|
|
773
|
+
const fileExisted = fs.existsSync(v.abs);
|
|
774
|
+
const originalContent = fileExisted ? await fsp.readFile(v.abs, 'utf8') : '';
|
|
681
775
|
const entry = await applyFixHistory({
|
|
682
|
-
scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
|
|
776
|
+
scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content, fileExisted,
|
|
683
777
|
findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
|
|
684
778
|
});
|
|
685
779
|
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
|
|
686
780
|
}
|
|
687
781
|
} catch (e) {
|
|
782
|
+
// FR-306: roll back every file THIS batch already wrote before the
|
|
783
|
+
// failure — applyFixHistory already restored the one file that just
|
|
784
|
+
// failed; this covers the rest, so a multi-file patch never leaves
|
|
785
|
+
// some files patched and others not.
|
|
786
|
+
for (const w of written) {
|
|
787
|
+
try { await revertFixEntry(ctx.sessionRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
|
|
788
|
+
}
|
|
688
789
|
if (e && e.name === 'FixAttemptBudgetExceededError') {
|
|
689
790
|
return { _meta: META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
|
|
690
791
|
}
|
|
@@ -692,7 +793,7 @@ export const apply_fix = {
|
|
|
692
793
|
}
|
|
693
794
|
let acceptance = null;
|
|
694
795
|
try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
|
|
695
|
-
return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance };
|
|
796
|
+
return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
|
|
696
797
|
}
|
|
697
798
|
|
|
698
799
|
if (typeof f.fix?.replacement !== 'string') {
|
|
@@ -729,41 +830,47 @@ export const apply_fix = {
|
|
|
729
830
|
};
|
|
730
831
|
}
|
|
731
832
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
maxAttempts: e.max,
|
|
757
|
-
key: e.key,
|
|
758
|
-
};
|
|
833
|
+
// FR-301/A-08 (assurance-hardening PRD): this branch used to write
|
|
834
|
+
// f.fix.replacement straight to disk with NO fresh verification — no
|
|
835
|
+
// rescan, no lint, nothing confirming the stored replacement actually
|
|
836
|
+
// closes the finding it claims to fix. The caller-patch branch above
|
|
837
|
+
// already required this; there is no reason a STORED fix should be
|
|
838
|
+
// trusted more than a caller-supplied one just because it shipped with
|
|
839
|
+
// the finding. Routed through the same applyVerifiedFix() service the
|
|
840
|
+
// CLI's `fix --apply` now also uses (src/fix/apply-fix-service.js) —
|
|
841
|
+
// confinement/reserved-path are re-checked there too (harmless
|
|
842
|
+
// redundancy with the dry_run preview above, kept for that preview's
|
|
843
|
+
// size-diff shape) but the load-bearing addition is the verification
|
|
844
|
+
// gate before the write.
|
|
845
|
+
if (!f.stableId) {
|
|
846
|
+
return { _meta: META, applied: false, reason: 'finding has no stableId — cannot verify a stored fix against it' };
|
|
847
|
+
}
|
|
848
|
+
const result = await applyVerifiedFix({
|
|
849
|
+
scanRoot: ctx.sessionRoot,
|
|
850
|
+
finding: f,
|
|
851
|
+
files: { [f.file]: f.fix.replacement },
|
|
852
|
+
fixMeta,
|
|
853
|
+
});
|
|
854
|
+
if (!result.ok) {
|
|
855
|
+
if (result.budgetExceeded) {
|
|
856
|
+
return { _meta: META, applied: false, reason: result.reason, budgetExceeded: true, attempts: result.attempts, maxAttempts: result.maxAttempts, key: result.key };
|
|
759
857
|
}
|
|
760
|
-
|
|
858
|
+
return { _meta: META, applied: false, reason: result.reason, verify: result.verify || null };
|
|
761
859
|
}
|
|
762
860
|
// R25 (PRD §5): surface the running auto-fix acceptance rate after each
|
|
763
861
|
// applied fix, so the closed loop reports its own success metric.
|
|
764
862
|
let acceptance = null;
|
|
765
863
|
try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* metric is best-effort */ }
|
|
766
|
-
|
|
864
|
+
const entry = result.written[0];
|
|
865
|
+
return {
|
|
866
|
+
// FR-305: verifiedFull distinguishes "every required leg (lint, tests)
|
|
867
|
+
// genuinely ran and passed" from "passed, but a required leg was
|
|
868
|
+
// skipped or unavailable" — verified:true alone conflates them.
|
|
869
|
+
_meta: META, applied: true, verified: true, verifiedFull: result.verifiedFull,
|
|
870
|
+
historyId: entry.historyId, file: entry.file, backupPath: entry.backupPath,
|
|
871
|
+
integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance,
|
|
872
|
+
verify: result.verify,
|
|
873
|
+
};
|
|
767
874
|
},
|
|
768
875
|
};
|
|
769
876
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Real, preemptive deadline enforcement via worker_threads (assurance-
|
|
2
|
+
// hardening PRD FR-202, D-0046 phase 1).
|
|
3
|
+
//
|
|
4
|
+
// FR-201's runDetector (detector-runner.js) catches a THROWN exception —
|
|
5
|
+
// it does nothing for a detector that hangs (infinite loop, catastrophic
|
|
6
|
+
// regex backtracking), because JavaScript is single-threaded: nothing
|
|
7
|
+
// running in the same event loop as a synchronous hang can preempt it. The
|
|
8
|
+
// existing _perFileTimeoutMs (engine.js) and AGENTIC_SECURITY_DEEP_TIMEOUT_MS
|
|
9
|
+
// (dataflow/engine.js) are both COOPERATIVE — they measure elapsed time
|
|
10
|
+
// after work already finished, or poll the clock between iterations of an
|
|
11
|
+
// already-bounded loop. Neither can stop a genuinely hung synchronous call.
|
|
12
|
+
//
|
|
13
|
+
// runWithDeadline is the one piece that can: it races a worker_thread
|
|
14
|
+
// against a real timer and calls worker.terminate() — an OS-level
|
|
15
|
+
// preemptive kill — if the timer wins. This is phase 1 of D-0046's 4-phase
|
|
16
|
+
// plan: a proven, isolated primitive, not yet wired into engine.js's real
|
|
17
|
+
// scan loop (phase 3). See D-0046 for the full plan and why per-file
|
|
18
|
+
// (not per-detector) granularity is the eventual wiring target.
|
|
19
|
+
|
|
20
|
+
import { Worker } from 'node:worker_threads';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const DEFAULT_WORKER_SCRIPT = path.join(HERE, 'analyzer-worker.js');
|
|
26
|
+
|
|
27
|
+
// PRD FR-202: "the configured deadline plus a documented grace period."
|
|
28
|
+
// The grace period exists so a worker that finishes essentially on time
|
|
29
|
+
// (message already in flight) isn't killed by scheduler jitter.
|
|
30
|
+
export const DEFAULT_GRACE_MS = 500;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run `exportName` from `modulePath`, called with `args`, inside a worker
|
|
34
|
+
* thread, with a real preemptive deadline.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} spec
|
|
37
|
+
* @param {string} spec.modulePath - absolute path to the module to import inside the worker.
|
|
38
|
+
* @param {string} spec.exportName - the named export to call.
|
|
39
|
+
* @param {Array} [spec.args] - plain, structured-clone-safe arguments.
|
|
40
|
+
* @param {object} [opts]
|
|
41
|
+
* @param {number} opts.timeoutMs - required. The configured deadline.
|
|
42
|
+
* @param {number} [opts.graceMs] - documented grace period beyond timeoutMs before terminating. Default DEFAULT_GRACE_MS.
|
|
43
|
+
* @param {string} [opts.workerScript] - override for testing.
|
|
44
|
+
* @returns {Promise<{ok:true, result:*} | {ok:false, timedOut?:true, error?:string}>}
|
|
45
|
+
*/
|
|
46
|
+
export function runWithDeadline(spec, opts = {}) {
|
|
47
|
+
const { modulePath, exportName, args = [] } = spec || {};
|
|
48
|
+
const timeoutMs = opts.timeoutMs;
|
|
49
|
+
const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
|
|
50
|
+
const workerScript = opts.workerScript || DEFAULT_WORKER_SCRIPT;
|
|
51
|
+
|
|
52
|
+
if (!modulePath || !exportName) {
|
|
53
|
+
return Promise.resolve({ ok: false, error: 'runWithDeadline requires spec.modulePath and spec.exportName' });
|
|
54
|
+
}
|
|
55
|
+
if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) {
|
|
56
|
+
return Promise.resolve({ ok: false, error: 'runWithDeadline requires opts.timeoutMs > 0' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let settled = false;
|
|
61
|
+
const worker = new Worker(workerScript, { workerData: { modulePath, exportName, args } });
|
|
62
|
+
|
|
63
|
+
const finish = (value) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(killTimer);
|
|
67
|
+
worker.terminate().catch(() => {});
|
|
68
|
+
resolve(value);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The one preemptive mechanism: a real OS-level Worker.terminate(),
|
|
72
|
+
// triggered by a real timer, not a cooperative clock-check inside the
|
|
73
|
+
// work itself. This is what makes this different from the existing
|
|
74
|
+
// _perFileTimeoutMs / deadlineMs mechanisms.
|
|
75
|
+
const killTimer = setTimeout(() => {
|
|
76
|
+
finish({ ok: false, timedOut: true, timeoutMs, graceMs });
|
|
77
|
+
}, timeoutMs + graceMs);
|
|
78
|
+
killTimer.unref?.();
|
|
79
|
+
|
|
80
|
+
worker.once('message', (msg) => {
|
|
81
|
+
if (msg && msg.ok) finish({ ok: true, result: msg.result });
|
|
82
|
+
else finish({ ok: false, error: (msg && msg.error) || 'unknown worker error' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
worker.once('error', (err) => {
|
|
86
|
+
finish({ ok: false, error: String((err && err.message) || err) });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
worker.once('exit', (code) => {
|
|
90
|
+
finish({ ok: false, error: `worker exited with code ${code} before reporting a result` });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|