@clear-capabilities/agentic-security-scanner 0.154.0 → 0.155.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
@@ -9,6 +9,19 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.155.0 - premortem remediation (trust boundary, unseen-shape measurement), NixOS VM check
13
+
14
+ An adversarial premortem of 0.154.0 found the following; each is fixed here and covered by `test/language/language-trust-boundary.test.js`.
15
+
16
+ - **Trust boundary.** Advisory snapshots are read only from the operator (environment variable or `$XDG_CONFIG_HOME/agentic-security/`). A snapshot inside the scanned project is ignored and the scan says so, so a repository can no longer ship an empty feed to look clean. Nix export freshness is derived by the scanner from `flake.lock`, project-supplied exports and plans are disclosed, a refused export contributes nothing, and a Cabal plan that contradicts the freeze file is flagged.
17
+ - **Unseen-shape split.** The holdout shares its code shapes with development, so it measures robustness to renaming only. A new `unseen` split (300 author-labelled cases, 3 new vulnerable and 3 new safe shapes per family) is measured once and never tuned against. Its result is stored beside every holdout row with 95% Clopper-Pearson intervals and strict precision. It is **below target** for Haskell SAST (R 79.2%), Haskell taint (P 77.8%) and Nix config-sast (P 86.1%); those are engine gaps, not fixed here. The `auth` row is now promoted from its own holdout evidence.
18
+ - **Completion honesty.** A release check (`completion-declared`) fails when `docs/completion-status.json` lists unverified requirements and the changelog has no paragraph naming them. Chrome-dependent export tests are a separate informational job rather than a silent skip.
19
+ - **Nix package fix (found by the first hosted NixOS guest run).** The packaged `agentic-security-mcp` and `agentic-security-lsp` wrappers ran `bin/*.js`, which import `src/` and therefore npm dependencies the Nix package does not ship, so both would have crashed for a Nix user. They now run from the self-contained bundle, and the CLI gained an `lsp` subcommand (the same server the editor plugins start). The flake's package source is now exactly the files it installs, so an offline rebuild elsewhere reproduces the same derivation.
20
+ - **Destructive-command guard.** `hooks/pre-bash-guard.js` judged `rm` by text, so it refused any absolute path (and `docker run --rm -v /abs/...`) while missing nothing it should. Deletion (`rm`, `rmdir`, `unlink`, `find -delete`) is now path-aware: targets are parsed and resolved (`~`, `$HOME`, relative paths, `cd`, symlinks) and allowed only strictly inside an allowed root (default `~/code`, set with `allowedRoots` in `.agentic-security/destructive-guard.json`). Anything outside, anything unresolvable (`$VAR`, command substitution, `xargs`) and the root itself is refused. It cannot see deletion done inside another program, so it guards against mistakes and is not a sandbox. Pinned by `test/pre-bash-guard.test.js`.
21
+ - **Not done.** Peak scan memory is measured under a stated heap cap and was **not reduced**: a lazy-token experiment retained less but changed no measurement, so it was reverted. `flake.nix` is now locked (`flake.lock`), built and checked on aarch64-linux in a `nixos/nix` container (Nix 2.35.2) and on x86_64-linux on a hosted runner; aarch64-darwin is declared but unbuilt, and x86_64-darwin is dropped (current nixpkgs no longer supports it).
22
+
23
+ Partial release: the loop controller lists NIX-011, NIX-012 and REL-001 as not verified. NIX-011 and NIX-012 passed on hosted CI in this release's pull request (the NIX-011 suite on Nix 2.24.10; the NIX-012 suite inside a real NixOS guest booted by the flake's `nixos-host` check on an x86_64 runner with KVM), but that is CI evidence, not controller-signed evidence, because the controller runs where `nix` and `/etc/NIXOS` are absent. The aarch64 NixOS guest was not run: the arm runner has no KVM and a software-emulated guest does not boot in any usable time. REL-001 is the controller's own final acceptance and needs those two to be controller-verified first.
24
+
12
25
  ## 0.154.0 - Haskell and Nix/NixOS support, with every limit stated
13
26
 
14
27
  Haskell and Nix/NixOS are now scanned by the same commands as every other language, with no compiler, no `nix` binary and no
@@ -137,6 +137,7 @@ Commands:
137
137
  tickets sync --provider <p> Two-way sync findings ↔ GitHub Issues / Linear / Jira
138
138
  digest --slack <webhook> Vibecoder: send daily digest to Slack
139
139
  mcp Start the MCP stdio server (scan_diff, query_taint, explain_finding, apply_fix)
140
+ lsp Start the Language Server Protocol server on stdio (what the editor plugins run)
140
141
  validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
141
142
  verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
142
143
  ask "<question>" [target] Bounded local-model Q&A with read-only tool access (requires AGENTIC_SECURITY_LLM_PRESET=ollama)
