Runtime guardrail for AI coding agents: scans an agent's shell commands and written code with xgrep, and its IaC with cnspec, feeding findings back before they…

A Claude Code mod that puts Mondoo's scanners in the loop as an AI agent works — routing each tool call to the right engine:

rm -rf of home or root, a force-push to main, DROP DATABASE, chmod -R 777 /). Each of these is a category you can tune.
main.tf is still caught.So xgrep runs in two complementary modes — prevent secrets/PII leaving (guard) and enforce the OWASP Top 10 on code (scan) — and cnspec adds IaC policy. This single routing and finding logic lives in hooks/core.mjs, shared with the other agent adapters; this file is the Claude Code adapter.
Inline review is advisory (the edit always lands); only the shell guard can hold/block. Everything is fail-open: a scanner that is missing, too old, or errors never wedges your session.
/plugin install secure-guard --marketplace mondoohq/secure-stack
That works in one step on Claude Code 2.1.275 or later. On an older version, add the marketplace first:
claude plugin marketplace add mondoohq/secure-stack
claude plugin install secure-guard@secure-stack
The mod loads in your next session. Run /secure-guard to check it is reaching its engines.
CNSPEC_PATH or cnspec on PATH). If it isn't, the IaC guard stays silently off.The IaC guard works with no configuration. By default it runs the latest public cnspec policy bundles that match the file (Terraform: AWS/Azure/GCP security; Dockerfile and Kubernetes: security and best practices; CloudFormation: AWS security). cnspec downloads them from GitHub when it scans.
Only policies come down. Your files never go up. cnspec evaluates the file on your machine, runs --incognito, and reports results to nowhere but the agent. The first download in a session is announced with a toast and a transcript line, so it is never silent.
Other policy sources, in precedence order:
| Set | Policies | Network |
|---|---|---|
CNSPEC_POLICY_BUNDLE | your bundle(s): comma-separated local paths, https:// or s3:// URLs | only if a URL |
CNSPEC_CONTENT_DIR | the same public bundles, from a local cnspec content/ checkout | none, fully offline |
CNSPEC_USE_PLATFORM=1 | the policies assigned in your logged-in Mondoo Platform space | cnspec reports the scan results to your space (opt-in for that reason) |
| (nothing) | the latest public bundles | policies download; nothing is uploaded |
A custom bundle needs policy filters that match the IaC platform you're scanning (terraform-hcl, dockerfile, k8s, cloudformation), or cnspec has nothing to run.
Every check runs on your machine. xgrep scans each command and file itself (no service in between), and cnspec evaluates IaC files locally. Your commands and code are never uploaded, and nothing is evaluated server-side. That matters for two reasons:
What does cross the network is listed below. The guard announces the xgrep fetch and the policy download in the session the first time each happens:
| What | When | Direction |
|---|---|---|
the xgrep binary, from the public @mondoohq/xgrep npm package | only if no xgrep ≥ 0.84 is installed | down: the scanner, not your data |
| a version check against install.mondoo.com | xgrep's own, when the guard runs xgrep version to probe it; cached for 24 h; off with XGREP_UPDATE_CHECK=0 or DO_NOT_TRACK=1 | down: the latest version number |
| a newer xgrep, from the npm package | only when you run /secure-guard update | down: the scanner |
| cnspec policy bundles, from github.com/mondoohq/cnspec | on an IaC scan, unless CNSPEC_CONTENT_DIR points at a local copy | down: policies only |
| cnspec providers (e.g. its Terraform provider) | on an IaC scan, when cnspec's own --auto-update (on by default) finds one missing or outdated | down: cnspec's plugins |
| scan results to your Mondoo Platform space | only with CNSPEC_USE_PLATFORM=1 | up, by your choice |
| a false-positive issue on github.com/mondoohq/secure-stack (public) | only when you press File issue after reviewing it | up: a minimal repro written for the report, never your files |
Installing xgrep (npm i -g @mondoohq/xgrep) and setting CNSPEC_CONTENT_DIR removes the first two. The provider check is cnspec's own behavior and follows its --auto-update setting.
A rule that fires on correct code costs every user a detour, so the guard gives the agent a way to report it instead of "fixing" code that is already right. Every code finding the agent reads ends with a pointer to the mod's report_false_positive tool, which takes the rule id, the language, a minimal snippet that still triggers the rule, and why the finding is wrong.
xgrep scan --stdin --lang <language> --rule-id <rule> --json on the snippet. A snippet that doesn't trigger the rule, an unknown rule id, or an incomplete report goes back to the agent to fix, so you only ever review reports a maintainer can reproduce.gh issue create --repo mondoohq/secure-stack with the false-positive label (without it, if the repo doesn't have the label). Without a working GitHub CLI, the agent gives you a prefilled link to submit yourself instead.The issue carries the rule id, xgrep version, the repro in a code block, the one-line command that reproduces it, and the reason, which is what a maintainer needs to adjust the rule.
A mod runs with your permissions inside Claude Code. It can read files, start processes (including the xgrep download above), and make network requests. Review it before you install it: claude plugin validate mods/secure-guard lists every event it hooks, every capability it uses ($.process, $.fs, $.http, …), and every environment variable it reads. This mod only reaches outside its own code through those capabilities, so that list is the complete picture. Install mods only from sources you trust.
Run /secure-guard to see how it's reaching each engine (xgrep: daemon / in-process / fetched; cnspec: available / not installed), and whether a newer xgrep is available.
Every shell-guard rule belongs to a category: secrets, pii, remote-code, remote-access, exfiltration, destructive, code-execution, obfuscation. Each one blocks by default (the guard holds the command for you). To change that, set a category's mode to block, ask, warn or off in a guard.yaml. xgrep reads it, so it applies to the guard without any setting in the mod:
# <user config dir>/xgrep/guard.yaml — e.g. ~/.config/xgrep/guard.yaml, or
# ~/Library/Application Support/xgrep/guard.yaml on macOS
categories:
pii: off # we redact PII elsewhere
destructive: ask
A repository's own .xgrep/guard.yaml can only make a category stricter: a repo you clone can't switch your guard off. Run xgrep guard categories to see each category's mode and where it came from; the xgrep guard docs have the details.
New xgrep releases add and sharpen the rules the guard runs, so an outdated xgrep quietly catches less. xgrep already checks for newer releases itself when it reports its version, which the guard does to probe it. The guard reads that answer, so it makes no network call of its own, and xgrep's opt-outs (XGREP_UPDATE_CHECK=0, DO_NOT_TRACK=1) turn the notice off. With an xgrep that supports version --json --check-update, the guard reads the answer as structured data; with an older one, it reads xgrep's update notice instead.
When a newer xgrep is out, the transcript says so, at most once a day per version:
secure-guard: xgrep 0.83.0 is available (you have 0.81.0 at /opt/homebrew/bin/xgrep).
Run /secure-guard update to install it — it runs: npm --prefix /opt/homebrew install -g @mondoohq/xgrep@latest
Run /secure-guard update to install it. The guard then switches to the new binary right away. What the update does depends on how xgrep is installed:
The update only runs when you type /secure-guard update yourself. It never runs for the model, the SDK or another plugin, because it changes software on your machine.
Pure logic (finding filters, path routing, SARIF parsing, advisory text, version checks) is unit-tested; an end-to-end scenario drives the engines against the real binaries:
node --test hooks/*.test.mjs # unit tests (no binaries needed)
claude plugin test . # hook-level tests, run by Claude Code itself
node test/pipeline-scenario.mjs # end-to-end (XGREP_PATH / CNSPEC_PATH to skip the download)
The repo's build/validate/test commands are in the root AGENTS.md.
Both GIFs are real Claude Code sessions, recorded with VHS from the repo root (needs claude, vhs, node):
vhs mods/secure-guard/demo/demo.tape # demo.gif — the shell guard holds a piped installer
vhs mods/secure-guard/demo/inline-review.tape # demo-inline-review.gif — xgrep review, fixed in the same turn
demo/setup.sh runs off camera. It loads this checkout's mod and disables any installed copy, makes sure a guard-capable xgrep is on PATH, and starts Claude in accept-edits mode with Bash and the report tool allowed, so the guard's own panes are the only prompts on screen. The demo project is .demo/acme-app at the repo root (gitignored): it has to live outside the mod's folder, because Claude Code asks before any edit inside a loaded plugin.
The tapes wait for what's on screen rather than fixed timings, so Claude's response time doesn't break a take; its wording varies a little between takes. The installer URL is on the reserved example.com domain, so nothing can run even if Proceed were pressed. The inline review starts from demo/fixtures/app.py, an intentionally vulnerable file.
Apache License 2.0. The xgrep and cnspec binaries it invokes are distributed under their own terms.
hooks/secure-guard.mjs 883 lines1// Copyright (c) Mondoo, Inc.
2// SPDX-License-Identifier: Apache-2.0
3//
4// secure-guard — a Claude Code mod for AI secure development.
5//
6// It puts Mondoo's scanners in the loop as an agent works, routing each tool
7// call to the right engine:
8//
9// 1. Shell guard (xgrep). It holds a Bash tool call, asks xgrep whether the
10// command is risky, and (when it is) shows the findings in a pane with
11// Proceed / Cancel, holding the call until you answer.
12// 2. Inline code review (xgrep). After the agent writes or edits code, it scans
13// the file with xgrep (SAST/SCA/secrets) and hands high-confidence findings
14// back beside the tool result so the agent can fix them in the same turn.
15// 3. IaC policy guard (cnspec). After the agent writes or edits Terraform, a
16// Dockerfile, or a Kubernetes/CloudFormation manifest, it runs cnspec policy
17// checks and hands the violations back the same way. Terraform and
18// Dockerfiles get the xgrep code review too, for hard-coded secrets.
19//
20// All inline review is advisory — the edit always lands; only the shell guard
21// can hold/block. It prefers a local xgrep; if none new enough is installed it
22// fetches one, visibly, from the public npm package. cnspec, if installed, adds
23// the IaC checks; if it is not installed, the IaC guard stays silently off.
24//
25// All scanning is LOCAL: rules execute on this machine (in-process, or via a
26// localhost-only daemon). Commands and code are never uploaded and nothing is
27// evaluated server-side. What does come DOWN, each time visibly: the xgrep
28// binary when none new enough is installed, and — unless CNSPEC_CONTENT_DIR
29// points at a local copy — the latest public cnspec policy bundles. Those are
30// policies, not your data; cnspec runs --incognito and reports nothing. The one
31// exception is opt-in: CNSPEC_USE_PLATFORM runs the policies assigned in your
32// Mondoo Platform space, and cnspec reports those scan results to that space.
33//
34// The mod only reaches outside its own code through the injected `$` API
35// (`$.process`, `$.http`, `$.fs`, `$.store`, `$.env`, `$.ui`, `$.clock`), so
36// `claude plugin validate` can list everything it does before you install it.
37//
38// ─── Two integration seams that depend on xgrep (marked TODO below) ──────────
39// 1. The in-process verdict: `xgrep guard --command <cmd>` scans one command
40// and prints { decision, summary, findings } as JSON. evaluateInProcess
41// calls it. (Needs a recent enough xgrep; older builds lack the flag and
42// the mod fails open.)
43// 2. The warm path: reaching a long-running `xgrep guard daemon` over
44// ConnectRPC so each tool call doesn't start a process. The daemon's
45// Evaluate RPC needs to be reachable off the Unix socket (e.g. a
46// token-authenticated localhost transport) — an upstream xgrep change.
47// Until then the mod uses the in-process path.
48// ─────────────────────────────────────────────────────────────────────────────
49
50// The agent-neutral engine logic (parsing, filtering, routing, formatting,
51// version compare, cnspec bundle mapping) lives in core.mjs, shared with every
52// other agent adapter. This file is the Claude Code adapter: the hooks, the
53// pane UI, and the xgrep/cnspec I/O driven through the `$` API.
54import {
55 XGREP_NPM, XGREP_PIN, XGREP_MIN, CNSPEC_INSTALL_URL,
56 parseVersion, meetsMin, normalizeVerdict,
57 highConfidenceFindings, advisoryText, isScannable,
58 iacScanKind, iacNeedsContent, alsoCodeScan, combineAdvisories,
59 cnspecScanArgs, cnspecPolicySource, cnspecPolicyNotice, sarifFindings, iacAdvisoryText,
60 parseJsonObject, IAC_TIMEOUT_MS,
61 FP_REPO, FP_LABEL, validateFpReport, fpReproArgs, fpReproduces, fpIssue, fpIssueUrl,
62 findingsNotice, findingsToast,
63 npxNodeModulesDir, nativeXgrepCandidates,
64 XGREP_INSTALL_URL, parseUpdateNotice, npmGlobalPrefix, xgrepUpdateArgv, parseVersionJSON,
65} from "./core.mjs";
66
67// The tool the agent calls to report an xgrep false positive (listed to the
68// model as mcp__secure-guard__report_false_positive).
69// (The tool.call matcher spells the full name out so `claude plugin validate`
70// can list it.)
71const FP_TOOL = "report_false_positive";
72// Appended to xgrep code advisories so the agent knows the way out of a wrong
73// finding exists — and that the user stays in control of it.
74const FP_HINT =
75 `If you are confident a finding is a false positive, explain why instead of changing correct ` +
76 `code, and you may offer to report it with the ${FP_TOOL} tool (the user reviews the issue ` +
77 `before anything is filed).`;
78
79const DOCS_URL = "https://mondoo.com/docs/xgrep/ai-agents/guard-hooks"; // what the guard does
80const NPM_URL = "https://www.npmjs.com/package/@mondoohq/xgrep"; // where the binary comes from
81const PANE_ID = "secure-guard";
82const POLL = "0.25"; // seconds; held in a free `$.process.run(["sleep",…])`
83const HOLD_LIMIT_MS = 10 * 60 * 1000;
84
85// cnspec (IaC policy engine) resolution state. cnspec has no npm package, so
86// resolution is CNSPEC_PATH → `cnspec` on PATH → unavailable (IaC guard off).
87let cnspecBackend = null; // { mode: "ok", cmd } | { mode: "unavailable", reason }
88// Whether this session has shown the policy-source notice (download/Platform).
89let cnspecNoticeShown = false;
90
91// Inline review timeout; fail-open on any error/timeout. The skip set + the
92// "is this file worth scanning" decision live in core (isScannable).
93const REVIEW_TIMEOUT_MS = 10000;
94
95// Resolved once per session: how we reach xgrep this session.
96// { mode: "daemon", cmd, addr, token } | { mode: "inproc", cmd } |
97// { mode: "unavailable", reason }
98let backend = null;
99// The call currently held for review, or null. One at a time.
100let held = null;
101// Where this session already said a recurring failure out loud (see warnOnce).
102const warned = new Set();
103// $.store key for the native xgrep behind the pinned npx package (see
104// resolveNativeXgrep); keyed by the pin so a new pin resolves afresh.
105const NATIVE_XGREP_KEY = `native-xgrep-${XGREP_PIN}`;
106// A newer xgrep the user could update to, noticed during the backend probe:
107// { current, latest, path, prefix, canUpdate } (see noteXgrepUpdate), or null.
108let xgrepUpdate = null;
109// The update note in flight (noteXgrepUpdate runs in the background so it
110// never delays a tool call; /secure-guard update waits for it).
111let xgrepUpdateNoted = Promise.resolve();
112// How often the same available version is announced.
113const UPDATE_NOTICE_EVERY_MS = 24 * 60 * 60 * 1000;
114
115export function register(on) {
116 on("session.start", async ($, e, next) => {
117 // Resolve the engines in the background so the session starts right away.
118 $.clock.after(0, () => ensureBackend($).catch(() => {}));
119 $.clock.after(0, () => ensureCnspec($).catch(() => {}));
120 try {
121 await $.command.register({
122 name: "secure-guard",
123 description: "Show how secure-guard is reaching xgrep and cnspec; `update` installs a newer xgrep",
124 argumentHint: "[update]",
125 });
126 } catch {
127 // name already taken — fine
128 }
129 try {
130 await $.tool.register({
131 name: FP_TOOL,
132 description:
133 `Report an xgrep finding you are confident is a false positive as an issue on ` +
134 `github.com/${FP_REPO} (public), so the rule can be fixed. Provide a MINIMAL, ` +
135 `SELF-CONTAINED snippet written for the report that still triggers the rule — never ` +
136 `paste the user's own code, names, paths, or secrets. The snippet is checked with ` +
137 `xgrep first; the user then sees the exact issue and decides whether to file it.`,
138 inputSchema: {
139 type: "object",
140 properties: {
141 rule: { type: "string", description: "The finding's rule id, e.g. python-sql-injection." },
142 language: { type: "string", description: "The snippet's language as xgrep names it: python, javascript, typescript, go, java, …" },
143 snippet: { type: "string", description: "A minimal synthetic reproduction (a few lines) that still triggers the rule but is safe/correct code." },
144 reason: { type: "string", description: "Why the finding is a false positive: what makes this code safe." },
145 expected: { type: "string", description: "Optional: what the rule should do instead." },
146 },
147 required: ["rule", "language", "snippet", "reason"],
148 },
149 });
150 } catch {
151 // registration unavailable here — the rest of the guard still works
152 }
153 return next(e);
154 });
155
156 // False-positive reports: verify the reproduction, let the user review the
157 // exact issue, and only then file it. Never fails the session: any error
158 // becomes a "not filed" answer the agent can relay.
159 on("tool.call", { tool: "mcp__secure-guard__report_false_positive" }, async ($, e, next) => {
160 try {
161 return { result: await reportFalsePositive($, e, next) };
162 } catch (err) {
163 return { result: `Not filed: secure-guard hit an error (${err?.message ?? err}).` };
164 }
165 });
166
167 // A /secure-guard command the user can run to see (and re-resolve) the engines.
168 on("command.run", { command: "secure-guard" }, async ($, e) => {
169 if (String(e.args ?? "").trim() === "update") return { text: await runXgrepUpdate($, e) };
170 const b = await ensureBackend($);
171 const xline = {
172 daemon: `warm daemon at ${b.addr}`,
173 inproc: `in-process via ${b.cmd?.join(" ")}`,
174 unavailable: `not available: ${b.reason}`,
175 }[b.mode];
176 const c = await ensureCnspec($);
177 const cline = c.mode === "ok" ? `via ${c.cmd.join(" ")}` : `not available: ${c.reason}`;
178 return {
179 text:
180 `secure-guard\n` +
181 ` xgrep (shell + code): ${xline}\n` +
182 ` cnspec (IaC policy): ${cline}\n` +
183 (xgrepUpdate
184 ? ` update available: xgrep ${xgrepUpdate.latest ?? "(newer)"} — run /secure-guard update\n`
185 : "") +
186 `Docs: ${DOCS_URL}\n` +
187 `npm package (xgrep): ${NPM_URL}`,
188 };
189 });
190
191 // The guard itself: scan a Bash command before it runs. The whole body is
192 // wrapped so any unexpected throw fails OPEN (the command runs) rather than
193 // breaking the gate — a scanner mod must never wedge the user's session.
194 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
195 try {
196 return await guardBashCall($, e, next);
197 } catch (err) {
198 warnOnce($, "bash-error", `xgrep guard: unexpected error (${err?.message ?? err}); allowing`);
199 return next(e);
200 }
201 });
202
203 // Inline review: let the write/edit happen, then scan the file it touched and,
204 // if there are high-confidence security findings, attach them to the tool's
205 // result (withAdvisory) so Claude sees them and can fix them in the same turn.
206 // `next` runs the tool exactly once (outside the scan try/catch), so any scan
207 // error fails OPEN — the edit stays, we just stay quiet.
208 on("tool.call", { tool: ["Write", "Edit", "MultiEdit"] }, async ($, e, next) => {
209 const r = await next(e);
210 if (!r || r.deny || r.isError) return r; // tool blocked or failed — nothing landed
211 try {
212 // Route by artifact: IaC (Terraform/Dockerfile/K8s/CFN) → cnspec policy,
213 // plus xgrep for Terraform/Dockerfiles; everything else → xgrep review.
214 const file = String(e.file_path ?? "");
215 const kind = iacScanKind(file, await contentForRouting($, e, file));
216 if (kind) return await reviewIac($, e, r, kind);
217 return await reviewEdit($, e, r);
218 } catch (err) {
219 warnOnce($, "review-error", `secure-guard: inline review error (${err?.message ?? err}); not reporting`);
220 return r;
221 }
222 });
223
224 on("ui.render", { component: "Pane" }, ($, e, next) => {
225 if (e.requestId !== PANE_ID || held === null) return next(e);
226 return draw($.ui.resolve(e), held);
227 });
228 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
229 if (held === null || held.where !== "band") return next(e);
230 return draw($.ui.resolve(e), held);
231 });
232}
233
234// guardBashCall holds + reviews a single Bash command. Separated out so the
235// registered hook stays a thin try/catch wrapper around it (fail-open).
236async function guardBashCall($, e, next) {
237 {
238 const command = String(e.command ?? "");
239 if (command.trim() === "") return next(e);
240
241 const b = await ensureBackend($);
242 if (b.mode === "unavailable") {
243 // Fail OPEN: never block the user's work because the scanner couldn't load.
244 // (A fail-closed policy could be a userConfig option.)
245 $.ui.log(`xgrep guard: ${b.reason} — not scanning. ${DOCS_URL}`);
246 return next(e);
247 }
248
249 let verdict;
250 try {
251 verdict = await evaluate($, b, command);
252 } catch (err) {
253 warnOnce($, "evaluate-failed", `xgrep guard: evaluate failed (${err?.message ?? err}); allowing`);
254 return next(e); // fail open on an evaluate error
255 }
256 if (!verdict || verdict.decision === "allow") {
257 return next(e);
258 }
259
260 // Risky → hold the call behind a pane until the user decides.
261 const mine = { kind: "command", command, verdict };
262 await awaitDecision($, next.signal, mine, { title: "xgrep guard", rows: paneRows(verdict) });
263
264 if (mine.decision === "proceed") {
265 $.ui.toast("xgrep guard: running it");
266 return next(e);
267 }
268 const why = {
269 cancel: "you pressed Cancel",
270 timeout: "no answer within 10 minutes",
271 interrupted: "the turn was interrupted",
272 error: "xgrep guard hit an error while holding it",
273 }[mine.decision] ?? "no answer was recorded";
274 return denyResult(why, verdict);
275 }
276}
277
278// awaitDecision shows `mine` in the pane (or the band above the prompt when the
279// pane can't be placed) and waits until the user presses a button, the turn is
280// interrupted, or HOLD_LIMIT_MS passes; it sets mine.decision. One request is
281// shown at a time; a second waits for the first. `mine.kind` picks the drawing.
282async function awaitDecision($, signal, mine, { title, rows }) {
283 mine.decision = null;
284 mine.where = "pane";
285 while (held !== null) {
286 if (signal.aborted) { mine.decision = "interrupted"; return; }
287 await $.process.run(["sleep", POLL], { timeoutMs: 5000 });
288 }
289 held = mine;
290 let opened = { isPlaced: false };
291 try {
292 opened = await $.ui.open({ id: PANE_ID, title, focus: true, rows });
293 if (!opened.isPlaced) mine.where = "band";
294 $.ui.invalidate("ui.render");
295
296 const start = await $.clock.now();
297 while (mine.decision === null) {
298 if (signal.aborted) { mine.decision = "interrupted"; break; }
299 if ((await $.clock.now()) - start > HOLD_LIMIT_MS) { mine.decision = "timeout"; break; }
300 await $.process.run(["sleep", POLL], { timeoutMs: 5000 });
301 }
302 } catch {
303 mine.decision = "error";
304 } finally {
305 try { if (opened.isPlaced) await $.ui.close({ id: PANE_ID }); } catch {}
306 if (held === mine) held = null;
307 $.ui.invalidate("ui.render");
308 }
309}
310
311function denyResult(why, verdict) {
312 return {
313 deny:
314 `xgrep guard held this command and did not run it: ${why}. ` +
315 `xgrep flagged ${verdict.flagged}. Do not retry it unless the user asks you to.`,
316 };
317}
318
319// ─── Backend resolution ──────────────────────────────────────────────────────
320
321// ensureBackend resolves (once per session) how to reach an xgrep new enough
322// for the guard. Precedence:
323// 1. XGREP_PATH env, if it meets XGREP_MIN
324// 2. `xgrep` on PATH, if it meets XGREP_MIN
325// 3. the pinned release via `npx` — fetched VISIBLY — when nothing local
326// qualifies: a fresh install, OR an installed xgrep too old for the guard,
327// which the mod "updates" past the way an IDE pulls its own managed tool
328// 4. unavailable
329async function ensureBackend($) {
330 if (backend) return backend;
331
332 const candidates = [];
333 const envPath = await $.env.get("XGREP_PATH"); // $.env.get resolves async
334 if (envPath) candidates.push([envPath]);
335 candidates.push(["xgrep"]);
336
337 let outdated = null; // a local xgrep that runs but is older than XGREP_MIN
338 for (const cmd of candidates) {
339 const p = await probe($, cmd);
340 if (!p.ok) continue;
341 if (meetsMin(p.version)) {
342 backend = await connectOrInproc($, cmd);
343 xgrepUpdateNoted = noteXgrepUpdate($, cmd, p.update).catch(() => {});
344 return backend;
345 }
346 outdated = p.version ?? "an older build";
347 // Too old for the guard: worth updating whether or not a release notice came.
348 xgrepUpdateNoted = noteXgrepUpdate($, cmd, p.update ?? { current: p.version ?? "an older build", latest: null }).catch(() => {});
349 }
350
351 // Nothing local qualifies. A native binary resolved from the pinned npx
352 // package in an earlier session needs no fetch and no npx round trip.
353 const cached = await $.store.get(NATIVE_XGREP_KEY).catch(() => undefined);
354 if (typeof cached === "string" && cached) {
355 const c = await probe($, [cached]);
356 if (c.ok && meetsMin(c.version)) {
357 backend = await connectOrInproc($, [cached]);
358 if (!outdated) xgrepUpdateNoted = noteXgrepUpdate($, null, c.update).catch(() => {}); // no local install
359 return backend;
360 }
361 await $.store.delete(NATIVE_XGREP_KEY).catch(() => {});
362 }
363
364 // Fetch the pinned release via npx — this installs xgrep when it is missing
365 // and updates past a too-old install, the way VS Code / IntelliJ pull their
366 // own managed copy rather than touch yours.
367 if (outdated) {
368 $.ui.toast(`xgrep guard: xgrep ${outdated} is older than ${XGREP_MIN}; updating to ${XGREP_PIN}`);
369 $.ui.log(`xgrep guard: your xgrep (${outdated}) predates the guard's minimum (${XGREP_MIN}), so the shell check can't run on it. Fetching ${XGREP_NPM}@${XGREP_PIN} via npx for this session. To update your own install: npm i -g ${XGREP_NPM}@latest. See ${DOCS_URL}`);
370 } else {
371 $.ui.toast(`xgrep guard: fetching xgrep from npm (${XGREP_NPM})`);
372 $.ui.log(`xgrep guard: xgrep is not installed. Fetching ${XGREP_NPM}@${XGREP_PIN} via npx. See ${DOCS_URL}`);
373 }
374 const npx = ["npx", "-y", `${XGREP_NPM}@${XGREP_PIN}`];
375 const p = await probe($, npx);
376 if (p.ok && meetsMin(p.version)) {
377 // Call the native binary npx just fetched, not npx: npx re-resolves the
378 // package on every call (seconds on a busy machine — long enough to time
379 // the guard out and let commands through unchecked).
380 const native = await resolveNativeXgrep($);
381 if (native) await $.store.set(NATIVE_XGREP_KEY, native).catch(() => {});
382 backend = await connectOrInproc($, native ? [native] : npx);
383 if (!outdated) xgrepUpdateNoted = noteXgrepUpdate($, null, p.update).catch(() => {}); // no local install
384 return backend;
385 }
386 backend = { mode: "unavailable", reason: `no xgrep >= ${XGREP_MIN} found or fetchable (${XGREP_NPM}). Install it: ${NPM_URL}` };
387 return backend;
388}
389
390// resolveNativeXgrep finds the platform binary inside the pinned npx package:
391// npx puts the package's bin shim on PATH, the shim's node_modules holds
392// @mondoohq/xgrep_<os>_<arch>/ (npm installs only this platform's), and the
393// binary there is what the shim would exec. null when it can't be found or
394// doesn't run — the caller keeps using npx.
395async function resolveNativeXgrep($) {
396 for (const finder of [["which", "xgrep"], ["where", "xgrep"]]) {
397 let r;
398 try {
399 r = await $.process.run(["npx", "-y", "-p", `${XGREP_NPM}@${XGREP_PIN}`, ...finder], { timeoutMs: 120000 });
400 } catch {
401 continue;
402 }
403 if (r.exitCode !== 0) continue;
404 const shim = String(r.stdout ?? "").split(/\r?\n/).map((l) => l.trim()).find(Boolean);
405 const nodeModules = npxNodeModulesDir(shim);
406 if (!nodeModules) continue;
407 const scope = `${nodeModules}/@mondoohq`;
408 const entries = await $.fs.list(scope).catch(() => []);
409 for (const rel of nativeXgrepCandidates(entries.map((e) => e.name))) {
410 const bin = `${scope}/${rel}`;
411 if (!(await $.fs.exists(bin).catch(() => false))) continue;
412 const v = await probe($, [bin]);
413 if (v.ok && meetsMin(v.version)) return bin;
414 }
415 }
416 return null;
417}
418
419// warnOnce logs a recurring failure the first time it happens in a session,
420// with a toast saying what it means — a guard that fails open on every call
421// should say so once, clearly, not scroll the same line past on each command.
422function warnOnce($, key, line) {
423 if (warned.has(key)) return;
424 warned.add(key);
425 $.ui.log(`${line} (further occurrences this session are not logged)`);
426 $.ui.toast("secure-guard: a scan failed and the action went through unchecked — see the transcript");
427}
428
429// ─── Keeping the user's xgrep current ────────────────────────────────────────
430
431// noteXgrepUpdate records that a newer xgrep is available for the binary the
432// guard runs (`cmd`; null when xgrep comes from the npx package, i.e. there is
433// no local install) and tells the user — at most once per version a day, so a
434// version they don't want right now doesn't nag every session.
435async function noteXgrepUpdate($, cmd, update) {
436 if (!update) return;
437 let path = null;
438 let prefix = null;
439 if (cmd && cmd.length === 1) {
440 path = cmd[0];
441 if (!/[\\/]/.test(path)) path = await whichXgrep($, path);
442 const real = path ? (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath : undefined;
443 prefix = npmGlobalPrefix(real ?? path);
444 }
445 // An npm global install can be updated in place; no local install at all can
446 // get one; anything else (a release download, a dev build) is the user's.
447 const canUpdate = cmd === null || prefix !== null;
448 xgrepUpdate = { current: update.current, latest: update.latest, path, prefix, canUpdate };
449
450 const key = `xgrep-update-noticed-${update.latest ?? `below-${XGREP_MIN}`}`;
451 const last = await $.store.get(key).catch(() => undefined);
452 const now = await $.clock.now();
453 if (typeof last === "number" && now - last < UPDATE_NOTICE_EVERY_MS) return;
454 await $.store.set(key, now).catch(() => {});
455
456 const what = update.latest
457 ? `xgrep ${update.latest} is available (you have ${update.current}${path ? ` at ${path}` : ""}).`
458 : `xgrep ${update.current}${path ? ` at ${path}` : ""} is too old for the guard (needs ${XGREP_MIN}).`;
459 $.ui.log(
460 canUpdate
461 ? `secure-guard: ${what} Run /secure-guard update to install it — it runs: ${xgrepUpdateArgv(prefix).join(" ")}`
462 : `secure-guard: ${what} It isn't an npm install, so update it the way you installed it: ${XGREP_INSTALL_URL}`,
463 );
464 $.ui.toast(canUpdate ? "secure-guard: a newer xgrep is available — /secure-guard update" : "secure-guard: a newer xgrep is available");
465}
466
467async function whichXgrep($, name) {
468 for (const finder of [["which", name], ["where", name]]) {
469 try {
470 const r = await $.process.run(finder, { timeoutMs: 10000 });
471 const line = String(r.stdout ?? "").split(/\r?\n/).map((l) => l.trim()).find(Boolean);
472 if (r.exitCode === 0 && line) return line;
473 } catch {
474 // try the next finder
475 }
476 }
477 return null;
478}
479
480// runXgrepUpdate answers `/secure-guard update`: it installs the newer xgrep
481// with npm, then switches the guard to it. It runs only when the person typed
482// the command (origin "composer") — never for the model, the SDK or a plugin —
483// because it changes software on their machine.
484async function runXgrepUpdate($, e) {
485 if (e?.origin?.kind !== "composer") {
486 return "secure-guard: `/secure-guard update` only runs when you type it yourself.";
487 }
488 await ensureBackend($);
489 await xgrepUpdateNoted; // the probe's update note may still be in flight
490 const u = xgrepUpdate;
491 if (!u) return "secure-guard: xgrep is up to date — nothing to update.";
492 if (!u.canUpdate) {
493 return `secure-guard: this xgrep${u.path ? ` (${u.path})` : ""} isn't an npm install, so the guard won't change it. ` +
494 `Update it the way you installed it: ${XGREP_INSTALL_URL}`;
495 }
496 const argv = xgrepUpdateArgv(u.prefix);
497 $.ui.toast("secure-guard: updating xgrep…");
498 let r;
499 try {
500 r = await $.process.run(argv, { timeoutMs: 10 * 60 * 1000 });
501 } catch (err) {
502 return `secure-guard: the update didn't run (${err?.message ?? err}). Run it yourself: ${argv.join(" ")}`;
503 }
504 if (r.exitCode !== 0) {
505 const tail = String(r.stderr || r.stdout || "").trim().split("\n").slice(-5).join("\n");
506 return `secure-guard: the update failed (exit ${r.exitCode}):\n${tail}\nRun it yourself: ${argv.join(" ")}`;
507 }
508 // Re-resolve, so the guard runs the binary that was just installed.
509 backend = null;
510 xgrepUpdate = null;
511 const b = await ensureBackend($);
512 const v = b.cmd ? await probe($, b.cmd) : { ok: false };
513 return `secure-guard: updated xgrep${v.ok ? ` to ${v.version}` : ""} (${argv.join(" ")}). The guard uses it from now on.`;
514}
515
516// probe runs `xgrep version` and returns whether it ran and the semver it
517// reported (e.g. "xgrep 0.80.0 (commit: …)" -> "0.80.0").
518async function probe($, cmd) {
519 // Structured first: `version --json --check-update` (xgrep#3238) reports the
520 // version and any newer release as data. An older xgrep rejects the flag, so
521 // fall back to the text form, whose update notice rides along on stderr.
522 try {
523 const j = await $.process.run([...cmd, "version", "--json", "--check-update"], { timeoutMs: 120000 });
524 const doc = j.exitCode === 0 ? parseVersionJSON(j.stdout) : null;
525 if (doc) return { ok: true, version: doc.version, update: doc.update };
526 } catch {
527 // fall through to the text form
528 }
529 try {
530 const r = await $.process.run([...cmd, "version"], { timeoutMs: 120000 });
531 if (r.exitCode !== 0) return { ok: false };
532 return { ok: true, version: parseVersion(r.stdout), update: parseUpdateNotice(r.stderr) };
533 } catch {
534 return { ok: false };
535 }
536}
537
538// connectOrInproc: start/reach the warm daemon over ConnectRPC if possible, else
539// use the in-process path. The daemon path needs an upstream xgrep change (the
540// Evaluate RPC reachable off the Unix socket), so today this returns in-process.
541async function connectOrInproc($, cmd) {
542 // TODO: start `xgrep guard daemon --addr 127.0.0.1:0
543 // --token-file <tmp>` (or reuse a running one), read its addr + token, confirm
544 // GuardService is reachable, and return { mode: "daemon", cmd, addr, token }.
545 // Cache addr/token in $.store so later sessions reuse the warm daemon.
546 return { mode: "inproc", cmd };
547}
548
549// ─── Evaluate ────────────────────────────────────────────────────────────────
550
551// evaluate returns a verdict: { decision: "allow"|"ask"|"deny", summary, lines }.
552async function evaluate($, b, command) {
553 if (b.mode === "daemon") return evaluateConnect($, b, command);
554 return evaluateInProcess($, b, command);
555}
556
557// evaluateConnect calls GuardService.Evaluate over the Connect protocol: a plain
558// HTTP POST to /<service>/<method> with a JSON body. (Needs the upstream change
559// that makes Evaluate reachable off the Unix socket.)
560async function evaluateConnect($, b, command) {
561 const url = `http://${b.addr}/xgrep.guard.v1.GuardService/Evaluate`;
562 const res = await $.http.fetch(url, {
563 method: "POST",
564 headers: {
565 "Content-Type": "application/json",
566 // TODO: match the daemon's token scheme for the --token-file TCP path.
567 Authorization: `Bearer ${b.token}`,
568 },
569 // TODO: match the real Evaluate request message shape.
570 body: JSON.stringify({ command }),
571 });
572 if (!res.ok) throw new Error(`daemon Evaluate HTTP ${res.status}`);
573 return normalizeVerdict(JSON.parse(res.text));
574}
575
576// evaluateInProcess shells out to xgrep for a one-shot verdict.
577async function evaluateInProcess($, b, command) {
578 // `xgrep guard --command <cmd>` scans one command and prints
579 // { decision, summary, findings:[…] } as JSON, exiting 0.
580 const r = await $.process.run([...b.cmd, "guard", "--command", command], { timeoutMs: 15000 });
581 if (r.stdout && r.stdout.trim().startsWith("{")) {
582 try { return normalizeVerdict(JSON.parse(r.stdout)); } catch {}
583 }
584 // No parseable verdict (e.g. an xgrep too old to know --command) → fail open.
585 return { decision: "allow", summary: "", lines: [] };
586}
587
588// ─── Inline review ───────────────────────────────────────────────────────────
589
590// contentForRouting returns the file's full content when routing needs it. A
591// Write carries it; an Edit/MultiEdit carries only a fragment, so for a path
592// that can only be classified by content (YAML/JSON → K8s? CFN?) read the file
593// as it now stands — the edit has already landed. Unreadable → undefined, and
594// the file is simply not treated as IaC (fail-open).
595async function contentForRouting($, e, file) {
596 if (typeof e.content === "string") return e.content;
597 if (!file || !iacNeedsContent(file)) return undefined;
598 try {
599 return await $.fs.read(file);
600 } catch {
601 return undefined;
602 }
603}
604
605// reviewEdit scans the file a Write/Edit/MultiEdit just touched and, if there are
606// high-confidence security findings, returns the tool result augmented with them.
607// `r` is what the tool itself returned (the write already happened). On anything
608// uninteresting it returns `r` unchanged.
609async function reviewEdit($, e, r) {
610 if (!r || r.deny || r.isError) return r; // tool blocked or failed — nothing landed
611 return withAdvisory(r, await codeAdvisory($, String(e.file_path ?? "")));
612}
613
614// withAdvisory attaches findings to the tool's own result as `context`, which
615// the model reads right after the result (like a PostToolUse reminder). The
616// result itself must stay the tool's record: core validates a hook's `result`
617// against the tool's output schema, and a bare string there turns the
618// successful write into a tool error the model can't act on.
619function withAdvisory(r, text) {
620 if (!text) return r;
621 return { ...r, context: [...(r.context ?? []), text] };
622}
623
624// codeAdvisory runs the xgrep code review on one file and returns the advisory
625// text, or null when the file isn't scannable, xgrep isn't here, or it's clean.
626async function codeAdvisory($, file) {
627 if (!file || !isScannable(file)) return null;
628 const b = await ensureBackend($);
629 if (b.mode === "unavailable") return null; // scanner not here — stay quiet (fail open)
630 const findings = await scanFile($, b, file);
631 if (!findings.length) return null;
632 announceFindings($, "xgrep", findings, file);
633 return `${advisoryText(file, findings)}\n${FP_HINT}`;
634}
635
636// announceFindings tells the user what the agent was just handed: advisories
637// reach the model as `context`, which the transcript doesn't show, so without
638// this the user sees the agent change code they didn't ask about with no
639// visible reason. A transcript line (kept, right under the edit) says what
640// Mondoo caught; a toast draws the eye to it.
641function announceFindings($, engine, findings, file) {
642 const line = findingsNotice(engine, file, findings);
643 if (line === null) return; // nothing handed over — nothing to show
644 $.ui.log(line);
645 $.ui.toast(findingsToast(engine, file, findings));
646}
647
648// scanFile runs xgrep over one file and returns the high-confidence security
649// findings. Filtering is done here on the JSON (confidence HIGH, not a test/
650// fixture path) rather than via the CLI severity floor — a rule's `confidence`
651// is the reliable "this is real" signal; `severity` can read INFO on a
652// high-confidence match.
653async function scanFile($, b, file) {
654 const run = await $.process.run([...b.cmd, "scan", file, "--json"], { timeoutMs: REVIEW_TIMEOUT_MS });
655 const out = (run.stdout ?? "").trim();
656 if (!out.startsWith("{")) return []; // no JSON (e.g. an xgrep too old) → stay quiet
657 let doc;
658 try { doc = JSON.parse(out); } catch { return []; }
659 return highConfidenceFindings(doc);
660}
661
662// ─── False-positive reports ──────────────────────────────────────────────────
663
664// reportFalsePositive answers the report_false_positive tool with the text the
665// agent reads. Order matters: validate, prove the snippet reproduces, THEN show
666// the user the exact issue — so they never review a report that wouldn't help
667// a maintainer — and file only on their say-so. Nothing leaves the machine
668// before that press.
669async function reportFalsePositive($, e, next) {
670 const problems = validateFpReport(e);
671 if (problems.length) return `Not filed — fix the report and call again:\n- ${problems.join("\n- ")}`;
672 const rule = String(e.rule).trim();
673 const language = String(e.language).trim();
674 const snippet = String(e.snippet);
675
676 const b = await ensureBackend($);
677 if (b.mode === "unavailable") return `Not filed: xgrep isn't available to check the reproduction (${b.reason}).`;
678 const run = await $.process.run([...b.cmd, ...fpReproArgs(rule, language)], { stdin: snippet, timeoutMs: REVIEW_TIMEOUT_MS });
679 const doc = parseJsonObject(run.stdout ?? "");
680 if (!doc) {
681 const why = String(run.stderr ?? "").trim().split("\n")[0] || `exit code ${run.exitCode}`;
682 return `Not filed: xgrep could not check the snippet (${why}). Check the rule id and language.`;
683 }
684 if (!fpReproduces(doc, rule)) {
685 return `Not filed: the snippet does not trigger ${rule}, so it wouldn't reproduce the false positive. ` +
686 `Write a minimal snippet that still triggers ${rule} and call again.`;
687 }
688
689 const issue = fpIssue({ rule, language, snippet, reason: e.reason, expected: e.expected, xgrepVersion: doc.version });
690 const mine = { kind: "report", rule, language, snippet, reason: String(e.reason).trim(), issue };
691 await awaitDecision($, next.signal, mine, { title: "Report a false positive", rows: 18 });
692 if (mine.decision !== "file") {
693 const why = { cancel: "the user pressed Cancel", timeout: "no answer within 10 minutes", interrupted: "the turn was interrupted" }[mine.decision]
694 ?? "the review didn't complete";
695 return `Not filed: ${why}. Don't report it again unless the user asks.`;
696 }
697 return await fileIssue($, issue);
698}
699
700// fileIssue files with the GitHub CLI (the user's own account), with the
701// false-positive label when the repo has it. Without a working `gh`, it hands
702// back a prefilled link for the user to submit themselves.
703async function fileIssue($, issue) {
704 const create = (withLabel) => $.process.run(
705 ["gh", "issue", "create", "--repo", FP_REPO, "--title", issue.title, "--body-file", "-", ...(withLabel ? ["--label", FP_LABEL] : [])],
706 { stdin: issue.body, timeoutMs: 60000 },
707 );
708 let run;
709 try {
710 run = await create(true);
711 if (run.exitCode !== 0 && /label/i.test(run.stderr ?? "")) run = await create(false);
712 } catch (err) {
713 run = { exitCode: 127, stdout: "", stderr: String(err?.message ?? err) };
714 }
715 const url = (String(run.stdout ?? "").match(/https:\/\/github\.com\/\S+\/issues\/\d+/) ?? [])[0];
716 if (run.exitCode === 0 && url) {
717 $.ui.toast(`secure-guard: filed ${url}`);
718 return `Filed ${url}. Tell the user, and keep the code as it is.`;
719 }
720 const why = String(run.stderr ?? "").trim().split("\n")[0] || "the GitHub CLI isn't available";
721 return `Not filed automatically (${why}). Give the user this link to review and submit it themselves:\n${fpIssueUrl(issue)}`;
722}
723
724// ─── cnspec (IaC policy) engine ──────────────────────────────────────────────
725
726// ensureCnspec resolves cnspec once per session: CNSPEC_PATH → `cnspec` on PATH
727// → unavailable. cnspec has no npm package, so when it is absent the IaC guard
728// simply stays off (fail-open); we point the user at the install docs.
729async function ensureCnspec($) {
730 if (cnspecBackend) return cnspecBackend;
731 const candidates = [];
732 const envPath = await $.env.get("CNSPEC_PATH");
733 if (envPath) candidates.push([envPath]);
734 candidates.push(["cnspec"]);
735 for (const cmd of candidates) {
736 try {
737 const r = await $.process.run([...cmd, "version"], { timeoutMs: 120000 });
738 if (r.exitCode === 0) { cnspecBackend = { mode: "ok", cmd }; return cnspecBackend; }
739 } catch { /* try next */ }
740 }
741 cnspecBackend = { mode: "unavailable", reason: `cnspec not installed — install it for IaC policy checks: ${CNSPEC_INSTALL_URL}` };
742 return cnspecBackend;
743}
744
745// reviewIac scans an IaC file the agent just wrote with cnspec — and, for
746// Terraform and Dockerfiles, with xgrep too — and returns any findings as the
747// tool result. The two engines run side by side and each fails open on its own:
748// one missing or erroring never hides the other's findings.
749async function reviewIac($, e, r, kind) {
750 const file = String(e.file_path ?? "");
751 if (!file) return r;
752 const quiet = (leg) => (err) => {
753 $.ui.log(`secure-guard: ${leg} review error (${err?.message ?? err}); not reporting`);
754 return null;
755 };
756 const [iac, code] = await Promise.all([
757 iacAdvisory($, file, kind).catch(quiet("cnspec")),
758 alsoCodeScan(kind) ? codeAdvisory($, file).catch(quiet("xgrep")) : null,
759 ]);
760 return withAdvisory(r, combineAdvisories([iac, code]));
761}
762
763// iacAdvisory runs cnspec on one IaC file and returns the advisory text, or null
764// when cnspec isn't installed or every check passed.
765async function iacAdvisory($, file, kind) {
766 const b = await ensureCnspec($);
767 if (b.mode !== "ok") return null; // cnspec not installed — stay quiet
768 const findings = await cnspecScan($, b, kind, file);
769 if (!findings.length) return null;
770 announceFindings($, "cnspec", findings, file);
771 return iacAdvisoryText(file, kind, findings);
772}
773
774async function cnspecScan($, b, kind, file) {
775 const source = cnspecPolicySource(kind, {
776 CNSPEC_POLICY_BUNDLE: await $.env.get("CNSPEC_POLICY_BUNDLE"),
777 CNSPEC_CONTENT_DIR: await $.env.get("CNSPEC_CONTENT_DIR"),
778 CNSPEC_USE_PLATFORM: await $.env.get("CNSPEC_USE_PLATFORM"),
779 });
780 const notice = cnspecPolicyNotice(source);
781 if (notice && !cnspecNoticeShown) {
782 cnspecNoticeShown = true;
783 $.ui.toast(source.kind === "platform"
784 ? "secure-guard: cnspec is using your Mondoo Platform policies"
785 : "secure-guard: cnspec is downloading the latest policies (nothing is uploaded)");
786 $.ui.log(`secure-guard: ${notice}`);
787 }
788 const args = cnspecScanArgs(kind, file, source.bundles, { platform: source.kind === "platform" });
789 const run = await $.process.run([...b.cmd, ...args], { timeoutMs: IAC_TIMEOUT_MS });
790 const doc = parseJsonObject(run.stdout ?? "");
791 return doc ? sarifFindings(doc) : [];
792}
793
794// ─── Drawing (pane / band) ───────────────────────────────────────────────────
795
796function paneRows(v) {
797 return Math.min(24, 6 + (v.lines?.length ?? 0));
798}
799
800// The pane says what it is doing — holding the call until you choose — and
801// lists what xgrep flagged in its own words (not xgrep's "blocked … retry"
802// summary, which is written for a hook that blocks outright).
803function draw(t, state) {
804 return state.kind === "report" ? drawReport(t, state) : drawCommand(t, state);
805}
806
807// drawReport previews the issue exactly as it would be filed — the user is the
808// one publishing it, to a public repo.
809function drawReport(t, state) {
810 const { Box, Text, Button } = t;
811 const decide = (choice) => () => { if (state.decision === null) state.decision = choice; };
812 const shown = state.snippet.replace(/\s+$/, "").split("\n");
813 const snippet = shown.slice(0, 8).map((line, i) => Text({ key: `s${i}`, wrap: "truncate-end", children: ` ${line}` }));
814 if (shown.length > 8) snippet.push(Text({ key: "more", dimColor: true, children: ` … ${shown.length - 8} more line(s)` }));
815 const row = (key, label, value) => Text({ key, wrap: "truncate-end", children: [Text({ dimColor: true, children: label }), Text({ children: value })] });
816 return Box({
817 flexDirection: "column",
818 borderStyle: "round",
819 borderColor: "cyan",
820 paddingX: 1,
821 children: [
822 Text({ key: "title", children: [
823 Text({ bold: true, color: "cyan", children: "Report a false positive " }),
824 Text({ dimColor: true, children: `to github.com/${FP_REPO} (public)` }),
825 ] }),
826 row("t", "Title ", state.issue.title),
827 row("r", "Why ", state.reason.split("\n")[0]),
828 Text({ key: "rh", dimColor: true, children: "Repro (checked: still triggers the rule)" }),
829 Box({ key: "snip", flexDirection: "column", children: snippet }),
830 Box({
831 key: "buttons",
832 marginTop: 1,
833 gap: 2,
834 children: [
835 Button({ key: "file", label: "File issue", hotkey: "1", plain: true, onPress: decide("file") }),
836 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
837 Text({ key: "hint", dimColor: true, children: "nothing is sent unless you file it" }),
838 ],
839 }),
840 ],
841 });
842}
843
844function drawCommand(t, state) {
845 const { Box, Text, Button } = t;
846 const { verdict } = state;
847 const list = (verdict.lines?.length ? verdict.lines : [verdict.flagged]).map((line, i) =>
848 Text({
849 key: `l${i}`,
850 wrap: "truncate-end",
851 children: [
852 Text({ dimColor: true, children: i === 0 ? "Flagged " : " " }),
853 Text({ color: "red", bold: true, children: line }),
854 ],
855 })
856 );
857 const decide = (choice) => () => { if (state.decision === null) state.decision = choice; };
858 return Box({
859 flexDirection: "column",
860 borderStyle: "round",
861 borderColor: "yellow",
862 paddingX: 1,
863 children: [
864 Text({ key: "title", children: [
865 Text({ bold: true, color: "yellow", children: "⚠ xgrep guard " }),
866 Text({ dimColor: true, children: "held this command until you decide" }),
867 ] }),
868 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
869 Box({ key: "list", flexDirection: "column", children: list }),
870 Box({
871 key: "buttons",
872 marginTop: 1,
873 gap: 2,
874 children: [
875 Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
876 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
877 Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
878 ],
879 }),
880 ],
881 });
882}
883hooks/core.mjs 592 lines1// Copyright (c) Mondoo, Inc.
2// SPDX-License-Identifier: Apache-2.0
3//
4// secure-guard core — the agent-neutral logic shared by every adapter
5// (the Claude mod, the Codex/Vibe hook command, the Pi/opencode extensions).
6//
7// Everything here is PURE: parsing, filtering, routing, version comparison,
8// cnspec bundle mapping, and advisory formatting. There is NO I/O and no
9// dependency on any agent API — each adapter spawns xgrep/cnspec itself (with
10// its own runtime) and calls these functions to decide and format. That keeps
11// one implementation of "what counts as a finding and how we phrase it" across
12// all agents.
13//
14// The two engines this drives:
15// xgrep — secrets/PII + dangerous-command guard (via `xgrep guard --command`,
16// parsed by normalizeVerdict) AND OWASP Top 10 code review (via
17// `xgrep scan --json`, filtered by highConfidenceFindings).
18// cnspec — IaC policy checks (via `cnspec scan … -o sarif`, parsed by
19// sarifFindings), with the policy source chosen by cnspecPolicySource.
20//
21// Terraform and Dockerfiles go to BOTH engines: cnspec for policy, xgrep for the
22// secrets and code issues cnspec doesn't look for (a hard-coded key in main.tf).
23
24// ─── Shared constants ────────────────────────────────────────────────────────
25
26export const XGREP_NPM = "@mondoohq/xgrep"; // public package — the fetch/install source
27// Pinned to a tested release so a session can't pull up an unvetted build.
28export const XGREP_PIN = "0.84.0";
29// Minimum xgrep the guard needs. 0.84.0 is where `guard --command` runs every
30// scan leg the hook runs (secrets and PII in commands, referenced scripts,
31// inline code — xgrep#3233), gains the env-var-secret, env-dump and destructive
32// command rules (#3235), honors rule categories (#3236/#3237), and reports
33// updates as JSON (#3238). Older builds run, but miss what the docs promise, so
34// the guard uses the pinned release instead and offers /secure-guard update.
35export const XGREP_MIN = "0.84.0";
36
37export const CNSPEC_INSTALL_URL = "https://mondoo.com/docs/cnspec/install";
38
39// A cnspec IaC scan loads several bundles and compiles MQL — a real one takes
40// ~20–30s — so the budget is generous: a timeout fails open, silently, and must
41// not hit an ordinary slow scan. Shared by the mod and every adapter.
42export const IAC_TIMEOUT_MS = 90000;
43
44// IaC kinds that also get the xgrep code scan. Terraform and Dockerfiles are
45// where secrets get hard-coded; K8s/CFN YAML is left to cnspec alone.
46const IAC_ALSO_CODE = new Set(["terraform", "docker"]);
47
48// cnspec runs a policy only when one of its filters matches the IaC asset. The
49// public content bundles are platform/provider scoped, so the guard loads a set
50// per target (cnspec accepts multiple `-f`; non-matching bundles are skipped).
51// `terraform-deprecations` matches ANY terraform-hcl, so it guarantees at least
52// one policy runs and no "asset doesn't support any policies" error.
53export const CNSPEC_CONTENT_RAW = "https://raw.githubusercontent.com/mondoohq/cnspec/main/content";
54export const CNSPEC_BUNDLES = {
55 terraform: ["terraform-deprecations", "mondoo-aws-security", "mondoo-azure-security", "mondoo-gcp-security"],
56 docker: ["mondoo-dockerfile-security", "mondoo-dockerfile-best-practices"],
57 k8s: ["mondoo-kubernetes-security", "mondoo-kubernetes-best-practices"],
58 cloudformation: ["mondoo-aws-security"],
59};
60
61// Inline review skips files that are not code worth scanning.
62export const REVIEW_SKIP_EXT = new Set([
63 ".md", ".markdown", ".txt", ".rst", ".json", ".lock", ".sum", ".mod",
64 ".yaml", ".yml", ".toml", ".ini", ".cfg", ".csv", ".tsv", ".svg", ".png",
65 ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".ico", ".lockb",
66]);
67const REVIEW_SKIP_DIRS = new Set(["node_modules", ".git", "vendor", "dist"]);
68const REVIEW_MAX_SHOWN = 10;
69
70// ─── xgrep: version resolution helpers ───────────────────────────────────────
71
72// parseVersion pulls an x.y.z out of `xgrep version` output; null if absent.
73export function parseVersion(out) {
74 const m = /(\d+)\.(\d+)\.(\d+)/.exec(String(out ?? ""));
75 return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
76}
77
78// meetsMin reports whether a parsed version is >= XGREP_MIN. An unparseable
79// version counts as too old, so the guard prefers a known-good release.
80export function meetsMin(version) {
81 return version != null && cmpSemver(version, XGREP_MIN) >= 0;
82}
83
84// cmpSemver compares two x.y.z strings and returns -1, 0, or 1.
85export function cmpSemver(a, b) {
86 const pa = String(a).split(".").map(Number);
87 const pb = String(b).split(".").map(Number);
88 for (let i = 0; i < 3; i++) {
89 const d = (pa[i] || 0) - (pb[i] || 0);
90 if (d !== 0) return d < 0 ? -1 : 1;
91 }
92 return 0;
93}
94
95// ─── xgrep: shell verdict ────────────────────────────────────────────────────
96
97// normalizeVerdict maps a `xgrep guard --command` JSON verdict
98// ({decision, summary, findings[]}) to { decision, summary, lines, flagged }.
99//
100// `summary` is xgrep's own ready-made message, written for a hook that blocks
101// outright ("xgrep guard blocked this action … remove … and retry"); the
102// pre-tool adapters, which do block, pass it on. A guard that HOLDS the call
103// for the user must not echo it — nothing was blocked yet — so `lines` (one per
104// finding, for a list) and `flagged` (one line, for a sentence) are phrased
105// here from the structured findings instead.
106export function normalizeVerdict(v) {
107 const findings = Array.isArray(v?.findings) ? v.findings : [];
108 const decision = v?.decision ?? (findings.length ? "ask" : "allow");
109 const summary = v?.summary ?? (findings.length ? `${findings.length} finding(s)` : "");
110 const named = findings.map(findingName);
111 const lines = findings.map((f, i) => {
112 const sev = String(f?.severity ?? "").trim().toUpperCase();
113 return sev ? `${sev} · ${named[i]}` : named[i];
114 });
115 const flagged = named.length
116 ? named.join("; ")
117 : firstLine(summary).replace(/[:.]+$/, "") || "a risky command";
118 return { decision, summary, lines, flagged };
119}
120
121// findingName is how one guard finding reads to a person: its title, then the
122// rule id that lets them (or the agent) look it up.
123function findingName(f) {
124 const title = String(f?.title ?? "").trim();
125 const rule = String(f?.rule ?? "").trim();
126 if (title && rule && rule !== title) return `${title} (${rule})`;
127 return title || rule || "finding";
128}
129
130function firstLine(s) {
131 return String(s ?? "").trim().split("\n")[0].trim();
132}
133
134// ─── xgrep: code review ──────────────────────────────────────────────────────
135
136// highConfidenceFindings: given a parsed `xgrep scan --json` document, return the
137// high-confidence security findings — filtered on the rule's `confidence` (HIGH),
138// dropping test/fixture scope.
139export function highConfidenceFindings(doc) {
140 const results = Array.isArray(doc?.results) ? doc.results : [];
141 return results
142 .filter((m) => m?.extra?.confidence === "HIGH" && m?.extra?.scope !== "test")
143 .map((m) => ({
144 rule: m.check_id ?? "finding",
145 title: m.extra?.title ?? m.check_id ?? "finding",
146 line: m.start?.line ?? 0,
147 message: firstSentence(m.extra?.message ?? ""),
148 }));
149}
150
151// advisoryText phrases xgrep code findings for the agent. `written` says which
152// side of the write the adapter runs on: the Claude mod reviews after the file
153// landed (advisory); the pre-write adapters block it, so the agent must fix the
154// content and write it again — "File written." would tell it the opposite.
155export function advisoryText(file, findings, { written = true } = {}) {
156 const head = written
157 ? `File written. xgrep flagged ${findings.length} high-confidence security ` +
158 `issue(s) in ${file} that you should fix before continuing:`
159 : `Not written: xgrep flagged ${findings.length} high-confidence security ` +
160 `issue(s) in ${file}. Fix them and write the file again:`;
161 const shown = findings.slice(0, REVIEW_MAX_SHOWN);
162 const body = shown
163 .map((f) => ` • ${f.title} (${f.rule}), line ${f.line}: ${f.message}`)
164 .join("\n");
165 const more = findings.length > shown.length
166 ? `\n … and ${findings.length - shown.length} more.`
167 : "";
168 return `${head}\n${body}${more}`;
169}
170
171// isScannable keeps inline review on actual code: no docs/data/lockfiles, no
172// vendored or VCS trees, no dotfiles. xgrep decides the language from here.
173export function isScannable(file) {
174 const segs = file.split(/[\\/]/); // tolerate both / and \ (Windows paths)
175 const base = segs[segs.length - 1] ?? "";
176 if (base === "" || base.startsWith(".")) return false;
177 if (segs.some((s) => REVIEW_SKIP_DIRS.has(s))) return false;
178 const dot = base.lastIndexOf(".");
179 const ext = dot > 0 ? base.slice(dot).toLowerCase() : "";
180 if (REVIEW_SKIP_EXT.has(ext)) return false;
181 return true;
182}
183
184// firstSentence trims a long message to its first sentence.
185export function firstSentence(msg) {
186 const s = String(msg).trim();
187 const end = s.indexOf(". ");
188 return end > 0 ? s.slice(0, end + 1) : s;
189}
190
191// ─── cnspec: IaC routing, bundles, SARIF ─────────────────────────────────────
192
193// iacScanKind maps a written file to a cnspec scan target, or null. Terraform
194// and Dockerfile are unambiguous by path; k8s and CloudFormation YAML/JSON are
195// classified from content (available on Write).
196export function iacScanKind(file, content) {
197 const base = (file.split(/[\\/]/).pop() ?? "").toLowerCase();
198 if (base === "") return null;
199 if (base.endsWith(".tf") || base.endsWith(".tf.json")) return "terraform";
200 if (base === "dockerfile" || base === "containerfile" || base.endsWith(".dockerfile")) return "docker";
201 if (base.endsWith(".yaml") || base.endsWith(".yml") || base.endsWith(".json")) {
202 return classifyYaml(content);
203 }
204 return null;
205}
206
207// alsoCodeScan reports whether an IaC kind also gets the xgrep code scan.
208export function alsoCodeScan(kind) {
209 return IAC_ALSO_CODE.has(kind);
210}
211
212// iacNeedsContent reports whether iacScanKind can only classify this path from
213// its content (YAML/JSON could be K8s, CloudFormation, or neither). An adapter
214// whose event carries no full content (Edit/MultiEdit) reads the file for these.
215export function iacNeedsContent(file) {
216 const base = (String(file ?? "").split(/[\\/]/).pop() ?? "").toLowerCase();
217 if (base.endsWith(".tf.json")) return false; // terraform by path
218 return base.endsWith(".yaml") || base.endsWith(".yml") || base.endsWith(".json");
219}
220
221// classifyYaml tells a Kubernetes manifest from a CloudFormation template by
222// content; null when it is neither (or content is unavailable).
223export function classifyYaml(content) {
224 if (typeof content !== "string" || content === "") return null;
225 if (/AWSTemplateFormatVersion|^\s*Resources:\s*$/m.test(content) && /\bType:\s*["']?AWS::/.test(content)) return "cloudformation";
226 if (/^\s*apiVersion:\s/m.test(content) && /^\s*kind:\s/m.test(content)) return "k8s";
227 return null;
228}
229
230// cnspecScanArgs builds the `cnspec scan …` argv for one IaC file. The docker
231// provider scans a Dockerfile as `scan docker file <path>`; the others take the
232// path directly.
233//
234// By default the scan runs --incognito against the given bundles: cnspec
235// evaluates the file on this machine, reports nothing anywhere, and skips the
236// Mondoo Platform config so a broken/absent credential never breaks the guard.
237// With { platform: true } it instead runs the policies assigned in the user's
238// logged-in Mondoo Platform space (no -f, no --incognito); cnspec then reports
239// the scan's results to that space — which is why it is opt-in.
240export function cnspecScanArgs(kind, file, bundles, opts = {}) {
241 const target = kind === "docker" ? ["docker", "file", file] : [kind, file];
242 if (opts.platform) return ["scan", ...target, "-o", "sarif"];
243 const policy = (Array.isArray(bundles) ? bundles : []).flatMap((b) => ["-f", b]);
244 return ["scan", ...target, ...policy, "--incognito", "-o", "sarif"];
245}
246
247// cnspecPolicySource decides where the IaC policies come from, from the
248// adapter's environment ({ CNSPEC_POLICY_BUNDLE, CNSPEC_CONTENT_DIR,
249// CNSPEC_USE_PLATFORM }), in precedence:
250// 1. CNSPEC_POLICY_BUNDLE — explicit bundle(s), incognito
251// 2. CNSPEC_CONTENT_DIR — a local cnspec content checkout, incognito, offline
252// 3. CNSPEC_USE_PLATFORM — the logged-in Mondoo Platform space's policies
253// 4. the latest public bundles, downloaded from GitHub, incognito
254// Returns { kind: "bundles"|"platform", bundles, remote } where `remote` is true
255// when cnspec will download policy bundles (policies come down; no file content
256// goes up). An adapter turns `kind`/`remote` into a one-time notice.
257export function cnspecPolicySource(kind, env = {}) {
258 const override = String(env.CNSPEC_POLICY_BUNDLE ?? "").trim();
259 const contentDir = String(env.CNSPEC_CONTENT_DIR ?? "").trim();
260 if (!override && !contentDir && isTruthy(env.CNSPEC_USE_PLATFORM)) {
261 return { kind: "platform", bundles: [], remote: false };
262 }
263 const bundles = cnspecBundlesFor(kind, override, contentDir);
264 return { kind: "bundles", bundles, remote: bundles.some(isRemoteBundle) };
265}
266
267function isTruthy(v) {
268 return /^(1|true|yes|on)$/i.test(String(v ?? "").trim());
269}
270
271function isRemoteBundle(b) {
272 return /^(https?|s3):\/\//i.test(b);
273}
274
275// cnspecPolicyNotice is the one line an adapter shows the first time a session
276// runs cnspec, so the policy download (or the Platform reporting) is never
277// silent. null when the scan neither downloads nor reports anything.
278export function cnspecPolicyNotice(source) {
279 if (source?.kind === "platform") {
280 return "cnspec is running your Mondoo Platform space's assigned IaC policies " +
281 "(CNSPEC_USE_PLATFORM); it reports scan results to that space.";
282 }
283 if (source?.remote) {
284 return "cnspec is downloading the latest policy bundles for IaC checks. Only " +
285 "policies are downloaded: your files are assessed on this machine and " +
286 "nothing is uploaded. Set CNSPEC_CONTENT_DIR to a local cnspec content " +
287 "checkout to work offline.";
288 }
289 return null;
290}
291
292// cnspecBundlesFor resolves the `-f` policy sources for an IaC kind, in
293// precedence: CNSPEC_POLICY_BUNDLE (comma-separated override) → a local cnspec
294// content checkout (contentDir) → the public raw content URLs.
295export function cnspecBundlesFor(kind, override, contentDir) {
296 if (override && override.trim()) {
297 return override.split(",").map((s) => s.trim()).filter(Boolean);
298 }
299 const names = CNSPEC_BUNDLES[kind] || [];
300 if (contentDir && contentDir.trim()) {
301 const base = contentDir.trim().replace(/\/+$/, "");
302 return names.map((n) => `${base}/${n}.mql.yaml`);
303 }
304 return names.map((n) => `${CNSPEC_CONTENT_RAW}/${n}.mql.yaml`);
305}
306
307// extractJsonObject returns the first complete top-level {…} object in s, or
308// null. It scans balanced braces, skipping any inside JSON strings, so a
309// scanner that writes a log/progress/summary line AROUND the JSON on stdout
310// (cnspec can) doesn't break the parse. The callers already skipped a leading
311// prefix with indexOf("{"); this also bounds the suffix, so trailing text no
312// longer makes JSON.parse throw and silently drop real findings (fail-open).
313export function extractJsonObject(s) {
314 const str = String(s ?? "");
315 const start = str.indexOf("{");
316 if (start < 0) return null;
317 let depth = 0, inStr = false, esc = false;
318 for (let i = start; i < str.length; i++) {
319 const c = str[i];
320 if (inStr) {
321 if (esc) esc = false;
322 else if (c === "\\") esc = true;
323 else if (c === '"') inStr = false;
324 continue;
325 }
326 if (c === '"') inStr = true;
327 else if (c === "{") depth++;
328 else if (c === "}" && --depth === 0) return str.slice(start, i + 1);
329 }
330 return null; // unbalanced — no complete object
331}
332
333// parseJsonObject extracts and parses the first top-level JSON object in s,
334// returning the parsed value or null. It NEVER throws, so a cnspec call site
335// doesn't rely on a distant outer try/catch to stay fail-open.
336export function parseJsonObject(s) {
337 const obj = extractJsonObject(s);
338 if (obj == null) return null;
339 try { return JSON.parse(obj); } catch { return null; }
340}
341
342// sarifFindings extracts failed policy checks from a SARIF document. It drops
343// cnspec's `asset-error` results (the scan could not evaluate → stay quiet) and
344// keeps only FAILED checks (cnspec marks passes kind:"pass"/level:"none").
345export function sarifFindings(doc) {
346 const runs = Array.isArray(doc?.runs) ? doc.runs : [];
347 const out = [];
348 for (const run of runs) {
349 for (const res of Array.isArray(run?.results) ? run.results : []) {
350 const rule = res?.ruleId ?? "policy-check";
351 if (rule === "asset-error") continue;
352 const failed = res?.kind === "fail" ||
353 (res?.kind == null && ["error", "warning"].includes(res?.level));
354 if (!failed) continue;
355 // cnspec encodes "<title>: FAIL · <sev> · score n/100" — keep the title.
356 const title = String(res?.message?.text ?? "").split(/:\s+(?:PASS|FAIL)\b/i)[0].trim();
357 out.push({
358 rule,
359 level: res?.level ?? "warning",
360 severity: res?.properties?.severity ?? "",
361 message: firstSentence(title),
362 });
363 }
364 }
365 return out;
366}
367
368// combineAdvisories joins the advisories from both engines into one tool result
369// (null when there are none). Only the first keeps its "File written." /
370// "Not written:" lead.
371export function combineAdvisories(texts) {
372 const parts = (Array.isArray(texts) ? texts : []).filter((t) => typeof t === "string" && t !== "");
373 if (parts.length === 0) return null;
374 return parts.map((t, i) => (i === 0 ? t : t.replace(/^(?:File written\.|Not written:) /, ""))).join("\n\n");
375}
376
377export function iacAdvisoryText(file, kind, findings, { written = true } = {}) {
378 const head = written
379 ? `File written. cnspec policy found ${findings.length} issue(s) in ${file} ` +
380 `(${kind}) to fix before continuing:`
381 : `Not written: cnspec policy found ${findings.length} issue(s) in ${file} ` +
382 `(${kind}). Fix them and write the file again:`;
383 const shown = findings.slice(0, REVIEW_MAX_SHOWN);
384 const body = shown
385 .map((f) => ` • ${f.severity ? `[${f.severity}] ` : ""}${f.message} (${f.rule})`)
386 .join("\n");
387 const more = findings.length > shown.length
388 ? `\n … and ${findings.length - shown.length} more.`
389 : "";
390 return `${head}\n${body}${more}`;
391}
392
393// ─── False-positive reports ──────────────────────────────────────────────────
394//
395// When the agent is confident an xgrep finding is a false positive, it can
396// report it as an issue on this repo so the rule gets adjusted. A report must be
397// actionable: a MINIMAL, SELF-CONTAINED snippet that still triggers the rule
398// (verified with `xgrep scan --stdin` before anything is filed), the rule id,
399// the xgrep version, and why the finding is wrong. The repo is public, so the
400// snippet must be written for the report — never the user's own code — and
401// the user sees the exact issue and confirms before it is filed.
402
403export const FP_REPO = "mondoohq/secure-stack";
404export const FP_LABEL = "false-positive";
405const FP_MAX_SNIPPET_LINES = 60;
406const FP_MAX_SNIPPET_CHARS = 4000;
407const FP_MAX_REASON_CHARS = 2000;
408
409// validateFpReport checks the agent's input; returns a list of problems, each
410// phrased so the agent can fix its call (empty = valid).
411export function validateFpReport(input) {
412 const problems = [];
413 const rule = String(input?.rule ?? "").trim();
414 const language = String(input?.language ?? "").trim();
415 const snippet = String(input?.snippet ?? "");
416 const reason = String(input?.reason ?? "").trim();
417 if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(rule)) problems.push("`rule` must be the finding's rule id, e.g. python-sql-injection");
418 if (!/^[A-Za-z0-9#+_-]+$/.test(language)) problems.push("`language` must be the snippet's language as xgrep names it, e.g. python, javascript, go");
419 if (snippet.trim() === "") problems.push("`snippet` must be a minimal, self-contained reproduction");
420 else if (snippet.length > FP_MAX_SNIPPET_CHARS || snippet.split("\n").length > FP_MAX_SNIPPET_LINES) {
421 problems.push(`\`snippet\` must be minimal: at most ${FP_MAX_SNIPPET_LINES} lines and ${FP_MAX_SNIPPET_CHARS} characters`);
422 }
423 if (reason === "") problems.push("`reason` must say why the finding is a false positive");
424 else if (reason.length > FP_MAX_REASON_CHARS) problems.push(`\`reason\` must be at most ${FP_MAX_REASON_CHARS} characters`);
425 return problems;
426}
427
428// fpReproArgs is the xgrep argv that checks the snippet (on stdin) still
429// triggers the rule — the same command the issue tells a maintainer to run.
430export function fpReproArgs(rule, language) {
431 return ["scan", "--stdin", "--lang", language, "--rule-id", rule, "--json"];
432}
433
434// fpReproduces reports whether a parsed `xgrep scan --json` result holds a
435// finding for `rule`.
436export function fpReproduces(doc, rule) {
437 const results = Array.isArray(doc?.results) ? doc.results : [];
438 return results.some((m) => m?.check_id === rule);
439}
440
441// fpIssue renders the issue title and body. The code fence is longer than any
442// run of backticks in the snippet, so a snippet can't break out of it.
443export function fpIssue({ rule, language, snippet, reason, expected, xgrepVersion }) {
444 const longest = Math.max(0, ...(String(snippet).match(/`+/g) ?? []).map((r) => r.length));
445 const fence = "`".repeat(Math.max(3, longest + 1));
446 const title = `False positive: ${rule} (${language})`;
447 const body = [
448 `**Rule:** \`${rule}\` `,
449 `**Language:** ${language} `,
450 `**xgrep:** ${xgrepVersion || "unknown"}`,
451 "",
452 "### Reproduction",
453 "",
454 `${fence}${language}`,
455 String(snippet).replace(/\s+$/, ""),
456 fence,
457 "",
458 `\`xgrep ${fpReproArgs(rule, language).join(" ")} < repro\` reports \`${rule}\` on this snippet (checked before filing).`,
459 "",
460 "### Why this is a false positive",
461 "",
462 String(reason).trim(),
463 ...(String(expected ?? "").trim() ? ["", "### Expected", "", String(expected).trim()] : []),
464 "",
465 "---",
466 "Reported from the secure-guard mod after the user reviewed it. The snippet is a minimal reproduction written for this report.",
467 ].join("\n");
468 return { title, body };
469}
470
471// fpIssueUrl is the prefilled new-issue link — the fallback when the GitHub CLI
472// can't file it. Nothing is sent until the user opens the link and submits.
473export function fpIssueUrl({ title, body }) {
474 const q = new URLSearchParams({ title, body, labels: FP_LABEL });
475 return `https://github.com/${FP_REPO}/issues/new?${q.toString()}`;
476}
477
478// ─── What the user sees when findings go to the agent ────────────────────────
479
480const NOTICE_MAX_SHOWN = 3;
481
482// findingsNotice is the transcript line telling the user that a Mondoo scanner
483// caught something and handed it to the agent. Advisories reach the model as
484// hidden context, so this line is how the user sees what was caught and why the
485// agent is about to touch code they didn't ask about. `engine` is "xgrep"
486// (code findings: { rule, title, line }) or "cnspec" (policy findings:
487// { rule, severity, message }).
488export function findingsNotice(engine, file, findings) {
489 const list = Array.isArray(findings) ? findings : [];
490 if (list.length === 0) return null; // nothing was handed over — say nothing
491 const name = (f) => engine === "cnspec"
492 // A cnspec finding has no separate title: sarifFindings already cut its
493 // `message` down to the check's title ("<title>: FAIL · …" → "<title>"),
494 // so `message` is the human name here, as `title` is for xgrep.
495 ? `${f?.severity ? `${String(f.severity).toUpperCase()} ` : ""}${f?.message || f?.rule || "policy check"} (${f?.rule ?? "policy"})`
496 : `${f?.title || f?.rule || "finding"} (${f?.rule ?? "finding"})${f?.line ? `, line ${f.line}` : ""}`;
497 const shown = list.slice(0, NOTICE_MAX_SHOWN).map(name).join("; ");
498 const more = list.length > NOTICE_MAX_SHOWN ? `; and ${list.length - NOTICE_MAX_SHOWN} more` : "";
499 return `${foundLead(engine, file, list.length)}: ${shown}${more}. Sent to Claude to address.`;
500}
501
502// findingsToast is the short cue for the same event; it shares findingsNotice's
503// lead so the two can't drift apart. null when there is nothing to report.
504export function findingsToast(engine, file, findings) {
505 const n = Array.isArray(findings) ? findings.length : 0;
506 return n === 0 ? null : `${foundLead(engine, file, n)} — sent to Claude`;
507}
508
509// foundLead: "Mondoo xgrep found 1 issue in app.py".
510function foundLead(engine, file, n) {
511 const base = String(file ?? "").split(/[\\/]/).pop() || "the file";
512 return `Mondoo ${engine} found ${n} issue${n === 1 ? "" : "s"} in ${base}`;
513}
514
515// ─── The native xgrep behind npx ─────────────────────────────────────────────
516//
517// `npx -y @mondoohq/xgrep@<pin> …` re-resolves the package on every call — on a
518// busy machine that is seconds per call, enough to time the guard out and
519// let commands through unchecked. npm's `xgrep` is only a Node launcher for a
520// per-platform native binary in node_modules/@mondoohq/xgrep_<os>_<arch>/, and
521// npm installs only the current platform's one, so after the one npx fetch
522// the adapters find that binary and call it directly (milliseconds).
523
524// npxNodeModulesDir: the node_modules dir holding npx's package, from the path
525// of its bin shim (…/node_modules/.bin/xgrep, or xgrep.cmd on Windows).
526export function npxNodeModulesDir(shimPath) {
527 const p = String(shimPath ?? "").trim();
528 const parts = p.split(/[\\/]/);
529 if (parts.length < 3 || parts[parts.length - 2] !== ".bin") return null;
530 const sep = p.includes("\\") && !p.includes("/") ? "\\" : "/";
531 return parts.slice(0, -2).join(sep);
532}
533
534// nativeXgrepCandidates: the paths (relative to node_modules/@mondoohq) where
535// the platform binary can live, given that directory's entry names.
536export function nativeXgrepCandidates(entryNames) {
537 return (Array.isArray(entryNames) ? entryNames : [])
538 .filter((n) => typeof n === "string" && /^xgrep_[a-z0-9]+_[a-z0-9]+$/.test(n))
539 .sort()
540 .flatMap((n) => [`${n}/xgrep`, `${n}/xgrep.exe`]);
541}
542
543// ─── Keeping the user's xgrep current ────────────────────────────────────────
544//
545// xgrep checks for newer releases itself (Mondoo's install service, cached 24h,
546// skipped for dev builds and with XGREP_UPDATE_CHECK=0 / DO_NOT_TRACK=1) and
547// prints the result to stderr on `xgrep version` — which the guard already
548// runs to probe the binary. So the guard reads the answer from that probe: no
549// extra process, no network call of its own, and xgrep's opt-outs apply.
550
551export const XGREP_INSTALL_URL = "https://install.mondoo.com";
552
553// parseUpdateNotice reads xgrep's "A new xgrep release is available: v0.81.0 →
554// v0.83.0" line (ANSI styling stripped); null when there is none.
555export function parseUpdateNotice(stderr) {
556 const text = String(stderr ?? "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
557 const m = /new xgrep release is available:\s*v?(\d+\.\d+\.\d+)\s*(?:→|->)\s*v?(\d+\.\d+\.\d+)/i.exec(text);
558 return m ? { current: m[1], latest: m[2] } : null;
559}
560
561// npmGlobalPrefix: the npm prefix an xgrep was installed under, from the real
562// path of its launcher (<prefix>/lib/node_modules/@mondoohq/xgrep/… on Unix,
563// <prefix>\node_modules\@mondoohq\xgrep\… on Windows); null when it isn't an
564// npm global install (a release download, a dev build, an npx cache).
565export function npmGlobalPrefix(realPath) {
566 const p = String(realPath ?? "");
567 if (/[\\/]_npx[\\/]/.test(p)) return null; // npx's cache, not a global install
568 const m = /^(.*?)[\\/](?:lib[\\/])?node_modules[\\/]@mondoohq[\\/]xgrep(?:_[a-z0-9]+_[a-z0-9]+)?[\\/]/.exec(p);
569 return m && m[1] ? m[1] : null;
570}
571
572// xgrepUpdateArgv: the command that updates xgrep. With a prefix it updates
573// exactly the install the guard runs; without one it installs a global copy.
574export function xgrepUpdateArgv(prefix) {
575 return prefix
576 ? ["npm", "--prefix", prefix, "install", "-g", `${XGREP_NPM}@latest`]
577 : ["npm", "install", "-g", `${XGREP_NPM}@latest`];
578}
579
580// parseVersionJSON reads `xgrep version --json --check-update` (xgrep with
581// mondoohq/xgrep#3238): { version, update } where update is
582// { current, latest } when a newer release is out, else null. null when the
583// output isn't that document (an older xgrep — fall back to the text form).
584export function parseVersionJSON(stdout) {
585 const doc = parseJsonObject(stdout ?? "");
586 const version = parseVersion(doc?.version);
587 if (!version) return null;
588 const u = doc.update;
589 const latest = u?.checked && u?.available ? parseVersion(u.latest) : null;
590 return { version, update: latest ? { current: version, latest } : null };
591}
592