@clear-capabilities/agentic-security-scanner 0.124.0 → 0.127.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 CHANGED
@@ -1,5 +1,164 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.127.0 — cost advisor: an actual choice, not just a tip
4
+
5
+ The model-cost advisor (`hooks/model-cost-advisor.js`) has always been advisory
6
+ only — a `systemMessage` tip you read and act on yourself, by design, because a
7
+ `UserPromptSubmit` hook cannot pause for interactive input or change the model/
8
+ effort (verified against the current Claude Code hooks reference before writing
9
+ any of this, not assumed from the hook's own pre-existing comments). This
10
+ release adds a real choice on top of that hard limit, without giving up the
11
+ zero-token guarantee for anyone who doesn't opt in.
12
+
13
+ - **New opt-in `"interactive": true`** (`.agentic-security/model-optimizer.json`,
14
+ default `false`). On a qualifying prompt, the hook now additionally emits
15
+ `hookSpecificOutput.additionalContext` — billed as input tokens, unlike the
16
+ free `systemMessage` — directing Claude to call `AskUserQuestion` with three
17
+ options: keep current defaults; get the exact `/model`/`/effort` command to
18
+ run yourself (Claude cannot switch its own running model — no exception
19
+ exists); or apply the cheaper model/effort to Claude's own delegated
20
+ Task/Agent-tool sub-agent dispatches for the rest of the session — the one
21
+ axis Claude can genuinely act on directly. Every other install keeps today's
22
+ zero-token behavior unchanged; only projects that explicitly opt in pay for
23
+ the interactive path, and only on prompts that already qualify for a tip.
24
+ - The chosen sub-agent override is **sticky for the session** — persisted to
25
+ `.agentic-security/model-optimizer-state.json` (extending the existing state
26
+ file rather than adding a new one) and cleared at the next `SessionStart`,
27
+ which already does a bare overwrite of that file. A cooldown
28
+ (`interactiveCooldownTurns`, default 3) stops the directive from re-firing
29
+ its token cost if Claude doesn't act on it (e.g. a non-interactive/scripted
30
+ invocation).
31
+ - Fixed a real bug the new shape would otherwise have hit: `dispatch-user-
32
+ prompt.js`'s `mergeOutputs` previously let the legacy-alias-redirect's
33
+ `additionalContext` and the advisor's new `additionalContext` clobber each
34
+ other if both fired on the same prompt (independent triggers, so this can
35
+ genuinely co-occur) — now joined instead of last-write-wins.
36
+ - `commands/setup.md`'s `--model-optimizer` gained a matching `--interactive`
37
+ flag; `CLAUDE.md` documents the durable instruction for applying a saved
38
+ sub-agent override on future dispatches, respecting existing static
39
+ `model:` frontmatter pins (`security-triager.md`, `sca-triager.md`) and the
40
+ "task needs more capability" carve-out.
41
+
42
+ Also added `SECURITY.md` (vulnerability disclosure policy).
43
+
44
+ ## 0.126.0 — closing the find→fix loop: verified auto-fix, CWE-434/306, Fable 5 pricing
45
+
46
+ The largest remediation-side release to date. Prior to this, findings could be
47
+ *found* at scale but not *fixed* at scale — of the 677 findings on this repo's own
48
+ scan, zero could be mechanically applied. This release closes that loop end to end
49
+ while adding the detection classes and workflow polish the closed loop needed.
50
+ Full detail and rationale: `docs/FIND_AND_FIX_LOOP_PRD.md` (implemented; the file
51
+ itself is removed post-implementation per its own instructions).
52
+
53
+ **Remediation — a verified patch path for every finding, not just the ones with a
54
+ stored replacement.**
55
+ - `apply_fix` (MCP) now accepts a caller-supplied `patch` (a files map) and
56
+ **re-verifies it inline** — rescan-clean, no new ≥medium finding, lint-clean —
57
+ before writing. Previously `apply_fix` refused any finding without a stored
58
+ `fix.replacement`; now a template-only or description-only finding (the vast
59
+ majority) can be fixed, because the bytes are proven safe at write time instead
60
+ of trusted at synthesis time.
61
+ - **Deterministic zero-LLM patch synthesis** (`posture/deterministic-fix.js`) for
62
+ safe, context-independent classes — weak hash (md5/sha1→sha256), TLS
63
+ verify-off→on — materialized on demand by `synthesize_fix` and still gated by
64
+ the same inline verifier before `apply_fix` writes it.
65
+ - Regression tests are wired into the fix loop: `synthesize_fix` surfaces the
66
+ scan's existing PoC-derived `regression_test` so a fix ships with a test that
67
+ fails pre-fix and passes post-fix.
68
+ - `/fix --all` and `/find-and-fix-everything` now run independent findings in
69
+ **parallel** (serializing only same-file findings), never halt on the first
70
+ test failure, and publish the running **auto-fix acceptance rate**.
71
+ - `/fix --sca` surfaces its **upgrade break-rate** (build/test-verified upgrades
72
+ are the default; the break-rate is how often an "available" upgrade actually
73
+ wasn't safe to take).
74
+ - MTTR / SLA tracking is now live: every scan stamps `firstSeenAt`/`ageDays` and
75
+ surfaces an SLA-breach summary (`critical` 7d / `high` 30d / …).
76
+ - The Layer-3 LLM validator gained a first-class Anthropic preset
77
+ (`AGENTIC_SECURITY_LLM_PRESET=anthropic`) — previously BYO-endpoint only, so the
78
+ FP-suppression layer was reachable with just a key instead of a hand-built
79
+ endpoint.
80
+
81
+ **Detection.**
82
+ - New **CWE-434 unrestricted file-upload detector** (JS/Python) — a whole CWE
83
+ class that had zero coverage: unguarded Multer configs and writes that use the
84
+ client-supplied filename as the destination.
85
+ - New **CWE-306 missing-authentication** rule: an unauthenticated destructive
86
+ route (DELETE, or id-taking PUT/PATCH) fires only when the app authenticates
87
+ elsewhere in the codebase — so auth-detection is proven to work before the rule
88
+ trusts a "no auth found" signal, keeping it high-precision even on all-public
89
+ files.
90
+ - Corrected stale documentation in `scanner/src/ir/CLAUDE.md`: the Python CST
91
+ parser's `match`-case bodies, walrus bindings, destructuring, and comprehension
92
+ filters were already fully lowered and taint-propagating — verified end-to-end
93
+ with new flow tests, not just parser-shape tests.
94
+ - The independent-eval gate (`bench/independent-eval/`) is now **active**
95
+ (`aggregateF1`/`perFamilyRecall` floors instead of `null`) — proven to fail on a
96
+ deliberate regression and pass at the current corpus result.
97
+
98
+ **Hooks & cost.**
99
+ - Pricing tables across all five rate-table copies now include **Fable 5
100
+ ($10/$50 per MTok)** and Sonnet 5 — previously a Fable 5 session wasn't even
101
+ priced by the cache-economics reporter, and the cost advisor couldn't reason
102
+ about the flagship model at all.
103
+ - Two mechanical subagents (`security-triager`, `sca-triager`) pinned to Haiku.
104
+ - The two `UserPromptSubmit` hooks and three `PreToolUse` (Edit) hooks were each
105
+ consolidated into a single dispatcher process — halving/thirding the per-turn
106
+ node spawns. The security-critical bodyguard block is proven to survive
107
+ consolidation (a dedicated regression test asserts the exit-2 deny still
108
+ fires first and short-circuits the advisory hooks).
109
+ - The post-edit hook now offers a one-tap auto-fix inline when a fresh finding is
110
+ mechanically fixable, instead of only pointing at `/fix-all`.
111
+ - The model-cost optimizer now ships **on by default** (`mode: "advise"`) with a
112
+ predicted-vs-realized savings ledger.
113
+
114
+ **Workflow.**
115
+ - `scan --watch` wires the existing (previously unwired) watch-mode daemon:
116
+ continuous incremental re-scan on file change with a risk-delta status line.
117
+ - Opt-in, offline-degrading **live-secret validation**
118
+ (`--validate-secrets`) — labels a detected secret `live`/`dead`/`unknown` via a
119
+ read-only provider "whoami" check (GitHub, Stripe, OpenAI, SendGrid), so "this
120
+ key is LIVE" is distinguished from "you have a high-entropy string."
121
+ `--secret-history` (git-history sweep) was already wired; this release adds
122
+ the liveness half.
123
+ - Diff-scoped scans (`--pr` / `--changed-since`) now default `--incremental` on,
124
+ since a changed-file set is exactly the incremental cache's designed case.
125
+
126
+ ## 0.125.0 — multi-provider LLM cost + prompt-cache linter (Cache Economics v2, phase 1)
127
+
128
+ First slice of the Cache Economics v2 PRD (`docs/CACHE_ECONOMICS_V2_PRD.md`) — the
129
+ foundation (P1 provider detection, P2 provider catalog) + the flagship analyzer (F1
130
+ cache-hygiene + P3 per-provider model/depth recommendation). The optimizer now reaches
131
+ beyond this Claude Code session into the user's **own AI-app code**, across providers.
132
+
133
+ - **Provider catalog** (`scanner/src/posture/provider-catalog.js`): a dated, no-network
134
+ snapshot of Anthropic / OpenAI / Google Gemini / xAI — model ladder ($/1M rates), the
135
+ "depth" knob (effort / reasoning_effort / thinkingBudget), and the cache model
136
+ (explicit vs automatic vs implicit). Prices are sourced, not hardcoded-in-logic, with a
137
+ `SOURCED_AT` staleness date.
138
+ - **LLM cost/cache detector** (`scanner/src/sast/llm-cost-advisor.js`, `scanLlmCost`):
139
+ provider-aware, gated on detecting an LLM SDK (low FP), emits **advisory** findings —
140
+ (1) *prompt-cache killer*: a timestamp/UUID/random value baked into a prompt prefix
141
+ that defeats caching; (2) *over-provisioned*: a flagship model at high depth, with the
142
+ catalog's cheaper model + lower depth **in that provider's framework** as the fix
143
+ (e.g. an OpenAI app → "gpt-5.4 at reasoning_effort=low"). Severity `low`/`info` so it
144
+ never inflates security counts.
145
+
146
+ Remaining PRD phases (F2 measured cost, F3 TTL, F4 compaction, F5 pre-warm, F6 cross-
147
+ session warmth, F7 self-tuning; full P4 per-provider economics) ship next.
148
+
149
+ ## 0.124.1 — fix: narration no longer prints a broken location
150
+
151
+ Now that v0.124.0 surfaces `narration` prominently inline, a pre-existing template
152
+ bug became visible: some narrations read `app.js:?` / `app.js:undefined` because the
153
+ location was interpolated before the finding's line was finalised.
154
+
155
+ - `scanner/src/posture/flow-narration.js`: the location is redundant (the finding
156
+ header already shows `file:line`), so the family templates are now
157
+ location-agnostic, and `_routeOf()` (generic fallback + LLM prompt) falls back to
158
+ the file alone — or "this endpoint" — instead of emitting `:undefined` / `:?`.
159
+ - Regression test in `test/flow-narration.test.js` asserts no template renders a
160
+ broken location when the line is absent.
161
+
3
162
  ## 0.124.0 — inline finding depth (no second command for "why it fired")
4
163
 
5
164
  The depth `/triage --explain` produces now renders **inline** in the finding views,
@@ -113,6 +113,7 @@ Options:
113
113
  --since-baseline Only show findings NOT in the saved baseline
114
114
  --hide-proven-safe Drop findings discharged by a flow proof (provably safe)
115
115
  --secret-history Also sweep recent git history for committed secrets
116
+ --validate-secrets Label each detected secret live/dead/unknown (opt-in, network, offline-degrading)
116
117
  (removed from HEAD but recoverable from .git)
117
118
  --history-depth <n> Commits to sweep with --secret-history (default 50)
118
119
  --no-epss Skip EPSS exploit-prediction enrichment (default: enabled)
@@ -331,8 +332,40 @@ async function cmdScan(args) {
331
332
  }
332
333
  }