@@ -7056,6 +7057,13 @@ async function main() {
7056
7057
  runStdio({ sessionRoot: path.resolve(root) });
7057
7058
  return;
7058
7059
  }
7060
+ case 'lsp': {
7061
+ // The same server as bin/agentic-security-lsp.js, reachable from the self-contained bundle: a package that ships only the bundle
7062
+ // (the Nix package does) has no node_modules for the src/ entry point to import from.
7063
+ const { startLspServer } = await import('../src/lsp/server.js');
7064
+ startLspServer();
7065
+ return;
7066
+ }
7059
7067
  case 'explore': process.exit(await cmdExplore(args));
7060
7068
  case 'dataflow': {
7061
7069
  const sub = args._[1];
@@ -0,0 +1,445 @@
1
+ export const id = 1919;
2
+ export const ids = [1919];
3
+ export const modules = {
4
+
5
+ /***/ 21919:
6
+ /***/ ((__webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ __webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependencies__, __webpack_async_result__) => { try {
9
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
10
+ /* harmony export */ startLspServer: () => (/* binding */ startLspServer)
11
+ /* harmony export */ });
12
+ /* unused harmony export _internals */
13
+ /* harmony import */ var _language_discovery_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(30951);
14
+ /* harmony import */ var _language_context_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(15911);
15
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(73024);
16
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(76760);
17
+ /* harmony import */ var node_readline__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(80481);
18
+ /* harmony import */ var _runScan_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(45950);
19
+ /* harmony import */ var _posture_custom_rules_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(9049);
20
+ /* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(31174);
21
+ /* harmony import */ var _mcp_redact_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(83468);
22
+ /* harmony import */ var _report_index_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(75917);
23
+ var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_runScan_js__WEBPACK_IMPORTED_MODULE_5__, _report_index_js__WEBPACK_IMPORTED_MODULE_8__]);
24
+ ([_runScan_js__WEBPACK_IMPORTED_MODULE_5__, _report_index_js__WEBPACK_IMPORTED_MODULE_8__] = __webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__);
25
+ // Minimal LSP server for agentic-security.
26
+ //
27
+ // Speaks the Language Server Protocol over stdio. On every textDocument/
28
+ // didSave (and didOpen), the server runs runScan on the file and emits
29
+ // textDocument/publishDiagnostics with the resulting findings mapped to
30
+ // LSP Diagnostic objects.
31
+ //
32
+ // This is a STARTER implementation — feature-complete enough that JetBrains
33
+ // (via LSP4IJ) and Neovim (via built-in LSP) can both attach and see
34
+ // findings inline. The full feature set (code actions for /fix, inline
35
+ // remediation hover, exploitability tooltip) is future work.
36
+ //
37
+ // Wire-format: vscode-jsonrpc framing (Content-Length headers). Stateless
38
+ // per file — no incremental analysis yet.
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+ const PROTOCOL_VERSION = '3.17';
52
+ const SERVER_NAME = 'agentic-security-lsp';
53
+ const SERVER_VERSION = '0.1.0';
54
+
55
+ let _rootUri = null;
56
+ let _rootDir = process.cwd();
57
+ let _stdoutMutex = Promise.resolve();
58
+ const _diagnosticsByUri = new Map();
59
+
60
+ function uriToPath(uri) {
61
+ if (!uri) return null;
62
+ if (uri.startsWith('file://')) return decodeURIComponent(uri.slice(7));
63
+ return uri;
64
+ }
65
+
66
+ function pathToUri(p) {
67
+ if (!p) return null;
68
+ if (p.startsWith('file://')) return p;
69
+ return 'file://' + encodeURI(node_path__WEBPACK_IMPORTED_MODULE_3__.resolve(p));
70
+ }
71
+
72
+ function sevToLsp(sev) {
73
+ switch ((sev || '').toLowerCase()) {
74
+ case 'critical': return 1; // Error
75
+ case 'high': return 1; // Error
76
+ case 'medium': return 2; // Warning
77
+ case 'low': return 3; // Information
78
+ default: return 4; // Hint
79
+ }
80
+ }
81
+
82
+ // The most precise location a finding carries. Haskell and Nix findings keep their ORIGINAL span (0-based columns); a
83
+ // finding with only a line is reported across that line, as before.
84
+ function _rangeOf(f) {
85
+ const o = f && f.originalLocation;
86
+ if (o && Number.isInteger(o.startLine) && Number.isInteger(o.startColumn)) {
87
+ return { start: { line: Math.max(0, o.startLine - 1), character: o.startColumn }, end: { line: Math.max(0, (Number.isInteger(o.endLine) ? o.endLine : o.startLine) - 1), character: Number.isInteger(o.endColumn) ? o.endColumn : o.startColumn + 1 } };
88
+ }
89
+ if (o && Number.isInteger(o.line) && Number.isInteger(o.column)) {
90
+ const line = Math.max(0, o.line - 1);
91
+ return { start: { line, character: o.column }, end: { line, character: 200 } };
92
+ }
93
+ const line = Math.max(0, ((f && f.line) || 1) - 1);
94
+ return { start: { line, character: 0 }, end: { line, character: 200 } };
95
+ }
96
+
97
+ function findingToDiagnostic(f) {
98
+ const line = Math.max(0, (f.line || 1) - 1);
99
+ // Stage 6 correctness audit: this read f.remediation directly, but raw
100
+ // scan.findings entries (what this consumes, pre-normalizeFindings) come
101
+ // from two conventions — most posture/*.js and newer sast/*.js modules
102
+ // set `remediation`, while ~127 of engine.js's own detectors set a `fix`
103
+ // STRING field instead. _remediationOf carries the same precedence
104
+ // report/index.js already established for this exact split (CMP-3).
105
+ const remediation = (0,_report_index_js__WEBPACK_IMPORTED_MODULE_8__/* ._remediationOf */ .uV)(f);
106
+ return {
107
+ range: _rangeOf(f),
108
+ severity: sevToLsp(f.severity),
109
+ source: 'agentic-security',
110
+ code: f.cwe || f.family || 'finding',
111
+ // carried back by the client in codeAction requests, so a fix is offered for exactly this finding
112
+ data: { id: f.id || null, stableId: f.stableId || null, rule: f.rule || null, family: f.family || null, language: f.language || (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageOfFinding)(f) || null },
113
+ message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
114
+ tags: [],
115
+ };
116
+ }
117
+
118
+ function send(message) {
119
+ const json = JSON.stringify({ jsonrpc: '2.0', ...message });
120
+ _stdoutMutex = _stdoutMutex.then(() => new Promise(resolve => {
121
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`, resolve);
122
+ }));
123
+ return _stdoutMutex;
124
+ }
125
+
126
+ async function publishDiagnostics(uri, findings) {
127
+ await send({
128
+ method: 'textDocument/publishDiagnostics',
129
+ params: { uri, diagnostics: findings.map(findingToDiagnostic) },
130
+ });
131
+ _diagnosticsByUri.set(uri, findings);
132
+ }
133
+
134
+ // Manifest / schema files that downstream passes (SCA, cross-language) read.
135
+ // We walk the project tree once per LSP session and cache these so the
136
+ // per-save scan has them.
137
+ const DEP_BASE_NAMES = new Set([
138
+ 'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
139
+ 'requirements.txt', 'pyproject.toml', 'poetry.lock', 'Pipfile.lock',
140
+ 'composer.json', 'composer.lock', 'Gemfile', 'Gemfile.lock',
141
+ 'go.mod', 'Cargo.toml', 'Cargo.lock',
142
+ 'pom.xml', 'build.gradle', 'build.gradle.kts',
143
+ ]);
144
+ const DEP_EXT_RE = /\.(?:proto|graphql|gql|tf|cabal)$/i;
145
+ // Haskell and Nix project manifests and lock files (cabal.project*, stack.yaml*, package.yaml, flake.lock, ...) are
146
+ // recognised by the shared language discovery module, never by a second list here.
147
+ const _isLanguageDep = (base) => (0,_language_discovery_js__WEBPACK_IMPORTED_MODULE_0__/* .isLanguageManifest */ .mD)(base);
148
+ const DEP_NAME_RE = /(?:openapi|swagger)\.(?:ya?ml|json)$/i;
149
+
150
+ let _depCache = { rootDir: null, depFileContents: {} };
151
+
152
+ function _loadDepFileContents(rootDir) {
153
+ if (_depCache.rootDir === rootDir) return _depCache.depFileContents;
154
+ const out = {};
155
+ const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'target', 'vendor', '.bench-cache']);
156
+ function walk(dir) {
157
+ let entries;
158
+ try { entries = node_fs__WEBPACK_IMPORTED_MODULE_2__.readdirSync(dir, { withFileTypes: true }); } catch { return; }
159
+ for (const e of entries) {
160
+ if (skipDirs.has(e.name)) continue;
161
+ const full = node_path__WEBPACK_IMPORTED_MODULE_3__.join(dir, e.name);
162
+ if (e.isDirectory()) { walk(full); continue; }
163
+ if (!e.isFile()) continue;
164
+ const base = e.name;
165
+ if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
166
+ let stat;
167
+ try { stat = node_fs__WEBPACK_IMPORTED_MODULE_2__.statSync(full); } catch { continue; }
168
+ if (stat.size > 500_000) continue;
169
+ try { out[node_path__WEBPACK_IMPORTED_MODULE_3__.relative(rootDir, full)] = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(full, 'utf8'); }
170
+ catch { /* skip unreadable */ }
171
+ }
172
+ }
173
+ }
174
+ walk(rootDir);
175
+ _depCache = { rootDir, depFileContents: out };
176
+ return out;
177
+ }
178
+
179
+ async function scanFile(uri) {
180
+ const filePath = uriToPath(uri);
181
+ if (!filePath || !node_fs__WEBPACK_IMPORTED_MODULE_2__.existsSync(filePath)) return;
182
+ // Incremental scan (premortem 2R4.5 / 2R-10): hand runScan a single-file
183
+ // fileContents map for the saved code, AND a cached set of dep-manifest /
184
+ // schema files so SCA + cross-language passes have their inputs. Without
185
+ // depFileContents, the LSP path would silently drop CVE / OpenAPI / proto
186
+ // findings on the saved file.
187
+ try {
188
+ const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, filePath);
189
+ const content = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(filePath, 'utf8');
190
+ let fileContents = { [rel]: content };
191
+ let depFileContents = _loadDepFileContents(_rootDir);
192
+ // Haskell/Nix: scan the saved file together with the modules it imports and the ones that import it (bounded).
193
+ const lc = (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.withLanguageContext)(_rootDir, fileContents, depFileContents);
194
+ fileContents = lc.fileContents; depFileContents = lc.depFileContents;
195
+ // Premortem 4R-12 + 4R-15: reset the per-process custom-rules budget at
196
+ // the start of each LSP scan. Each save is a logical scan session; without
197
+ // the reset, a long-lived LSP server would accumulate budget across saves
198
+ // and eventually start skipping custom rules.
199
+ (0,_posture_custom_rules_js__WEBPACK_IMPORTED_MODULE_6__/* .resetCustomRulesBudget */ .zO)(_rootDir);
200
+ // PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is
201
+ // default-on for the interactive CLI scan but was never requested here,
202
+ // so every on-save diagnostic pass was regex/AST-only — blind to any bug
203
+ // whose source and sink are connected only through a call. Scoped to
204
+ // exactly the saved file (fileContents has one entry), so this does not
205
+ // turn every keystroke's save into a full-project deep scan.
206
+ // withStateWritesDisabled, for the same reason mcp/tools.js's scan_diff
207
+ // wraps its own partial-set scan (FR-704). This is a DIAGNOSTIC surface: it
208
+ // runs on every file save, against the user's real project root, with a
209
+ // fileContents map holding exactly one file. Without the wrapper,
210
+ // runFullScan's state writers fire on every keystroke-save — dpia.md,
211
+ // ropa.md, privacy-framework.json, threat-model.json and the rest, written
212
+ // into the user's tree by an editor plugin they never asked to mutate
213
+ // anything.
214
+ //
215
+ // The provenance lifecycle store makes that actively destructive rather
216
+ // than merely noisy: updateLifecycle marks every open stableId ABSENT from
217
+ // the finding set it is handed as `remediated`, and this set is one file's
218
+ // worth of findings. Every save would remediate the whole project, and the
219
+ // next real scan would reintroduce it.
220
+ //
221
+ // Chosen over forwarding `provenance:false` through runScan because that
222
+ // would fix only the lifecycle half and leave the other state writers
223
+ // firing. The flag is process-global (see its KNOWN LIMITATION), which is
224
+ // harmless here: this server is a read-only surface whose every scan wants
225
+ // writes off, so overlapping saves can only ever agree, and the `finally`
226
+ // restores the prior value either way. exceptCategories:['provenance-cache']
227
+ // (M2 §2.4) is the one deliberate exception — every OTHER write this scan
228
+ // would make stays suppressed, but the provenance disk cache stays live so
229
+ // repeated saves of the same file are not each paying the full uncached
230
+ // resolution cost.
231
+ const { scan } = await (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_7__/* .withStateWritesDisabled */ .Ao)(() =>
232
+ (0,_runScan_js__WEBPACK_IMPORTED_MODULE_5__.runScan)(_rootDir, { fileContents, depFileContents, deep: true, deepInCi: true }),
233
+ { exceptCategories: ['provenance-cache'] });
234
+ // Stage 6 correctness audit: this only ever read scan.findings (the SAST
235
+ // channel). scan.secrets and scan.logicVulns are separate arrays on the
236
+ // raw runScan() result — normalizeFindings is what merges all four
237
+ // channels, and that hasn't run here — so a saved file with a hardcoded
238
+ // credential got a clean problem pane, no diagnostic at all. Unlike the
239
+ // MCP surface, this server never applied redactFinding either (nothing
240
+ // here imported mcp/redact.js), which would have been a landmine the
241
+ // moment secrets/logicVulns were added without it: those channels are
242
+ // exactly where raw secret material shows up in `snippet`. Both fixed
243
+ // together — merge the channels AND redact — so the fix for one gap
244
+ // doesn't open the other.
245
+ const findings = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])]
246
+ .filter(f => f.file === rel)
247
+ .map(f => (0,_mcp_redact_js__WEBPACK_IMPORTED_MODULE_9__/* .redactFinding */ .lE)(f));
248
+ await publishDiagnostics(uri, findings);
249
+ } catch (e) {
250
+ process.stderr.write(`agentic-security-lsp: scan failed: ${e.message}\n`);
251
+ }
252
+ }
253
+
254
+ function handleInitialize(params) {
255
+ if (params.rootUri) {
256
+ _rootUri = params.rootUri;
257
+ _rootDir = uriToPath(params.rootUri) || process.cwd();
258
+ } else if (params.rootPath) {
259
+ _rootDir = params.rootPath;
260
+ }
261
+ return {
262
+ capabilities: {
263
+ textDocumentSync: {
264
+ openClose: true,
265
+ change: 1,
266
+ save: { includeText: false },
267
+ },
268
+ diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false },
269
+ // quick fixes for Haskell and Nix findings (preview edits from the verified language fixers)
270
+ codeActionProvider: { codeActionKinds: ['quickfix'], resolveProvider: false },
271
+ },
272
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
273
+ };
274
+ }
275
+
276
+ /**
277
+ * textDocument/codeAction: a quick fix for each agentic-security diagnostic in range that a language fixer can repair.
278
+ * The edit is a PREVIEW the editor applies; nothing is written here. A finding with no safe fix gets a disabled
279
+ * action that says why, never a silent omission.
280
+ */
281
+ async function codeActionsFor(params) {
282
+ const uri = params && params.textDocument && params.textDocument.uri;
283
+ const diags = ((params && params.context && params.context.diagnostics) || []).filter((d) => d && d.source === 'agentic-security' && d.data && d.data.id);
284
+ const stored = (uri && _diagnosticsByUri.get(uri)) || [];
285
+ if (!diags.length || !stored.length) return [];
286
+ let files = null;
287
+ const actions = [];
288
+ for (const d of diags) {
289
+ const f = stored.find((x) => x && x.id === d.data.id);
290
+ if (!f || !(0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageOfFinding)(f)) continue;
291
+ if (!files) files = (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.loadLanguageProject)(_rootDir).files;
292
+ let prev;
293
+ try { prev = await (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageFixPreview)(f, files); } catch (e) { prev = { ok: false, reason: String((e && e.message) || e) }; }
294
+ if (prev && prev.ok && prev.file) {
295
+ const target = pathToUri(node_path__WEBPACK_IMPORTED_MODULE_3__.join(_rootDir, prev.file));
296
+ actions.push({
297
+ title: `Fix${prev.label ? ` (${prev.label})` : ''}: ${f.vuln || f.rule || 'finding'}`,
298
+ kind: 'quickfix', diagnostics: [d], isPreferred: false,
299
+ edit: { changes: { [target]: [(0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.minimalEdit)(prev.before, prev.after)] } },
300
+ data: { stableId: f.stableId || null, verified: { syntax: true, rescan: true } },
301
+ });
302
+ } else {
303
+ actions.push({ title: `No automatic fix: ${f.vuln || f.rule || 'finding'}`, kind: 'quickfix', diagnostics: [d], disabled: { reason: (prev && (prev.reason || prev.status)) || 'no deterministic fix exists for this finding' } });
304
+ }
305
+ }
306
+ return actions;
307
+ }
308
+
309
+ async function handleMessage(msg) {
310
+ if (msg.method === 'textDocument/codeAction') {
311
+ return { id: msg.id, result: await codeActionsFor(msg.params || {}) };
312
+ }
313
+ if (msg.method === 'initialize') {
314
+ return { id: msg.id, result: handleInitialize(msg.params || {}) };
315
+ }
316
+ if (msg.method === 'initialized' || msg.method === 'workspace/didChangeConfiguration') {
317
+ return null; // notification, no response
318
+ }
319
+ if (msg.method === 'shutdown') {
320
+ return { id: msg.id, result: null };
321
+ }
322
+ if (msg.method === 'exit') {
323
+ process.exit(0);
324
+ }
325
+ if (msg.method === 'textDocument/didOpen') {
326
+ const uri = msg.params?.textDocument?.uri;
327
+ if (uri) scanFile(uri);
328
+ return null;
329
+ }
330
+ if (msg.method === 'textDocument/didSave') {
331
+ const uri = msg.params?.textDocument?.uri;
332
+ if (uri) {
333
+ // Premortem 3R-9 / 4R-5: when the user saves a manifest file, the
334
+ // dep-cache entry for THAT file is stale. Granular invalidation (only
335
+ // re-read the saved file from disk) avoids the O(project) re-walk that
336
+ // 3R-9 introduced — important in monorepos where mass manifest edits
337
+ // would otherwise re-scan thousands of files per save.
338
+ const savedPath = uriToPath(uri);
339
+ if (savedPath && _depCache.rootDir === _rootDir) {
340
+ const base = node_path__WEBPACK_IMPORTED_MODULE_3__.basename(savedPath);
341
+ if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
342
+ try {
343
+ const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, savedPath);
344
+ const st = node_fs__WEBPACK_IMPORTED_MODULE_2__.statSync(savedPath);
345
+ if (st.size <= 500_000) {
346
+ _depCache.depFileContents[rel] = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(savedPath, 'utf8');
347
+ } else {
348
+ delete _depCache.depFileContents[rel];
349
+ }
350
+ } catch {
351
+ // File vanished between save event and stat — drop from cache.
352
+ try {
353
+ const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, savedPath);
354
+ delete _depCache.depFileContents[rel];
355
+ } catch {}
356
+ }
357
+ }
358
+ }
359
+ scanFile(uri);
360
+ }
361
+ return null;
362
+ }
363
+ if (msg.method === 'textDocument/didClose') {
364
+ const uri = msg.params?.textDocument?.uri;
365
+ if (uri) await publishDiagnostics(uri, []);
366
+ return null;
367
+ }
368
+ // Unknown method.
369
+ if (msg.id != null) {
370
+ return { id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
371
+ }
372
+ return null;
373
+ }
374
+
375
+ function startLspServer() {
376
+ let buffer = Buffer.alloc(0);
377
+ let expected = -1;
378
+ process.stdin.on('data', async (chunk) => {
379
+ buffer = Buffer.concat([buffer, chunk]);
380
+ while (true) {
381
+ if (expected < 0) {
382
+ const headerEnd = buffer.indexOf('\r\n\r\n');
383
+ if (headerEnd < 0) break;
384
+ const headers = buffer.slice(0, headerEnd).toString('utf8');
385
+ const m = headers.match(/Content-Length:\s*(\d+)/i);
386
+ if (!m) {
387
+ process.stderr.write('agentic-security-lsp: missing Content-Length header\n');
388
+ buffer = buffer.slice(headerEnd + 4);
389
+ continue;
390
+ }
391
+ expected = parseInt(m[1], 10);
392
+ buffer = buffer.slice(headerEnd + 4);
393
+ }
394
+ if (buffer.length < expected) break;
395
+ const body = buffer.slice(0, expected).toString('utf8');
396
+ buffer = buffer.slice(expected);
397
+ expected = -1;
398
+ let msg;
399
+ try { msg = JSON.parse(body); }
400
+ catch { process.stderr.write('agentic-security-lsp: malformed JSON\n'); continue; }
401
+ try {
402
+ const response = await handleMessage(msg);
403
+ if (response) await send(response);
404
+ } catch (e) {
405
+ if (msg.id != null) await send({ id: msg.id, error: { code: -32603, message: e.message } });
406
+ }
407
+ }
408
+ });
409
+ process.stdin.on('end', () => process.exit(0));
410
+ }
411
+
412
+ // Allow direct invocation as a bin entry: `node lsp/server.js`.
413
+ //
414
+ // `import.meta.url === file://${process.argv[1]}` looks equivalent but is
415
+ // NOT: when this script is invoked through a symlink (exactly what
416
+ // `npm install -g`, `npx`, and `node_modules/.bin/<name>` all do for a
417
+ // package's `bin` entries — and `agentic-security-lsp` IS one of this
418
+ // package's bin entries), Node resolves `import.meta.url` to the symlink's
419
+ // realpath while `process.argv[1]` stays the symlink path as invoked, so the
420
+ // two never match, the guard is always false, and the server silently exits
421
+ // with no output — an editor would see the language server start and
422
+ // immediately die with nothing on stderr to explain it. `import.meta.main` is
423
+ // resolved correctly through a symlink. It was added in Node v24.2.0
424
+ // (backported to v22.18.0) and is currently Stability 1.0 (early development)
425
+ // per Node's own docs — NOT stable, and NOT available on v20.11. Concretely:
426
+ // it is `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
427
+ // `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
428
+ // reproduce this exact bug on a plain non-symlinked invocation under those two
429
+ // point releases. The `??` fallback covers that gap without bumping the
430
+ // engines floor. Identical to bin/agentic-security.js's guard, deliberately —
431
+ // see the long-form note there.
432
+ if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
433
+ startLspServer();
434
+ }
435
+
436
+ function _setRootDir(dir) { _rootDir = dir; _depCache = { rootDir: null, depFileContents: {} }; }
437
+
438
+ const _internals = { findingToDiagnostic, scanFile, uriToPath, pathToUri, _diagnosticsByUri, _setRootDir };
439
+
440
+ __webpack_async_result__();
441
+ } catch(e) { __webpack_async_result__(e); } });
442
+
443
+ /***/ })
444
+
445
+ };
@@ -261,14 +261,14 @@ async function assessLanguageAssurance(input = {}) {
261
261
  const scaRequired = (required.haskell || []).includes('sca') && !disabled.has('haskell:sca');
262
262
  if (present.has('haskell') && scaRequired && manifests.length) {
263
263
  const db = input.advisoryDb;
264
- if (!db) conditions.push('no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed');
264
+ if (!db) conditions.push(`no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed${input.advisoryReason ? `. ${input.advisoryReason}` : ''}`);
265
265
  else if (db.stale) conditions.push(`the Hackage advisory snapshot is stale (${db.ageDays == null ? 'age unknown' : `${Math.floor(db.ageDays)} day(s) old`}, limit ${db.maxAgeDays})`);
266
266
  }
267
267
 
268
268
  // 3b. resolved dependency data and the Nix closure: a stale, malformed or unchecked input is stated, never silently dropped
269
269
  for (const g of input.supplyGaps || []) {
270
270
  if (!g || !g.kind) continue;
271
- if (/unavailable|stale|malformed|failed|unverified/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
271
+ if (/unavailable|stale|malformed|failed|unverified|refused|contradicts/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
272
272
  else limitations.push({ kind: 'supply-gap', gap: g.kind, note: String(g.detail || '').slice(0, 220) });
273
273
  }
274
274
 
@@ -19,14 +19,14 @@ __webpack_require__.d(__webpack_exports__, {
19
19
 
20
20
  // EXTERNAL MODULE: external "node:fs"
21
21
  var external_node_fs_ = __webpack_require__(73024);
22
- // EXTERNAL MODULE: ./src/posture/state-dir.js
23
- var state_dir = __webpack_require__(31174);
22
+ // EXTERNAL MODULE: external "node:crypto"
23
+ var external_node_crypto_ = __webpack_require__(77598);
24
+ // EXTERNAL MODULE: ./src/language/trusted-inputs.js
25
+ var trusted_inputs = __webpack_require__(6342);
24
26
  // EXTERNAL MODULE: ./src/language/haskell-manifests.js
25
27
  var haskell_manifests = __webpack_require__(16522);
26
28
  // EXTERNAL MODULE: ./src/language/haskell-resolved-graph.js
27
29
  var haskell_resolved_graph = __webpack_require__(86359);
28
- // EXTERNAL MODULE: external "node:crypto"
29
- var external_node_crypto_ = __webpack_require__(77598);
30
30
  ;// CONCATENATED MODULE: ./src/language/nix-closure.js
31
31
  // Resolved derivation and closure inventory (NIX-008).
32
32
  //
@@ -1032,6 +1032,7 @@ function mergeEvaluationHealth(scanHealth, result) {
1032
1032
 
1033
1033
 
1034
1034
 
1035
+
1035
1036
  const RESOLVED_PASS_VERSION = 'resolved-pass/1';
1036
1037
 
1037
1038
  const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
@@ -1071,6 +1072,12 @@ function resolvedHackageComponents(files) {
1071
1072
  const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
1072
1073
  const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
1073
1074
  if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
1075
+ // The plan is a file in the scanned project. Where the project's own freeze file also pins a package, the two must agree: a plan that
1076
+ // contradicts the freeze is stale or forged, and contributes no versions.
1077
+ const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
1078
+ const clashes = (g.units || []).filter((u) => u.name && u.version && frozen.has(u.name) && frozen.get(u.name) !== u.version).map((u) => `${u.name} ${u.version} (plan) vs ${frozen.get(u.name)} (freeze)`);
1079
+ if (clashes.length) { gaps.push({ kind: 'resolved-stale-plan-contradicts-freeze', detail: `the plan contradicts the freeze file (${clashes.slice(0, 3).join('; ')}): it is stale or forged, so no version was taken from it`, file: r.file }); return { components: [], gaps, summary }; }
1080
+ gaps.push({ kind: 'resolved-project-supplied-plan', detail: 'the plan is a file in the scanned project: it is checked against the declared bounds and the freeze file, but whoever controls the project controls its contents', file: r.file });
1074
1081
  const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
1075
1082
  const direct = new Set((g.declared || []).map((d) => d.name));
1076
1083
  const components = [];
@@ -1084,24 +1091,39 @@ function resolvedHackageComponents(files) {
1084
1091
  }
1085
1092
 
1086
1093
  // ── Nix closure ──────────────────────────────────────────────────────────────────
1087
- function loadNixExports(files) {
1088
- const exports = []; const sources = []; const problems = []; let expected = {};
1089
- for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) {
1090
- const parsed = parseJson(files[p]);
1091
- sources.push(p);
1092
- if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: p, detail: parsed.reason }); continue; }
1094
+ function loadNixExports(files, env = process.env) {
1095
+ const exports = []; const sources = []; const problems = []; const trust = new Set();
1096
+ const take = (label, text, origin) => {
1097
+ sources.push(label);
1098
+ const parsed = parseJson(text);
1099
+ if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
1093
1100
  const j = parsed.value;
1094
- if (j && Array.isArray(j.exports)) { exports.push(...j.exports.filter((e) => e && typeof e === 'object')); if (j.expected && typeof j.expected === 'object') expected = { ...expected, ...j.expected }; }
1101
+ trust.add(origin);
1102
+ // NOTE: an `expected` block inside the file is IGNORED. The file is the thing under test, so it cannot also say what it should be
1103
+ // checked against; what an export must match is derived by the scanner (below) or comes from the operator.
1104
+ if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
1095
1105
  else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
1096
- else exports.push({ text: files[p] });
1106
+ else exports.push({ text });
1107
+ };
1108
+ if (env.AGENTIC_SECURITY_NIX_EXPORT) {
1109
+ try { take(env.AGENTIC_SECURITY_NIX_EXPORT, (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
1110
+ catch (e) { sources.push(env.AGENTIC_SECURITY_NIX_EXPORT); problems.push({ kind: 'malformed-export', file: env.AGENTIC_SECURITY_NIX_EXPORT, detail: `unreadable: ${e.code || e.message}` }); }
1097
1111
  }
1098
- return { exports, sources, problems, expected };
1112
+ for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
1113
+ // What the export must have been made against: the real flake.lock of this project (a forged export cannot also forge that file's hash
1114
+ // without the lock it claims to describe being the lock that is here).
1115
+ const expected = {};
1116
+ const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
1117
+ if (lockPath) expected.flakeLockSha256 = (0,external_node_crypto_.createHash)('sha256').update(files[lockPath]).digest('hex');
1118
+ else if (sources.length) problems.push({ kind: 'export-unverified', detail: 'there is no flake.lock to bind the export to, so it cannot be shown to describe this project' });
1119
+ if (env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY) { try { expected.publicKeyPem = (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, 'utf8'); expected.requireSigned = true; } catch { problems.push({ kind: 'malformed-export', detail: 'the configured export public key is unreadable' }); } }
1120
+ return { exports, sources, problems, expected, trust: [...trust] };
1099
1121
  }
1100
1122
 
1101
1123
  /** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
1102
1124
  function nixClosureOf(files, { now = Date.now() } = {}) {
1103
1125
  return memoized(files, 'nix-closure', () => {
1104
- const { exports, sources, problems, expected } = loadNixExports(files);
1126
+ const { exports, sources, problems, expected, trust } = loadNixExports(files);
1105
1127
  if (!sources.length) return null;
1106
1128
  const closure = importNixClosure({ exports, expected, now });
1107
1129
  const drvEnv = {};
@@ -1110,15 +1132,19 @@ function nixClosureOf(files, { now = Date.now() } = {}) {
1110
1132
  let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
1111
1133
  for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
1112
1134
  }
1113
- return { closure, drvEnv, sources, problems };
1135
+ // A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
1136
+ // signature) means its contents are not evidence about this build: it is refused, not merely labelled.
1137
+ const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
1138
+ const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
1139
+ return { closure, drvEnv, sources, problems, trust, refused };
1114
1140
  });
1115
1141
  }
1116
1142
 
1117
1143
  /** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
1118
1144
  function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
1119
- const path = env.AGENTIC_SECURITY_NIX_ADVISORIES || (root && (0,external_node_fs_.existsSync)((0,state_dir.statePath)(root, 'nix-advisories.json')) ? (0,state_dir.statePath)(root, 'nix-advisories.json') : null);
1120
- if (!path) return { data: null, reason: 'no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES or provide nix-advisories.json in the project state directory)' };
1121
- let snap; try { snap = JSON.parse((0,external_node_fs_.readFileSync)(path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
1145
+ const sel = (0,trusted_inputs/* resolveOperatorSnapshot */.Rn)({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
1146
+ if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? (0,trusted_inputs/* IGNORED_NOTE */.cY)('nix-advisories.json') : ''}` };
1147
+ let snap; try { snap = JSON.parse((0,external_node_fs_.readFileSync)(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
1122
1148
  const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
1123
1149
  return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
1124
1150
  }
@@ -1134,9 +1160,11 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
1134
1160
  const adv = configuredNixAdvisories(scanRoot, env, hackage);
1135
1161
  let usage = null; try { usage = (0,haskell_supply/* collectUsage */.c$)(files); } catch { usage = null; }
1136
1162
  let matched = null;
1137
- try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
1163
+ if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
1138
1164
  const gaps = [];
1165
+ if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
1139
1166
  for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
1167
+ for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
1140
1168
  for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
1141
1169
  if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
1142
1170
  else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });