SLOPSHOPPER

secure-guard

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…

newpanebandguardcommandtoast
★ 1v2.0.1Apache-2.0updated 2026-10-09mondoohq/secure-stack/mods/secure-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secure-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secure-guard │ ● secure-guard: xgrep guard: your xgrep (an older build) predates the g│ xgrep guard: xgrep an older build is older │ ● secure-guard: secure-guard: xgrep an older build at dev is too old fo│ than 0.84.0; updating to 0.84.0 │ ⏺ Read(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Read 6 lines ╭──────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ secure-guard │ ⎿ Added 2 lines, removed 1 line │ secure-guard: a newer xgrep is available │ ⏺ Bash(bun test) ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secure-guard ⎿ secure-guard: secure-guard ⎿ secure-guard: xgrep (shell + code): not available: no xgrep >= 0.84.0 found or fetchable (@mondoohq/xgrep). Install it: ht ⎿ secure-guard: cnspec (IaC policy): via cnspec ⎿ secure-guard: update available: xgrep (newer) — run /secure-guard update ⎿ secure-guard: Docs: https://mondoo.com/docs/xgrep/ai-agents/guard-hooks ⎿ secure-guard: npm package (xgrep): https://www.npmjs.com/package/@mondoohq/xgrep ● secure-guard: xgrep guard: no xgrep >= 0.84.0 found or fetchable (@mondoohq/xgrep). Install it: https://www.npmjs.com/package/@mondoohq/xgrep — not scanning. https://mondoo.com/docs/xgrep/ai-agents/guard-hooks ● secure-guard: xgrep guard: no xgrep >= 0.84.0 found or fetchable (@mondoohq/xgrep). Install it: https://www.npmjs.com/package/@mondoohq/xgrep — not scanning. https://mondoo.com/docs/xgrep/ai-agents/guard-hooks ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secure-guard

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:

secure-guard holding a piped installer in Claude Code until the user decides

  • Shell guard (xgrep) — holds a risky Bash command behind a Proceed / Cancel pane until you answer. xgrep here keeps secrets and PII from leaving: a token or an SSN in the command, credential files, a secret sent by variable name, or the whole environment piped to a remote host. It also holds remote code piped into a shell, reverse shells, and destructive commands (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.
  • Inline code review (xgrep) — after the agent writes or edits code, scans it and hands high-confidence findings back right after the tool result so the agent fixes them in the same turn. xgrep here enforces the OWASP Top 10 (SAST taint) plus SCA and secrets on the code. The transcript says what Mondoo caught — "Mondoo xgrep found 1 issue in app.py: SQL injection (python-sql-injection), line 15. Sent to Claude to address." — right under the edit, so you can see why the agent touched code you didn't ask about.

secure-guard's inline xgrep review catching a SQL injection that Claude then fixes

  • IaC policy guard (cnspec) — after the agent writes or edits Terraform, a Dockerfile, or a Kubernetes / CloudFormation manifest, runs cnspec policy checks and hands violations back the same way. Terraform and Dockerfiles get the xgrep review too, so a hard-coded secret in main.tf is still caught.
  • False-positive reports — when the agent is confident an xgrep finding is wrong, it can report it to this repo with a reproducible case so the rule gets fixed. You review the exact issue and decide whether to file it (details).

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.

Install

/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.

The two engines

  • xgrep (shell + code) — fetched automatically from the public npm package if a new-enough one isn't installed. Learn more: xgrep.ai / docs.
  • cnspec (IaC policy) — must be installed (CNSPEC_PATH or cnspec on PATH). If it isn't, the IaC guard stays silently off.

Where the IaC policies come from

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:

SetPoliciesNetwork
CNSPEC_POLICY_BUNDLEyour bundle(s): comma-separated local paths, https:// or s3:// URLsonly if a URL
CNSPEC_CONTENT_DIRthe same public bundles, from a local cnspec content/ checkoutnone, fully offline
CNSPEC_USE_PLATFORM=1the policies assigned in your logged-in Mondoo Platform spacecnspec reports the scan results to your space (opt-in for that reason)
(nothing)the latest public bundlespolicies 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.

Local by design

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:

  • Fast: there's no network round-trip per tool call. A scan is a local pass, so guarding every command and write doesn't add server latency to the session.
  • Private: the code being scanned never leaves your machine.

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:

WhatWhenDirection
the xgrep binary, from the public @mondoohq/xgrep npm packageonly if no xgrep ≥ 0.84 is installeddown: the scanner, not your data
a version check against install.mondoo.comxgrep'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=1down: the latest version number
a newer xgrep, from the npm packageonly when you run /secure-guard updatedown: the scanner
cnspec policy bundles, from github.com/mondoohq/cnspecon an IaC scan, unless CNSPEC_CONTENT_DIR points at a local copydown: 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 outdateddown: cnspec's plugins
scan results to your Mondoo Platform spaceonly with CNSPEC_USE_PLATFORM=1up, by your choice
a false-positive issue on github.com/mondoohq/secure-stack (public)only when you press File issue after reviewing itup: 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.

Reporting false positives

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.

  1. Checked before you see it. The mod runs 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.
  2. You review the exact issue. A pane shows the title, the reason, and the repro, and nothing is sent unless you press File issue. The repo is public, so the agent is told to write the snippet for the report and never paste your code, names, paths, or secrets.
  3. Filed as you. The mod runs 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.

Trust

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.

Status in a session

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.

Tuning the guard

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.

Keeping xgrep current

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:

  • An npm global install (including one under Homebrew's Node) is updated in place, with the npm prefix it lives under, so it updates the copy the guard actually runs.
  • No local install (the guard was using the npm package it fetched): a global copy is installed, and the guard prefers it from then on.
  • Anything else (a release download, a development build) is left alone, and you get the link to install.mondoo.com to update it the way you installed it.

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.

Testing

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.

Recording the demos

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.

License

Apache License 2.0. The xgrep and cnspec binaries it invokes are distributed under their own terms.

Source 2 files
hooks/secure-guard.mjs 883 lines
1// 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}
883
hooks/core.mjs 592 lines
1// 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