333
334
 
335
+ // #21 — --watch : continuous incremental re-scan on file change. Each change
336
+ // re-scans (incrementally) and writes a risk-delta to
337
+ // .agentic-security/watch-status.md that a statusline / /posture poll surfaces
338
+ // inline — so "did my edit add risk?" is answered without a manual re-scan.
339
+ // Blocks until Ctrl-C, like `jest --watch`. Opt-in, so it never affects a
340
+ // normal one-shot scan.
341
+ if (args.flags['watch']) {
342
+ process.env.AGENTIC_SECURITY_INCREMENTAL = '1';
343
+ const { watchProject, computeDelta, persistStatus, renderStatusLine } = await import('../src/posture/watch-mode.js');
344
+ process.stderr.write(`[watch] scanning ${targetAbs} on change — Ctrl-C to stop. Status → .agentic-security/watch-status.md\n`);
345
+ const seed = await runScan(targetAbs, {});
346
+ let prevFindings = seed.scan.findings || [];
347
+ await watchProject(targetAbs, async () => {
348
+ try {
349
+ const { scan } = await runScan(targetAbs, {});
350
+ const curr = scan.findings || [];
351
+ const delta = computeDelta(prevFindings, curr);
352
+ persistStatus(targetAbs, delta);
353
+ process.stderr.write('[watch] ' + renderStatusLine(delta) + '\n');
354
+ prevFindings = curr;
355
+ } catch (e) {
356
+ process.stderr.write(`[watch] rescan failed: ${e.message}\n`);
357
+ }
358
+ });
359
+ return 0; // watchProject blocks until aborted
360
+ }
361
+
334
362
  // --incremental : reuse taint summaries from prior scans for faster deep mode.
335
- if (args.flags['incremental'] || process.env.AGENTIC_SECURITY_INCREMENTAL === '1') {
363
+ // #23 — default incremental ON for diff-scoped scans (--pr / --changed-since):
364
+ // that's the cache's designed use (small changed set → its callers), so the
365
+ // PR-native path is fast by default. A full-tree scan stays non-incremental
366
+ // unless explicitly requested (blanket-default flip needs broader validation).
367
+ const _diffScoped = !!(args.flags['pr'] || args.flags['changed-since']);
368
+ if (args.flags['incremental'] || process.env.AGENTIC_SECURITY_INCREMENTAL === '1' || _diffScoped) {
336
369
  process.env.AGENTIC_SECURITY_INCREMENTAL = '1';
337
370
  }
338
371
 
@@ -566,6 +599,46 @@ async function cmdScan(args) {
566
599
  if (_isSafeStateDir(stateDir)) {
567
600
  await fsp.mkdir(stateDir, { recursive: true });
568
601
  const persistedScan = toJSON(scan, meta);
602
+ // #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
603
+ // every finding carries an age, SLA breaches can be surfaced, and the fix
604
+ // loop can report time-to-clean. Best-effort; skipped under --deterministic
605
+ // so deterministic state stays byte-identical run-to-run.
606
+ if (!args.flags.deterministic) {
607
+ try {
608
+ const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary } = await import('../src/posture/mttr.js');
609
+ let baselineMap = new Map();
610
+ try {
611
+ const prev = JSON.parse(await fsp.readFile(path.join(stateDir, 'last-scan.json'), 'utf8'));
612
+ baselineMap = buildBaselineMap(prev);
613
+ } catch { /* first run — empty baseline, everything is firstSeen now */ }
614
+ const now = Date.now();
615
+ stampFindingTimestamps(persistedScan.findings || [], baselineMap, now);
616
+ stampFindingTimestamps(persistedScan.secrets || [], baselineMap, now);
617
+ stampFindingTimestamps((persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'), baselineMap, now);
618
+ // Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
619
+ const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
620
+ if (!isJson) {
621
+ const sla = renderSlaSummary(persistedScan.findings || []);
622
+ if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
623
+ }
624
+ } catch { /* MTTR is best-effort — never block a scan write */ }
625
+ }
626
+ // #22 — live-secret validation (opt-in, offline-degrading). Label each
627
+ // detected secret live | dead | unknown via a read-only provider "whoami".
628
+ // "This key is LIVE and was committed N commits ago" is the P0 that matters.
629
+ if (args.flags['validate-secrets'] || process.env.AGENTIC_SECURITY_VALIDATE_SECRETS === '1') {
630
+ try {
631
+ const { checkSecretLive } = await import('../src/posture/secret-live-check.js');
632
+ let live = 0;
633
+ for (const s of (persistedScan.secrets || [])) {
634
+ const { verdict, provider } = await checkSecretLive(s);
635
+ s.liveVerdict = verdict;
636
+ if (provider) s.liveProvider = provider;
637
+ if (verdict === 'live') live++;
638
+ }
639
+ if (live > 0) process.stderr.write(`🔴 agentic-security: ${live} LIVE secret(s) validated — rotate immediately (even if already removed from HEAD).\n`);
640
+ } catch { /* best-effort, offline-degrading — never block a scan */ }
641
+ }
569
642
  const lastScanBody = JSON.stringify(persistedScan, null, 2);
570
643
  await fsp.writeFile(path.join(stateDir, 'last-scan.json'), lastScanBody);
571
644
  try {
@@ -1535,7 +1608,7 @@ description: Remediate every finding at or above a severity threshold (default:
1535
1608
  argument-hint: "[--severity critical|high|medium]"
1536
1609
  ---
1537
1610
 
1538
- Read \`.agentic-security/last-scan.json\`. For every finding at or above \`\${1:-critical}\` severity, dispatch the security-fixer subagent in sequence not in parallel, as each fix may change subsequent findings. After each batch, re-run \`/security-scan-all\` to confirm. Stop and report if any test fails.
1611
+ Read \`.agentic-security/last-scan.json\`. For every finding at or above \`\${1:-critical}\` severity, dispatch the security-fixer subagent independent findings in parallel, serializing only findings that share a file. Each fix is inline-verified by apply_fix before it lands (finding gone + no new ≥medium + lint). Do NOT halt on the first failure: record each finding's outcome and continue, then report the full list and the auto-fix acceptance rate. Re-run \`/security-scan-all\` to confirm.
1539
1612
  `,
1540
1613
  'security-report.md': `---
1541
1614
  description: Generate an HTML security report (or JSON / Markdown / SARIF).
package/dist/178.index.js CHANGED
@@ -13,7 +13,7 @@ export const modules = {
13
13
  /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1421);
14
14
  /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
15
15
  /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
16
- /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5099);
16
+ /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(3793);
17
17
  // Time-travel + counterfactual scanning (v0.68).
18
18
  //
19
19
  // Two new modes that exploit the pure-input shape of runFullScan:
@@ -0,0 +1,193 @@
1
+ export const id = 220;
2
+ export const ids = [220];
3
+ export const modules = {
4
+
5
+ /***/ 9220:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ computeDelta: () => (/* binding */ computeDelta),
10
+ /* harmony export */ persistStatus: () => (/* binding */ persistStatus),
11
+ /* harmony export */ renderStatusLine: () => (/* binding */ renderStatusLine),
12
+ /* harmony export */ watchProject: () => (/* binding */ watchProject)
13
+ /* harmony export */ });
14
+ /* unused harmony exports readStatus, _internals */
15
+ /* harmony import */ var node_fs_promises__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1455);
16
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
17
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
18
+ // Watch mode — continuous incremental scan as the developer edits.
19
+ //
20
+ // Spawns a long-running scan watcher that:
21
+ // 1. Subscribes to file-system events under the project root
22
+ // 2. When a file matching the scan glob changes, re-scans incrementally
23
+ // using dataflow/incremental-cache.js (per-file cache hits avoid the
24
+ // full IR build)
25
+ // 3. Diffs against the prior scan to compute a "risk delta" string
26
+ // (added/removed/changed criticals + highs)
27
+ // 4. Writes the delta to .agentic-security/watch-status.{md,json}
28
+ //
29
+ // The Claude Code statusline / a chat command can poll watch-status.md
30
+ // (cheap file read) to surface the delta inline without re-running anything.
31
+ //
32
+ // Implementation is deliberately node-only — no chokidar dep, uses
33
+ // fs.promises.watch (Node ≥ 20). Debounces to 350ms.
34
+ //
35
+ // Lifecycle: start() returns an AbortController-like handle. The caller
36
+ // (a /watch slash command or a long-running daemon spawn) is responsible
37
+ // for keeping the process alive; this module is the pure logic.
38
+
39
+
40
+
41
+
42
+
43
+ const STATE = '.agentic-security';
44
+ const STATUS_MD = 'watch-status.md';
45
+ const STATUS_JSON = 'watch-status.json';
46
+ const DEBOUNCE_MS = 350;
47
+ const MAX_BURST = 50; // ignore beyond this # of rapid events
48
+
49
+ const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp|rs|sol|vy|swift|dart|toml|yml|yaml|json|tf|tfvars|bicep)$/i;
50
+ const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
51
+
52
+ function _isScanable(rel) {
53
+ if (!rel || IGNORE_DIR_RE.test(rel)) return false;
54
+ return SCAN_EXT_RE.test(rel);
55
+ }
56
+
57
+ function _readJsonSafe(fp) {
58
+ try { return JSON.parse(fsSync.readFileSync(fp, 'utf8')); } catch { return null; }
59
+ }
60
+
61
+ function _sevRank(s) { return ['info', 'low', 'medium', 'high', 'critical'].indexOf(s) + 1; }
62
+
63
+ /**
64
+ * Pure delta computation between two finding arrays. Same logic
65
+ * baseline-compare uses, surfaced as a single ASCII status line.
66
+ */
67
+ function computeDelta(prevFindings, currFindings) {
68
+ const key = (f) => `${f.file || ''}::${f.line || 0}::${f.family || f.parser || ''}`;
69
+ const prev = new Map();
70
+ for (const f of prevFindings || []) prev.set(key(f), f);
71
+ const cur = new Map();
72
+ for (const f of currFindings || []) cur.set(key(f), f);
73
+ const added = [], removed = [];
74
+ for (const [k, f] of cur) if (!prev.has(k)) added.push(f);
75
+ for (const [k, f] of prev) if (!cur.has(k)) removed.push(f);
76
+ const newCrit = added.filter(f => f.severity === 'critical').length;
77
+ const newHigh = added.filter(f => f.severity === 'high').length;
78
+ const fixedCrit = removed.filter(f => f.severity === 'critical').length;
79
+ const fixedHigh = removed.filter(f => f.severity === 'high').length;
80
+ return {
81
+ addedCount: added.length, removedCount: removed.length,
82
+ newCritical: newCrit, newHigh, fixedCritical: fixedCrit, fixedHigh,
83
+ added, removed,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Render a one-line status string for the Claude Code statusline.
89
+ */
90
+ function renderStatusLine(delta) {
91
+ const parts = [];
92
+ if (delta.newCritical) parts.push(`🛑 +${delta.newCritical} crit`);
93
+ if (delta.newHigh) parts.push(`⚠️ +${delta.newHigh} high`);
94
+ if (delta.fixedCritical) parts.push(`✅ -${delta.fixedCritical} crit`);
95
+ if (delta.fixedHigh) parts.push(`✅ -${delta.fixedHigh} high`);
96
+ if (!parts.length && (delta.addedCount + delta.removedCount) === 0) return 'agentic-security: clean';
97
+ if (!parts.length) return `agentic-security: +${delta.addedCount} / -${delta.removedCount}`;
98
+ return 'agentic-security: ' + parts.join(' · ');
99
+ }
100
+
101
+ /**
102
+ * Persist watch-status.{md,json}. Cheap atomic write (write tmp, rename).
103
+ */
104
+ function persistStatus(scanRoot, delta) {
105
+ const dir = node_path__WEBPACK_IMPORTED_MODULE_2__.join(scanRoot, STATE);
106
+ try { node_fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(dir, { recursive: true }); } catch {}
107
+ const status = {
108
+ ts: new Date().toISOString(),
109
+ line: renderStatusLine(delta),
110
+ delta: {
111
+ addedCount: delta.addedCount, removedCount: delta.removedCount,
112
+ newCritical: delta.newCritical, newHigh: delta.newHigh,
113
+ fixedCritical: delta.fixedCritical, fixedHigh: delta.fixedHigh,
114
+ },
115
+ addedTop5: (delta.added || []).slice(0, 5).map(f => ({
116
+ file: f.file, line: f.line, family: f.family, severity: f.severity, vuln: f.vuln,
117
+ })),
118
+ };
119
+ const jsonPath = node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, STATUS_JSON);
120
+ const mdPath = node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, STATUS_MD);
121
+ try { node_fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(jsonPath, JSON.stringify(status, null, 2)); } catch {}
122
+ const md = [
123
+ `# Watch status — ${status.ts.slice(11, 19)} UTC`,
124
+ '',
125
+ status.line,
126
+ '',
127
+ ];
128
+ if (status.addedTop5.length) {
129
+ md.push('## New findings');
130
+ for (const f of status.addedTop5) {
131
+ md.push(`- **[${(f.severity || '?').toUpperCase()}]** ${f.vuln || f.family || 'finding'} — \`${f.file}:${f.line}\``);
132
+ }
133
+ }
134
+ try { node_fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(mdPath, md.join('\n')); } catch {}
135
+ return status;
136
+ }
137
+
138
+ /**
139
+ * Read the latest watch-status (returns null if none).
140
+ */
141
+ function readStatus(scanRoot) {
142
+ return _readJsonSafe(path.join(scanRoot, STATE, STATUS_JSON));
143
+ }
144
+
145
+ /**
146
+ * Subscribe to FS events and call onChange(absPath, eventType) for each
147
+ * matching event. Debounces bursts. Returns a controller with stop().
148
+ *
149
+ * The actual incremental scan call lives in the caller — this module
150
+ * stays pure for testability.
151
+ */
152
+ async function watchProject(scanRoot, onChange, opts = {}) {
153
+ if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') return { stop: async () => {}, _disabled: true };
154
+ const recursive = opts.recursive !== false;
155
+ const ac = new AbortController();
156
+ let timer = null;
157
+ const pending = new Set();
158
+ const flush = () => {
159
+ timer = null;
160
+ if (pending.size > MAX_BURST) { pending.clear(); return; }
161
+ const batch = Array.from(pending);
162
+ pending.clear();
163
+ try { onChange(batch); } catch {}
164
+ };
165
+ let stopped = false;
166
+ (async () => {
167
+ try {
168
+ for await (const evt of node_fs_promises__WEBPACK_IMPORTED_MODULE_0__.watch(scanRoot, { recursive, signal: ac.signal })) {
169
+ if (stopped) break;
170
+ const rel = String(evt.filename || '').replace(/\\/g, '/');
171
+ if (!_isScanable(rel)) continue;
172
+ pending.add(node_path__WEBPACK_IMPORTED_MODULE_2__.join(scanRoot, rel));
173
+ if (timer) clearTimeout(timer);
174
+ timer = setTimeout(flush, DEBOUNCE_MS);
175
+ }
176
+ } catch (e) {
177
+ if (e && e.name !== 'AbortError') {
178
+ // Surface but don't crash — caller decides how to handle.
179
+ try { onChange([], e); } catch {}
180
+ }
181
+ }
182
+ })();
183
+ return {
184
+ stop: async () => { stopped = true; ac.abort(); if (timer) clearTimeout(timer); },
185
+ };
186
+ }
187
+
188
+ const _internals = { _isScanable, SCAN_EXT_RE, IGNORE_DIR_RE };
189
+
190
+
191
+ /***/ })
192
+
193
+ };
package/dist/384.index.js CHANGED
@@ -8,7 +8,7 @@ export const modules = {
8
8
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
9
  /* harmony export */ scanCredentials: () => (/* reexport safe */ _engine_js__WEBPACK_IMPORTED_MODULE_0__.Sv)
10
10
  /* harmony export */ });
11
- /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5099);
11
+ /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3793);
12
12
  // Secrets submodule view of the engine — credential + entropy + TODO scanning.
13
13
 
14
14