@cybedefend/vibedefend 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4850,18 +4850,20 @@ function resolveTokenLinux(mcpName) {
4850
4850
  return null;
4851
4851
  }
4852
4852
  }
4853
- async function resolveTokenWithRefresh(mcpName) {
4853
+ async function resolveTokenWithRefresh(mcpName, deps = {}) {
4854
+ const getValidAccessTokenFn = deps.getValidAccessToken ?? getValidAccessToken;
4855
+ const resolveTokenFn = deps.resolveTokenFn ?? resolveToken;
4854
4856
  const envToken = process.env.CYBEDEFEND_TOKEN;
4855
4857
  if (envToken && envToken.length > 0) return envToken;
4856
4858
  try {
4857
- const token = await getValidAccessToken(mcpName);
4859
+ const token = await getValidAccessTokenFn(mcpName);
4858
4860
  if (token) return token;
4859
4861
  } catch (e) {
4860
4862
  if (e instanceof LoginRequiredError) {
4861
- return null;
4863
+ return resolveTokenFn(mcpName);
4862
4864
  }
4863
4865
  }
4864
- return resolveToken(mcpName);
4866
+ return resolveTokenFn(mcpName);
4865
4867
  }
4866
4868
 
4867
4869
  // src/hooks/runtime/api.ts
@@ -4990,6 +4992,19 @@ function emitContext(body, client) {
4990
4992
  }
4991
4993
  emit(body, client);
4992
4994
  }
4995
+ function emitStop(body, client, reason) {
4996
+ if (!body) return;
4997
+ if (client === "claude-code") {
4998
+ process.stdout.write(
4999
+ JSON.stringify({
5000
+ decision: "block",
5001
+ reason: reason && reason.trim() ? reason : body
5002
+ })
5003
+ );
5004
+ return;
5005
+ }
5006
+ emit(body, client);
5007
+ }
4993
5008
 
4994
5009
  // src/hooks/runtime/config.ts
4995
5010
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
@@ -5469,7 +5484,7 @@ function readPackageMeta(opts = {}) {
5469
5484
  }
5470
5485
  }
5471
5486
  var pkg = readPackageMeta({
5472
- bakedVersion: true ? "1.2.3" : void 0
5487
+ bakedVersion: true ? "1.3.0" : void 0
5473
5488
  });
5474
5489
 
5475
5490
  // src/hooks/runtime/session-start.ts
@@ -5579,17 +5594,29 @@ var COUNTED_TOOLS = /* @__PURE__ */ new Set([
5579
5594
  "apply_patch",
5580
5595
  "pre_write_code"
5581
5596
  ]);
5597
+ var REVIEW_SENTINEL = "CybeDefend gap analysis (Stop hook";
5598
+ function transcriptHasReviewSentinel(transcript) {
5599
+ try {
5600
+ return JSON.stringify(transcript).includes(REVIEW_SENTINEL);
5601
+ } catch {
5602
+ return false;
5603
+ }
5604
+ }
5582
5605
  async function sessionReviewHook(deps = {}) {
5583
5606
  const readStdin = deps.readStdin ?? readStdinJson;
5584
5607
  const loadConfig = deps.loadConfig ?? loadRuntimeConfig;
5585
- const emitFn = deps.emitFn ?? emit;
5608
+ const emitFn = deps.emitFn ?? emitStop;
5586
5609
  const readTranscriptFile = deps.readTranscriptFile ?? defaultReadTranscript;
5587
5610
  const stdin = await readStdin();
5588
5611
  const event = sniff(stdin);
5612
+ if (stdin.stop_hook_active === true || stdin.stop_hook_active === "true") {
5613
+ return;
5614
+ }
5589
5615
  const config = loadConfig();
5590
5616
  if (!config) return;
5591
5617
  if (!config.hooks.enableSessionReview) return;
5592
5618
  const transcript = loadTranscript(stdin, readTranscriptFile);
5619
+ if (transcriptHasReviewSentinel(transcript)) return;
5593
5620
  let editCount = countEdits(transcript);
5594
5621
  if (process.env.CYBEDEFEND_DRY_RUN === "1") {
5595
5622
  editCount = parseInt(process.env.CYBEDEFEND_DRY_RUN_EDITS ?? "5", 10);
@@ -5597,13 +5624,18 @@ async function sessionReviewHook(deps = {}) {
5597
5624
  if (editCount === 0) return;
5598
5625
  if (editCount < config.hooks.reviewThreshold) return;
5599
5626
  const userPrompt = extractFirstUserPrompt(transcript);
5600
- const body = composeReviewBody(
5627
+ let body = composeReviewBody(
5601
5628
  editCount,
5602
5629
  event.client,
5603
5630
  userPrompt,
5604
5631
  config.hooks.autoProposeMode
5605
5632
  );
5606
- emitFn(body, event.client);
5633
+ const withDiffScan = config.hooks.enableDiffScan !== false;
5634
+ if (withDiffScan) {
5635
+ const cwd = typeof stdin.cwd === "string" && stdin.cwd ? stdin.cwd : process.cwd();
5636
+ body += "\n" + composeDiffScanBody(extractChangedFiles(transcript, void 0, cwd));
5637
+ }
5638
+ emitFn(body, event.client, composeStopReason(withDiffScan));
5607
5639
  }
5608
5640
  function loadTranscript(stdin, readFile) {
5609
5641
  if (Array.isArray(stdin.transcript)) return stdin.transcript;
@@ -5666,6 +5698,53 @@ function countEdits(transcript) {
5666
5698
  }
5667
5699
  return n;
5668
5700
  }
5701
+ var CHANGED_FILES_MAX = 300;
5702
+ var CHANGED_FILES_DISPLAY = 50;
5703
+ function toolInputOf(obj) {
5704
+ const input = obj.input ?? obj.tool_input;
5705
+ return input && typeof input === "object" ? input : {};
5706
+ }
5707
+ function filePathOfToolUse(obj) {
5708
+ const input = toolInputOf(obj);
5709
+ if (typeof input.file_path === "string" && input.file_path) {
5710
+ return input.file_path;
5711
+ }
5712
+ if (typeof input.path === "string" && input.path) return input.path;
5713
+ if (typeof input.patch === "string" && input.patch) {
5714
+ return filePathFromPatch(input.patch);
5715
+ }
5716
+ return "";
5717
+ }
5718
+ function collectChangedFilesDeep(node, depth, out) {
5719
+ if (depth > 8 || node === null || typeof node !== "object") return;
5720
+ if (Array.isArray(node)) {
5721
+ for (const item of node) collectChangedFilesDeep(item, depth + 1, out);
5722
+ return;
5723
+ }
5724
+ const obj = node;
5725
+ if (COUNTED_TOOLS.has(toolNameOf(obj))) {
5726
+ const p = filePathOfToolUse(obj);
5727
+ if (p) out.add(p);
5728
+ return;
5729
+ }
5730
+ for (const value of Object.values(obj)) {
5731
+ if (value && typeof value === "object") {
5732
+ collectChangedFilesDeep(value, depth + 1, out);
5733
+ }
5734
+ }
5735
+ }
5736
+ function relativizePath(p, cwd) {
5737
+ if (cwd && p.startsWith(cwd.endsWith("/") ? cwd : cwd + "/")) {
5738
+ return p.slice(cwd.endsWith("/") ? cwd.length : cwd.length + 1);
5739
+ }
5740
+ return p;
5741
+ }
5742
+ function extractChangedFiles(transcript, cap = CHANGED_FILES_MAX, cwd) {
5743
+ const out = /* @__PURE__ */ new Set();
5744
+ for (const event of transcript) collectChangedFilesDeep(event, 0, out);
5745
+ const arr = cwd ? Array.from(out).map((p) => relativizePath(p, cwd)) : Array.from(out);
5746
+ return arr.slice(0, cap);
5747
+ }
5669
5748
  var COMMAND_WRAPPER_PREFIXES = [
5670
5749
  "<local-command-",
5671
5750
  "<command-name>",
@@ -5719,43 +5798,96 @@ function extractFirstUserPrompt(transcript) {
5719
5798
  function composeReviewBody(editCount, client, userPrompt, autoPropose) {
5720
5799
  const safePrompt = userPrompt || "(could not extract \u2014 re-read transcript)";
5721
5800
  const proposalLine = autoPropose ? [
5722
- " **Auto-propose mode is ON** (configured at install). Without asking the user,",
5723
- " call `cybe_rules_report_missing(projectId, { rule_text, evidence_files,",
5724
- ' rationale: "<quote user prompt>", confirmed_by_user: true })` for each',
5725
- " selected concern (1\u20132 max). The user will validate them at the next",
5726
- " session start via the Accept/Reject picker."
5801
+ " **Auto-propose mode is ON** (configured at install). If \u2014 and ONLY",
5802
+ " if \u2014 one gap clears the bar above, call WITHOUT asking the user:",
5803
+ " `cybe_rules_report_missing(projectId, { rule_text, evidence_files,",
5804
+ ' rationale: "<quote user prompt>", confirmed_by_user: true })` for',
5805
+ " that single rule. The user validates it at the next session start",
5806
+ " via the Accept/Reject picker. If nothing clears the bar, propose",
5807
+ " nothing."
5727
5808
  ].join("\n") : [
5728
5809
  " Ask the user via AskUserQuestion:",
5729
- ' > "Your prompt mentioned <concern>, but no business rule covers it.',
5730
- ' > Propose this rule for the project?" \u2014 Yes / No / Edit before proposing.',
5731
- ' ONLY on explicit "yes": call `cybe_rules_report_missing(projectId,',
5810
+ ' > "Your prompt implied <concern>, but no business rule covers it.',
5811
+ ' > Propose it as a standing rule for the project?" \u2014 Yes / No / Edit.',
5812
+ ' ONLY on an explicit "yes": call `cybe_rules_report_missing(projectId,',
5732
5813
  ' { rule_text, evidence_files, rationale: "<quote user prompt>",',
5733
5814
  " confirmed_by_user: true })`."
5734
5815
  ].join("\n");
5735
5816
  return [
5736
5817
  `## \u{1F6E1}\uFE0F CybeDefend gap analysis (Stop hook \u2014 ${editCount} edits this session, client=${client})`,
5737
5818
  "",
5738
- "Do a **gap analysis** between the original user request and the",
5739
- "business rules that were applied during this session.",
5819
+ "Check whether this session revealed a MISSING standing rule \u2014 a durable,",
5820
+ "reusable security/business invariant the project should enforce on FUTURE",
5821
+ "changes. Proposing is the exception, not a reflex: **most sessions \u2014",
5822
+ "especially small edits \u2014 end with no proposal, and that is the expected",
5823
+ "outcome.**",
5740
5824
  "",
5741
5825
  "**Original user prompt (first 600 chars):**",
5742
5826
  `> ${safePrompt}`,
5743
5827
  "",
5744
- "### Required next-turn action",
5745
- "1. Extract security/business concerns the prompt mentions.",
5746
- "2. List CybeDefend rules returned during the session.",
5747
- "3. Identify concerns WITHOUT a matching rule, then select **AT MOST the",
5748
- " 1\u20132 most important** (prefer security-critical / data-handling). Ignore",
5749
- " the rest \u2014 over-proposing causes proposal fatigue and users end up",
5750
- " rejecting everything. Propose nothing if no gap is worth a standing rule.",
5751
- "4. For each selected concern (1\u20132 max):",
5828
+ "### How to decide",
5829
+ "1. List the security/business concerns the prompt mentions or implies.",
5830
+ "2. List the CybeDefend rules that were returned during this session.",
5831
+ "3. Find concerns with NO matching rule, then propose **at most 1** \u2014 the",
5832
+ " single most valuable \u2014 and ONLY if it is a durable, reusable invariant:",
5833
+ " it must apply to future changes (not a one-off), not be stylistic, and",
5834
+ " not already be covered. Over-proposing causes proposal fatigue and",
5835
+ " users end up rejecting everything \u2014 when in doubt, propose nothing.",
5836
+ "4. If (and only if) one gap clears that bar:",
5752
5837
  proposalLine,
5753
- "5. If every concern is covered (or none warrants a rule), say so:",
5754
- ' "\u{1F6E1}\uFE0F CybeDefend gap analysis: every concern in your request was covered',
5755
- ' by an existing rule. No new proposals."',
5838
+ "5. Otherwise \u2014 the normal case \u2014 propose nothing and say so:",
5839
+ ' "\u{1F6E1}\uFE0F CybeDefend gap analysis: nothing this session warrants a new',
5840
+ ' standing rule. No proposals."',
5841
+ ""
5842
+ ].join("\n");
5843
+ }
5844
+ function composeDiffScanBody(changedFiles) {
5845
+ const total = changedFiles.length;
5846
+ const shown = changedFiles.slice(0, CHANGED_FILES_DISPLAY);
5847
+ const heading = total > CHANGED_FILES_DISPLAY ? `**Changed files this session (showing first ${CHANGED_FILES_DISPLAY} of ${total}):**` : `**Changed files this session (${total}):**`;
5848
+ const fileLines = total > 0 ? [
5849
+ heading,
5850
+ ...shown.map((f) => `- \`${f}\``),
5851
+ "(If you changed other files not listed above, include them too.)"
5852
+ ] : [
5853
+ "Use the repo-relative paths of every file you created or edited this",
5854
+ "session as the `paths`."
5855
+ ];
5856
+ return [
5857
+ "## \u{1F50D} CybeDefend pre-commit diff scan",
5858
+ "",
5859
+ "Before the user commits, run a best-effort security scan (SAST / IaC /",
5860
+ "secrets) limited to the files you changed this session.",
5861
+ "",
5862
+ ...fileLines,
5863
+ "",
5864
+ "### Required next-turn action",
5865
+ "1. Call `cybe_scan_diff(projectId, { paths })` with those repo-relative",
5866
+ " PATHS (paths only \u2014 NOT the file contents). It returns",
5867
+ " `{ scanId, uploadUrl, expiresAt }`.",
5868
+ "2. Build a ZIP of those exact files (full current content, repo-relative",
5869
+ " entry names \u2014 e.g. `zip -q diff.zip <paths>`) and upload it with an HTTP",
5870
+ " PUT to `uploadUrl` (header `Content-Type: application/zip`) BEFORE",
5871
+ ' `expiresAt` (e.g. `curl -X PUT -H "Content-Type: application/zip"',
5872
+ ' --data-binary @diff.zip "<uploadUrl>"`). The contents go straight to the',
5873
+ " bucket, never through the MCP tool.",
5874
+ "3. Poll `cybe_scan_status(projectId, scanId)` until it is completed, then",
5875
+ " read `cybe_scan_findings(projectId, scanId)`.",
5876
+ "4. Fix or justify every finding. For a confident false positive, add a",
5877
+ " `// cybedefend-ignore: <reason>` comment on the offending line so it",
5878
+ " stays silenced on the full scan too.",
5879
+ "",
5880
+ "This diff scan is best-effort \u2014 it COMPLEMENTS, never replaces, the full",
5881
+ "scan (secrets are head-only; cross-file SAST context may be missing). Caps:",
5882
+ "300 files / 1 MB per file / 3 MB total \u2014 over the cap the scan fails, so run",
5883
+ "a full scan instead.",
5756
5884
  ""
5757
5885
  ].join("\n");
5758
5886
  }
5887
+ function composeStopReason(withDiffScan) {
5888
+ const scan = withDiffScan ? ", then run a pre-commit `cybe_scan_diff` on the files you changed (poll `cybe_scan_status`, read `cybe_scan_findings`) and fix or justify each finding" : "";
5889
+ return "\u{1F6E1}\uFE0F CybeDefend \u2014 before wrapping up, run the end-of-session review: gap-analyse the request against this session's business rules and, for any uncovered security/data concern, offer to propose a rule via AskUserQuestion (`cybe_rules_report_missing` only on an explicit yes)" + scan + ".";
5890
+ }
5759
5891
 
5760
5892
  // src/hooks/runtime/pre-compact.ts
5761
5893
  async function preCompactHook(deps = {}) {
@@ -6382,6 +6514,31 @@ var PATTERNS = [
6382
6514
  re: /AKIA[0-9A-Z]{16}/g,
6383
6515
  captureIndex: null
6384
6516
  },
6517
+ // 4b. Anthropic API key (matched before the generic sk- pattern below)
6518
+ {
6519
+ re: /sk-ant-[0-9A-Za-z_-]{16,}/g,
6520
+ captureIndex: null
6521
+ },
6522
+ // 4c. OpenAI API key
6523
+ {
6524
+ re: /sk-[0-9A-Za-z]{20,}/g,
6525
+ captureIndex: null
6526
+ },
6527
+ // 4d. Google API key
6528
+ {
6529
+ re: /AIza[0-9A-Za-z_-]{35,}/g,
6530
+ captureIndex: null
6531
+ },
6532
+ // 4e. Slack token (bot/user/app/refresh/legacy)
6533
+ {
6534
+ re: /xox[baprs]-[0-9A-Za-z-]{10,}/g,
6535
+ captureIndex: null
6536
+ },
6537
+ // 4f. Credentials embedded in a URL: scheme://user:pass@host — redact user:pass
6538
+ {
6539
+ re: /(?<=:\/\/)[^/\s:@]+:[^/\s:@]+(?=@)/g,
6540
+ captureIndex: null
6541
+ },
6385
6542
  // 5. Bearer token — preserve the "Bearer " keyword, replace only the token.
6386
6543
  // Group 1 = the prefix ("Bearer "), group 2 = the token value.
6387
6544
  {
@@ -6439,6 +6596,280 @@ function hashTarget(text) {
6439
6596
  return createHash("sha256").update(text, "utf8").digest("hex");
6440
6597
  }
6441
6598
 
6599
+ // src/hooks/runtime/near-miss.ts
6600
+ var RE_CLASS_ESCAPES = /* @__PURE__ */ new Set([
6601
+ "s",
6602
+ "S",
6603
+ "d",
6604
+ "D",
6605
+ "w",
6606
+ "W",
6607
+ "b",
6608
+ "B",
6609
+ "n",
6610
+ "r",
6611
+ "t",
6612
+ "f",
6613
+ "v",
6614
+ "A",
6615
+ "Z",
6616
+ "z",
6617
+ "G"
6618
+ ]);
6619
+ var TOKEN_CHARS = /[A-Za-z0-9_\-/=:.@]/;
6620
+ function dedupe(xs) {
6621
+ return [...new Set(xs.filter((x) => x.length > 0))];
6622
+ }
6623
+ function basename(p) {
6624
+ const parts = p.split("/");
6625
+ return parts[parts.length - 1] ?? p;
6626
+ }
6627
+ function expandTilde(p, homeDir) {
6628
+ if (p === "~") return homeDir;
6629
+ if (p.startsWith("~/")) return `${homeDir}/${p.slice(2)}`;
6630
+ return p;
6631
+ }
6632
+ function extractCommandAnchors(src) {
6633
+ const tokens = [];
6634
+ const opLiterals = [];
6635
+ let buf = "";
6636
+ const flush = () => {
6637
+ if (buf) tokens.push(buf);
6638
+ buf = "";
6639
+ };
6640
+ for (let i = 0; i < src.length; i++) {
6641
+ const c = src[i];
6642
+ if (c === "\\") {
6643
+ const nxt = src[i + 1] ?? "";
6644
+ i++;
6645
+ if ("|><&".includes(nxt)) {
6646
+ flush();
6647
+ opLiterals.push(nxt);
6648
+ } else if (RE_CLASS_ESCAPES.has(nxt)) {
6649
+ flush();
6650
+ } else {
6651
+ buf += nxt;
6652
+ }
6653
+ continue;
6654
+ }
6655
+ if (c === "[") {
6656
+ flush();
6657
+ while (i < src.length && src[i] !== "]") i++;
6658
+ continue;
6659
+ }
6660
+ if (c === ".") {
6661
+ flush();
6662
+ continue;
6663
+ }
6664
+ if (TOKEN_CHARS.test(c)) {
6665
+ buf += c;
6666
+ continue;
6667
+ }
6668
+ flush();
6669
+ }
6670
+ flush();
6671
+ const commands = [];
6672
+ const flags = [];
6673
+ const literals = [];
6674
+ for (const raw of tokens) {
6675
+ const t = raw;
6676
+ if (/^--?[A-Za-z][A-Za-z0-9-]*$/.test(t)) {
6677
+ flags.push(t);
6678
+ } else if (/[/=:@]/.test(t) || /\d{1,3}(\.\d{1,3}){2,}/.test(t)) {
6679
+ literals.push(t);
6680
+ } else if (/^[A-Za-z][A-Za-z0-9_-]{1,}$/.test(t)) {
6681
+ commands.push(t);
6682
+ } else if (t.length >= 3) {
6683
+ literals.push(t);
6684
+ }
6685
+ }
6686
+ const subLiterals = [];
6687
+ for (const lit of literals) {
6688
+ const m = lit.match(/\/[A-Za-z]{2,}\//g);
6689
+ if (m) subLiterals.push(...m);
6690
+ }
6691
+ return {
6692
+ commands: dedupe(commands),
6693
+ flags: dedupe(flags),
6694
+ literals: dedupe([...literals, ...subLiterals, ...opLiterals])
6695
+ };
6696
+ }
6697
+ var GENERIC_SEGMENTS = /* @__PURE__ */ new Set([
6698
+ "config",
6699
+ "conf",
6700
+ "configs",
6701
+ "settings",
6702
+ "data",
6703
+ "home",
6704
+ "user",
6705
+ "users",
6706
+ "usr",
6707
+ "var",
6708
+ "tmp",
6709
+ "opt",
6710
+ "etc",
6711
+ "app",
6712
+ "apps",
6713
+ "src",
6714
+ "lib",
6715
+ "bin",
6716
+ "dist",
6717
+ "build",
6718
+ "out",
6719
+ "target",
6720
+ "public",
6721
+ "static",
6722
+ "assets",
6723
+ "main",
6724
+ "index",
6725
+ "test",
6726
+ "tests",
6727
+ "docs",
6728
+ "node_modules",
6729
+ "vendor",
6730
+ "project"
6731
+ ]);
6732
+ function extractGlobAnchors(glob) {
6733
+ const segs = glob.split("/");
6734
+ const literals = [];
6735
+ let ext = null;
6736
+ for (const seg of segs) {
6737
+ if (!seg || seg === "**" || seg === "*") continue;
6738
+ const bare = seg.replace(/[*?]/g, "");
6739
+ if (!bare) continue;
6740
+ if (bare.startsWith(".") && !bare.slice(1).includes(".")) {
6741
+ literals.push(bare);
6742
+ if (bare.length <= 6) ext = bare;
6743
+ } else if (bare.includes(".")) {
6744
+ literals.push(bare);
6745
+ } else if (bare.length >= 3 && !GENERIC_SEGMENTS.has(bare.toLowerCase())) {
6746
+ literals.push(bare);
6747
+ }
6748
+ }
6749
+ return { ext, literals: dedupe(literals) };
6750
+ }
6751
+ function nearMissCommand(target, rule) {
6752
+ if (!rule.commandRegex) return false;
6753
+ const { commands, flags, literals } = extractCommandAnchors(
6754
+ rule.commandRegex
6755
+ );
6756
+ if (commands.length === 0 && flags.length === 0 && literals.length === 0)
6757
+ return false;
6758
+ const cmd = target.command;
6759
+ const base = basename(cmd);
6760
+ const joined = [cmd, ...target.argv].join(" ");
6761
+ const flagHits = (fl) => {
6762
+ if (fl.startsWith("--")) return joined.includes(fl);
6763
+ const letters = fl.replace(/^-+/, "").split("");
6764
+ if (letters.length === 0) return false;
6765
+ return target.argv.some(
6766
+ (t) => t.startsWith("-") && letters.every((ch) => t.includes(ch))
6767
+ );
6768
+ };
6769
+ const cmdHitCount = commands.filter(
6770
+ (a) => cmd === a || base === a || target.argv.includes(a)
6771
+ ).length;
6772
+ const flagHitCount = flags.filter(flagHits).length;
6773
+ const litHitCount = literals.filter((l) => joined.includes(l)).length;
6774
+ const core = cmdHitCount > 0 || litHitCount > 0;
6775
+ const totalAnchors = commands.length + flags.length + literals.length;
6776
+ const signalCount = cmdHitCount + flagHitCount + litHitCount;
6777
+ const threshold = totalAnchors >= 2 ? 2 : 1;
6778
+ return core && signalCount >= threshold;
6779
+ }
6780
+ function nearMissFile(target, rule, homeDir) {
6781
+ const globs = rule.filePathGlobs;
6782
+ if (!globs || globs.length === 0) return false;
6783
+ const path = expandTilde(target.path, homeDir);
6784
+ const base = basename(path);
6785
+ const segs = path.split("/");
6786
+ for (const g of globs) {
6787
+ const { ext, literals } = extractGlobAnchors(g);
6788
+ if (ext && path.endsWith(ext)) return true;
6789
+ for (const l of literals) {
6790
+ if (l.startsWith(".")) {
6791
+ if (base.includes(l) || path.includes(`/${l}`) || path.endsWith(l))
6792
+ return true;
6793
+ } else if (segs.includes(l) || base.includes(l)) {
6794
+ return true;
6795
+ }
6796
+ }
6797
+ }
6798
+ return false;
6799
+ }
6800
+ function literalsFromGlobList(globs) {
6801
+ const out = [];
6802
+ for (const g of globs) {
6803
+ for (const part of g.split(/[*?]/)) {
6804
+ const p = part.replace(/^[._-]+|[._-]+$/g, "");
6805
+ if (p.length >= 3) out.push(part.length >= 3 ? part : p);
6806
+ }
6807
+ }
6808
+ return dedupe(out);
6809
+ }
6810
+ function nearMissEnv(target, rule) {
6811
+ const globs = rule.envNameGlobs;
6812
+ if (!globs || globs.length === 0) return false;
6813
+ const name = target.name;
6814
+ for (const g of globs) {
6815
+ const lead = g.startsWith("*");
6816
+ const trail = g.endsWith("*");
6817
+ const core = g.replace(/[*?]/g, "");
6818
+ if (core.length < 3) continue;
6819
+ if (lead && trail) {
6820
+ if (name.includes(core)) return true;
6821
+ } else if (trail) {
6822
+ if (name.startsWith(core)) return true;
6823
+ } else if (lead) {
6824
+ if (name.endsWith(core)) return true;
6825
+ } else if (name === core) {
6826
+ return true;
6827
+ }
6828
+ }
6829
+ return false;
6830
+ }
6831
+ function nearMissHttp(target, rule) {
6832
+ const globs = rule.httpHostGlobs;
6833
+ if (!globs || globs.length === 0) return false;
6834
+ let hostname;
6835
+ try {
6836
+ hostname = new URL(target.url).hostname;
6837
+ } catch {
6838
+ hostname = target.url;
6839
+ }
6840
+ const literals = literalsFromGlobList(globs);
6841
+ const prefixes = [];
6842
+ for (const l of literals) {
6843
+ const m = l.match(/^(\d{1,3}\.\d{1,3})\.\d/);
6844
+ if (m) prefixes.push(`${m[1]}.`);
6845
+ }
6846
+ return [...literals, ...prefixes].some((l) => hostname.includes(l));
6847
+ }
6848
+ function nearMissGit(target, rule) {
6849
+ if (rule.gitSubcommand && rule.gitSubcommand.length > 0) {
6850
+ return rule.gitSubcommand.includes(target.subcommand);
6851
+ }
6852
+ return false;
6853
+ }
6854
+ function nearMiss(req, rule) {
6855
+ if (rule.toolMatch && rule.toolMatch.length > 0 && !rule.toolMatch.includes(req.tool)) {
6856
+ return false;
6857
+ }
6858
+ const { target, homeDir } = req;
6859
+ switch (target.type) {
6860
+ case "command":
6861
+ return nearMissCommand(target, rule);
6862
+ case "file":
6863
+ return nearMissFile(target, rule, homeDir);
6864
+ case "env":
6865
+ return nearMissEnv(target, rule);
6866
+ case "http":
6867
+ return nearMissHttp(target, rule);
6868
+ case "git":
6869
+ return nearMissGit(target, rule);
6870
+ }
6871
+ }
6872
+
6442
6873
  // src/hooks/runtime/guard-rules-cache.ts
6443
6874
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync5, existsSync as existsSync9, mkdirSync as mkdirSync4 } from "node:fs";
6444
6875
  import { dirname as dirname3 } from "node:path";
@@ -6489,7 +6920,9 @@ function snakeToCamelRule(raw) {
6489
6920
  gitSubcommand: raw["git_subcommand"] ?? raw["gitSubcommand"],
6490
6921
  denyMessage: raw["deny_message"] ?? raw["denyMessage"],
6491
6922
  warnMessage: raw["warn_message"] ?? raw["warnMessage"],
6492
- defaultAction: raw["default_action"] ?? raw["defaultAction"]
6923
+ defaultAction: raw["default_action"] ?? raw["defaultAction"],
6924
+ llmJudgeEnabled: raw["llm_judge_enabled"] ?? raw["llmJudgeEnabled"],
6925
+ catchEligible: raw["catch_eligible"] ?? raw["catchEligible"]
6493
6926
  };
6494
6927
  }
6495
6928
  function makeGuardRulesCache(opts) {
@@ -6500,7 +6933,7 @@ function makeGuardRulesCache(opts) {
6500
6933
  const entry = readCache(ctx.projectId);
6501
6934
  const ageSec = entry ? (Date.now() - entry.fetchedAt) / 1e3 : Infinity;
6502
6935
  if (entry && ageSec < staleSec) {
6503
- return { kind: "rules", rules: entry.rules };
6936
+ return { kind: "rules", rules: entry.rules, llmJudge: entry.llmJudge };
6504
6937
  }
6505
6938
  try {
6506
6939
  const result = await fetchFn({
@@ -6512,19 +6945,18 @@ function makeGuardRulesCache(opts) {
6512
6945
  ...entry,
6513
6946
  fetchedAt: Date.now()
6514
6947
  });
6515
- return { kind: "rules", rules: entry.rules };
6948
+ return { kind: "rules", rules: entry.rules, llmJudge: entry.llmJudge };
6516
6949
  }
6517
- const transformedRules = result.rules.map(
6518
- (r) => snakeToCamelRule(r)
6519
- );
6950
+ const transformedRules = result.rules.map((r) => snakeToCamelRule(r));
6520
6951
  const fresh = {
6521
6952
  projectId: ctx.projectId,
6522
6953
  rules: transformedRules,
6523
6954
  etag: result.etag,
6524
- fetchedAt: Date.now()
6955
+ fetchedAt: Date.now(),
6956
+ llmJudge: result.llmJudge ?? null
6525
6957
  };
6526
6958
  writeCache(fresh);
6527
- return { kind: "rules", rules: fresh.rules };
6959
+ return { kind: "rules", rules: fresh.rules, llmJudge: fresh.llmJudge };
6528
6960
  } catch (e) {
6529
6961
  const msg = e instanceof Error ? e.message : String(e);
6530
6962
  if (entry?.rules?.length) {
@@ -6533,7 +6965,7 @@ function makeGuardRulesCache(opts) {
6533
6965
  `[VibeDefend] Action Guards: rule refresh failed (${msg}). Using cached rules (age=${ageSec2}s).
6534
6966
  `
6535
6967
  );
6536
- return { kind: "rules", rules: entry.rules };
6968
+ return { kind: "rules", rules: entry.rules, llmJudge: entry.llmJudge };
6537
6969
  }
6538
6970
  return {
6539
6971
  kind: "unavailable",
@@ -6593,22 +7025,127 @@ async function fetchEffectiveRulesFromGateway(ctx, opts = {}) {
6593
7025
  const data = body.data ?? body;
6594
7026
  const rules = Array.isArray(data.rules) ? data.rules : [];
6595
7027
  const etag = res.headers.get("ETag");
6596
- return { rules, etag, notModified: false };
7028
+ const llmJudge = parseLlmJudgeHint(data["llm_judge"]);
7029
+ return { rules, etag, notModified: false, llmJudge };
7030
+ }
7031
+ function parseLlmJudgeHint(raw) {
7032
+ if (!raw || typeof raw !== "object") return null;
7033
+ const o = raw;
7034
+ return {
7035
+ enabled: Boolean(o["enabled"]),
7036
+ quotaRemaining: typeof o["quota_remaining"] === "number" ? o["quota_remaining"] : -1,
7037
+ quotaExhausted: Boolean(o["quota_exhausted"])
7038
+ };
7039
+ }
7040
+
7041
+ // src/hooks/runtime/judge.ts
7042
+ var DEFAULT_TIMEOUT_MS3 = 1500;
7043
+ async function callJudge(input, opts = {}) {
7044
+ const f = opts.fetchImpl ?? globalThis.fetch;
7045
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
7046
+ const url = `${input.apiBaseResolved}/project/${encodeURIComponent(input.projectId)}/action-guards/judge`;
7047
+ try {
7048
+ const res = await f(url, {
7049
+ method: "POST",
7050
+ headers: {
7051
+ Authorization: `Bearer ${input.token}`,
7052
+ "Content-Type": "application/json"
7053
+ },
7054
+ body: JSON.stringify({
7055
+ tool: input.tool,
7056
+ agent: input.agent,
7057
+ session_id: input.sessionId,
7058
+ phase: input.phase ?? "confirm",
7059
+ action_summary: input.actionSummary,
7060
+ action_hash: input.actionHash,
7061
+ rules: input.rules
7062
+ }),
7063
+ signal: AbortSignal.timeout(timeoutMs)
7064
+ });
7065
+ if (!res.ok) return null;
7066
+ const body = await res.json();
7067
+ const data = body.data ?? body;
7068
+ const decision = data["decision"];
7069
+ if (decision !== "block" && decision !== "allow") return null;
7070
+ return {
7071
+ decision,
7072
+ reason: typeof data["reason"] === "string" ? data["reason"] : "",
7073
+ model: typeof data["model"] === "string" ? data["model"] : "",
7074
+ quotaRemaining: typeof data["quota_remaining"] === "number" ? data["quota_remaining"] : -1,
7075
+ quotaExceeded: Boolean(data["quota_exceeded"])
7076
+ };
7077
+ } catch {
7078
+ return null;
7079
+ }
7080
+ }
7081
+
7082
+ // src/hooks/runtime/judge-cache.ts
7083
+ import { readFileSync as readFileSync10, writeFileSync as writeFileSync6, existsSync as existsSync10, mkdirSync as mkdirSync5 } from "node:fs";
7084
+ import { dirname as dirname4, join as join8 } from "node:path";
7085
+ import { homedir as homedir6 } from "node:os";
7086
+ var DEFAULT_JUDGE_CACHE_FILE = join8(
7087
+ homedir6(),
7088
+ ".cybedefend",
7089
+ "guards-judge-cache.json"
7090
+ );
7091
+ var DEFAULT_JUDGE_TTL_MS = 12e4;
7092
+ function makeJudgeCache(opts = {}) {
7093
+ const cacheFile = opts.cacheFile ?? DEFAULT_JUDGE_CACHE_FILE;
7094
+ const ttlMs = opts.ttlMs ?? DEFAULT_JUDGE_TTL_MS;
7095
+ const now = opts.now ?? (() => Date.now());
7096
+ function readAll() {
7097
+ if (!existsSync10(cacheFile)) return {};
7098
+ try {
7099
+ return JSON.parse(readFileSync10(cacheFile, "utf8"));
7100
+ } catch {
7101
+ return {};
7102
+ }
7103
+ }
7104
+ function get(key) {
7105
+ const entry = readAll()[key];
7106
+ if (!entry) return null;
7107
+ if (now() - entry.at > ttlMs) return null;
7108
+ return entry.res;
7109
+ }
7110
+ function set(key, res) {
7111
+ try {
7112
+ const all = readAll();
7113
+ const t = now();
7114
+ for (const k of Object.keys(all)) {
7115
+ if (t - all[k].at > ttlMs) delete all[k];
7116
+ }
7117
+ all[key] = { res, at: t };
7118
+ ensureDirFor3(cacheFile);
7119
+ writeFileSync6(cacheFile, JSON.stringify(all), "utf8");
7120
+ } catch {
7121
+ }
7122
+ }
7123
+ return { get, set };
7124
+ }
7125
+ function ensureDirFor3(filePath) {
7126
+ const dir = dirname4(filePath);
7127
+ if (!existsSync10(dir)) {
7128
+ mkdirSync5(dir, { recursive: true });
7129
+ }
6597
7130
  }
6598
7131
 
7132
+ // src/hooks/runtime/guard-check.ts
7133
+ import { homedir as homedir8 } from "node:os";
7134
+ import { join as join10 } from "node:path";
7135
+
6599
7136
  // src/hooks/runtime/guard-violations-buffer.ts
6600
7137
  import {
6601
7138
  appendFileSync,
6602
- existsSync as existsSync10,
6603
- readFileSync as readFileSync10,
6604
- writeFileSync as writeFileSync6,
6605
- mkdirSync as mkdirSync5
7139
+ existsSync as existsSync11,
7140
+ readFileSync as readFileSync11,
7141
+ writeFileSync as writeFileSync7,
7142
+ mkdirSync as mkdirSync6
6606
7143
  } from "node:fs";
6607
- import { dirname as dirname4 } from "node:path";
6608
- import { homedir as homedir6 } from "node:os";
6609
- import { join as join8 } from "node:path";
6610
- var DEFAULT_BUFFER_FILE = join8(
6611
- homedir6(),
7144
+ import { dirname as dirname5 } from "node:path";
7145
+ import { homedir as homedir7 } from "node:os";
7146
+ import { join as join9 } from "node:path";
7147
+ var DEFAULT_BUFFER_FILE = join9(
7148
+ homedir7(),
6612
7149
  ".cybedefend",
6613
7150
  "guards-violations-buffer.jsonl"
6614
7151
  );
@@ -6621,16 +7158,16 @@ function makeViolationsBuffer(opts = {}) {
6621
7158
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6622
7159
  };
6623
7160
  try {
6624
- ensureDirFor3(bufferFile);
7161
+ ensureDirFor4(bufferFile);
6625
7162
  appendFileSync(bufferFile, JSON.stringify(record) + "\n", "utf8");
6626
7163
  } catch {
6627
7164
  }
6628
7165
  }
6629
7166
  async function flushViolations2(ctx, postFn) {
6630
- if (!existsSync10(bufferFile)) return;
7167
+ if (!existsSync11(bufferFile)) return;
6631
7168
  let content;
6632
7169
  try {
6633
- content = readFileSync10(bufferFile, "utf8").trim();
7170
+ content = readFileSync11(bufferFile, "utf8").trim();
6634
7171
  } catch {
6635
7172
  return;
6636
7173
  }
@@ -6649,7 +7186,7 @@ function makeViolationsBuffer(opts = {}) {
6649
7186
  }
6650
7187
  if (records.length === 0) {
6651
7188
  try {
6652
- writeFileSync6(bufferFile, "", "utf8");
7189
+ writeFileSync7(bufferFile, "", "utf8");
6653
7190
  } catch {
6654
7191
  }
6655
7192
  return;
@@ -6679,20 +7216,20 @@ function makeViolationsBuffer(opts = {}) {
6679
7216
  }
6680
7217
  try {
6681
7218
  if (failed.length === 0) {
6682
- writeFileSync6(bufferFile, "", "utf8");
7219
+ writeFileSync7(bufferFile, "", "utf8");
6683
7220
  } else {
6684
7221
  const serialised = failed.map((r) => JSON.stringify(r)).join("\n") + "\n";
6685
- writeFileSync6(bufferFile, serialised, "utf8");
7222
+ writeFileSync7(bufferFile, serialised, "utf8");
6686
7223
  }
6687
7224
  } catch {
6688
7225
  }
6689
7226
  }
6690
7227
  return { bufferViolation: bufferViolation2, flushViolations: flushViolations2 };
6691
7228
  }
6692
- function ensureDirFor3(filePath) {
6693
- const dir = dirname4(filePath);
6694
- if (!existsSync10(dir)) {
6695
- mkdirSync5(dir, { recursive: true });
7229
+ function ensureDirFor4(filePath) {
7230
+ const dir = dirname5(filePath);
7231
+ if (!existsSync11(dir)) {
7232
+ mkdirSync6(dir, { recursive: true });
6696
7233
  }
6697
7234
  }
6698
7235
  var _defaultBuffer = makeViolationsBuffer();
@@ -6718,11 +7255,11 @@ function appendDebugLog(entry) {
6718
7255
  try {
6719
7256
  const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
6720
7257
  if (!homeDir) return;
6721
- const { mkdirSync: mkdirSync6, appendFileSync: appendFileSync2 } = __require("node:fs");
6722
- const { join: join9 } = __require("node:path");
6723
- const dir = join9(homeDir, ".cybedefend");
6724
- mkdirSync6(dir, { recursive: true });
6725
- appendFileSync2(join9(dir, "hook-debug.log"), entry + "\n", "utf8");
7258
+ const { mkdirSync: mkdirSync7, appendFileSync: appendFileSync2 } = __require("node:fs");
7259
+ const { join: join11 } = __require("node:path");
7260
+ const dir = join11(homeDir, ".cybedefend");
7261
+ mkdirSync7(dir, { recursive: true });
7262
+ appendFileSync2(join11(dir, "hook-debug.log"), entry + "\n", "utf8");
6726
7263
  } catch {
6727
7264
  }
6728
7265
  }
@@ -6893,36 +7430,171 @@ function stringifyTarget(t) {
6893
7430
  return `git ${t.subcommand}`;
6894
7431
  }
6895
7432
  }
7433
+ async function runJudgeStep(args) {
7434
+ const {
7435
+ judge,
7436
+ llmJudgeHint,
7437
+ rules,
7438
+ decision,
7439
+ projectContext,
7440
+ request,
7441
+ sessionId
7442
+ } = args;
7443
+ const matched = decision.matchedRuleIds.map((id) => rules.find((r) => r.id === id)).filter((r) => r !== void 0);
7444
+ const llmRules = matched.filter(
7445
+ (r) => r.action === "deny" && r.llmJudgeEnabled
7446
+ );
7447
+ if (llmRules.length === 0) return void 0;
7448
+ if (decision.maxSeverity === "critical") return void 0;
7449
+ if (llmJudgeHint?.enabled === false) return void 0;
7450
+ const targetStr = stringifyTarget(request.target);
7451
+ const actionHash = hashTarget(targetStr);
7452
+ const ruleIds = llmRules.map((r) => r.id).sort().join(",");
7453
+ const cacheKey = `${actionHash}|${ruleIds}|confirm`;
7454
+ let res = judge.cacheGet(cacheKey);
7455
+ if (!res) {
7456
+ res = await judge.callJudge({
7457
+ apiBaseResolved: projectContext.apiBaseResolved,
7458
+ token: projectContext.token,
7459
+ projectId: request.projectId,
7460
+ tool: request.tool,
7461
+ agent: "claude-code",
7462
+ sessionId,
7463
+ phase: "confirm",
7464
+ // Judge over the FULL (schema-bounded 2048) redacted action — the default
7465
+ // redactTarget cap of 256 (right for the DB-stored violation summary) would
7466
+ // truncate a long dangerous action and risk an overturn on a partial view.
7467
+ actionSummary: redactTarget(targetStr, 2048),
7468
+ actionHash,
7469
+ rules: llmRules.map((r) => ({
7470
+ id: r.id,
7471
+ name: r.name ?? r.id,
7472
+ description: r.description ?? "",
7473
+ severity: r.severity
7474
+ }))
7475
+ });
7476
+ if (res) judge.cacheSet(cacheKey, res);
7477
+ }
7478
+ if (!res) {
7479
+ return {
7480
+ engine: "llm",
7481
+ outcome: "fallback",
7482
+ fallback_reason: "unavailable"
7483
+ };
7484
+ }
7485
+ if (res.quotaExceeded) {
7486
+ judge.warnOnce(
7487
+ sessionId,
7488
+ "VibeDefend judge quota exhausted \u2014 regex-only enforcement for this session."
7489
+ );
7490
+ return { engine: "llm", outcome: "quota_exceeded", model: res.model };
7491
+ }
7492
+ if (res.decision === "allow") {
7493
+ return { engine: "llm", outcome: "overturned", model: res.model };
7494
+ }
7495
+ return { engine: "llm", outcome: "confirmed", model: res.model };
7496
+ }
7497
+ async function runCatchJudgeStep(args) {
7498
+ const {
7499
+ judge,
7500
+ llmJudgeHint,
7501
+ rules,
7502
+ decision,
7503
+ projectContext,
7504
+ request,
7505
+ sessionId
7506
+ } = args;
7507
+ if (llmJudgeHint?.enabled === false) return void 0;
7508
+ const matchedIds = new Set(decision.matchedRuleIds);
7509
+ const candidates = rules.filter(
7510
+ (r) => r.action === "deny" && r.llmJudgeEnabled && r.catchEligible && r.severity !== "critical" && !matchedIds.has(r.id)
7511
+ );
7512
+ if (candidates.length === 0) return void 0;
7513
+ const near = candidates.filter((r) => nearMiss(request, r));
7514
+ if (near.length === 0) return void 0;
7515
+ const targetStr = stringifyTarget(request.target);
7516
+ const actionHash = hashTarget(targetStr);
7517
+ const ruleIds = near.map((r) => r.id).sort();
7518
+ const cacheKey = `${actionHash}|${ruleIds.join(",")}|catch`;
7519
+ let res = judge.cacheGet(cacheKey);
7520
+ if (!res) {
7521
+ res = await judge.callJudge({
7522
+ apiBaseResolved: projectContext.apiBaseResolved,
7523
+ token: projectContext.token,
7524
+ projectId: request.projectId,
7525
+ tool: request.tool,
7526
+ agent: "claude-code",
7527
+ sessionId,
7528
+ phase: "catch",
7529
+ actionSummary: redactTarget(targetStr, 2048),
7530
+ actionHash,
7531
+ rules: near.map((r) => ({
7532
+ id: r.id,
7533
+ name: r.name ?? r.id,
7534
+ description: r.description ?? "",
7535
+ severity: r.severity
7536
+ }))
7537
+ });
7538
+ if (res) judge.cacheSet(cacheKey, res);
7539
+ }
7540
+ if (!res) return void 0;
7541
+ if (res.quotaExceeded) {
7542
+ judge.warnOnce(
7543
+ sessionId,
7544
+ "VibeDefend judge quota exhausted \u2014 regex-only enforcement for this session."
7545
+ );
7546
+ return void 0;
7547
+ }
7548
+ if (res.decision !== "block") return void 0;
7549
+ const top = near[0];
7550
+ const message = `Blocked by the VibeDefend judge: this action resembles a dangerous pattern (${top.name ?? top.id}) the rules did not catch.`;
7551
+ return {
7552
+ audit: { engine: "llm", outcome: "caught", model: res.model },
7553
+ ruleIds,
7554
+ message
7555
+ };
7556
+ }
7557
+ function emitUnverifiedBanner(reason) {
7558
+ const banner = [
7559
+ "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
7560
+ "\u2588 VibeDefend Action Guards \u2014 UNVERIFIED \u2588",
7561
+ "\u2588\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2588",
7562
+ "\u2588 Action Guards could NOT be enforced: \u2588",
7563
+ `\u2588 ${reason.slice(0, 76).padEnd(76)}\u2588`,
7564
+ "\u2588 \u2588",
7565
+ "\u2588 Your tool call is going through WITHOUT enforcement. This means: \u2588",
7566
+ "\u2588 \u2022 sensitive files / commands / URLs are NOT being blocked \u2588",
7567
+ "\u2588 \u2022 Action Guards rules are NOT being applied \u2588",
7568
+ "\u2588 \u2588",
7569
+ "\u2588 Fix this immediately: \u2588",
7570
+ "\u2588 vibedefend login \u2588",
7571
+ "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
7572
+ ].join("\n") + "\n";
7573
+ process.stderr.write(banner);
7574
+ const isCodex = typeof process.env.CODEX_SANDBOX !== "undefined" || typeof process.env.CODEX_AGENT !== "undefined";
7575
+ if (isCodex) {
7576
+ try {
7577
+ const ctx = JSON.stringify({
7578
+ additionalContext: `VibeDefend Action Guards UNVERIFIED \u2014 ${reason} Tool call allowed WITHOUT enforcement. Run \`vibedefend login\` to restore protection.`
7579
+ });
7580
+ process.stdout.write(ctx + "\n");
7581
+ } catch {
7582
+ }
7583
+ }
7584
+ }
6896
7585
  async function runPreToolUseGuardWith(opts) {
6897
- const { stdinEnvelope, projectContext, rulesOutcome, evaluateFn, bufferViolationFn } = opts;
7586
+ const {
7587
+ stdinEnvelope,
7588
+ projectContext,
7589
+ rulesOutcome,
7590
+ evaluateFn,
7591
+ bufferViolationFn
7592
+ } = opts;
6898
7593
  if (!projectContext) return 0;
6899
7594
  if (rulesOutcome.kind === "unavailable") {
6900
- const banner = [
6901
- "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
6902
- "\u2588 VibeDefend Action Guards \u2014 UNVERIFIED \u2588",
6903
- "\u2588\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2588",
6904
- "\u2588 The policy engine could not be reached: \u2588",
6905
- `\u2588 ${rulesOutcome.reason.slice(0, 76).padEnd(76)}\u2588`,
6906
- "\u2588 \u2588",
6907
- "\u2588 Your tool call is going through WITHOUT enforcement. This means: \u2588",
6908
- "\u2588 \u2022 sensitive files / commands / URLs are NOT being blocked \u2588",
6909
- "\u2588 \u2022 Action Guards rules are NOT being applied \u2588",
6910
- "\u2588 \u2588",
6911
- "\u2588 Fix this immediately: \u2588",
6912
- "\u2588 vibedefend login \u2588",
6913
- "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
6914
- ].join("\n") + "\n";
6915
- process.stderr.write(banner);
6916
- const isCodex = typeof process.env.CODEX_SANDBOX !== "undefined" || typeof process.env.CODEX_AGENT !== "undefined";
6917
- if (isCodex) {
6918
- try {
6919
- const ctx = JSON.stringify({
6920
- additionalContext: `VibeDefend Action Guards UNVERIFIED \u2014 policy engine unreachable (${rulesOutcome.reason}). Tool call allowed WITHOUT enforcement. Run \`vibedefend login\` to restore protection.`
6921
- });
6922
- process.stdout.write(ctx + "\n");
6923
- } catch {
6924
- }
6925
- }
7595
+ emitUnverifiedBanner(
7596
+ `Policy engine unreachable: ${rulesOutcome.reason}`
7597
+ );
6926
7598
  return 0;
6927
7599
  }
6928
7600
  const rules = rulesOutcome.rules;
@@ -6934,17 +7606,54 @@ async function runPreToolUseGuardWith(opts) {
6934
7606
  projectId: projectContext.projectId,
6935
7607
  sessionId
6936
7608
  };
6937
- const decision = evaluateFn(rules, guardRequest);
6938
- if (decision.verdict !== "allow" && decision.matchedRuleIds.length > 0) {
7609
+ let decision = evaluateFn(rules, guardRequest);
7610
+ let judgeAudit;
7611
+ if (opts.judge && decision.verdict === "deny" && decision.matchedRuleIds.length > 0) {
7612
+ judgeAudit = await runJudgeStep({
7613
+ judge: opts.judge,
7614
+ llmJudgeHint: rulesOutcome.llmJudge,
7615
+ rules,
7616
+ decision,
7617
+ projectContext,
7618
+ request: guardRequest,
7619
+ sessionId
7620
+ });
7621
+ if (judgeAudit?.outcome === "overturned") {
7622
+ decision = { ...decision, verdict: "allow", message: void 0 };
7623
+ }
7624
+ }
7625
+ if (opts.judge && opts.judgeCatch && decision.verdict === "allow") {
7626
+ const caught = await runCatchJudgeStep({
7627
+ judge: opts.judge,
7628
+ llmJudgeHint: rulesOutcome.llmJudge,
7629
+ rules,
7630
+ decision,
7631
+ projectContext,
7632
+ request: guardRequest,
7633
+ sessionId
7634
+ });
7635
+ if (caught) {
7636
+ decision = {
7637
+ ...decision,
7638
+ verdict: "deny",
7639
+ matchedRuleIds: caught.ruleIds,
7640
+ message: caught.message
7641
+ };
7642
+ judgeAudit = caught.audit;
7643
+ }
7644
+ }
7645
+ const isOverturn = judgeAudit?.outcome === "overturned";
7646
+ if (decision.verdict !== "allow" && decision.matchedRuleIds.length > 0 || isOverturn) {
6939
7647
  const targetStr = stringifyTarget(guardRequest.target);
6940
7648
  const violation = {
6941
7649
  ruleId: decision.matchedRuleIds[0],
6942
7650
  agent: "claude-code",
6943
7651
  sessionId: sessionId ?? null,
6944
7652
  tool: guardRequest.tool,
6945
- outcome: decision.verdict === "deny" ? "denied" : "warned",
7653
+ outcome: decision.verdict === "deny" || isOverturn ? "denied" : "warned",
6946
7654
  targetSummary: redactTarget(targetStr),
6947
- targetHash: hashTarget(targetStr)
7655
+ targetHash: hashTarget(targetStr),
7656
+ ...judgeAudit ? { extra: { judge: judgeAudit } } : {}
6948
7657
  };
6949
7658
  try {
6950
7659
  bufferViolationFn(projectContext, violation);
@@ -7005,6 +7714,27 @@ function emitAgentMessage(verdict, message) {
7005
7714
  } catch {
7006
7715
  }
7007
7716
  }
7717
+ function makeProductionJudgeDeps() {
7718
+ const cache = makeJudgeCache();
7719
+ const warnDir = process.env.CYBEDEFEND_BASELINE_DIR ?? join10(homedir8(), ".cybedefend");
7720
+ const warnTracker = defaultSessionTracker(
7721
+ join10(warnDir, "guards-judge-warned-sessions.json")
7722
+ );
7723
+ return {
7724
+ callJudge,
7725
+ cacheGet: (k) => cache.get(k),
7726
+ cacheSet: (k, r) => cache.set(k, r),
7727
+ warnOnce: (sessionId, message) => {
7728
+ if (sessionId && warnTracker.hasSeen(sessionId)) return;
7729
+ try {
7730
+ process.stderr.write(`[VibeDefend Action Guards] ${message}
7731
+ `);
7732
+ } catch {
7733
+ }
7734
+ if (sessionId) void warnTracker.markSeen(sessionId);
7735
+ }
7736
+ };
7737
+ }
7008
7738
  async function runPreToolUseGuard() {
7009
7739
  try {
7010
7740
  const envelope = await readStdinJson();
@@ -7014,7 +7744,12 @@ async function runPreToolUseGuard() {
7014
7744
  if (!projectId) return 0;
7015
7745
  const apiBaseResolved = process.env.CYBEDEFEND_API_BASE ?? configApiBase ?? config.region.apiBase;
7016
7746
  const token = await resolveTokenWithRefresh(config.region.mcpName);
7017
- if (!token) return 0;
7747
+ if (!token) {
7748
+ emitUnverifiedBanner(
7749
+ "Not authenticated \u2014 run `vibedefend login` to restore Action Guards."
7750
+ );
7751
+ return 0;
7752
+ }
7018
7753
  const projectContext = {
7019
7754
  projectId,
7020
7755
  apiBase: configApiBase,
@@ -7035,7 +7770,11 @@ async function runPreToolUseGuard() {
7035
7770
  projectContext,
7036
7771
  rulesOutcome,
7037
7772
  evaluateFn: evaluate,
7038
- bufferViolationFn: buffer.bufferViolation.bind(buffer)
7773
+ bufferViolationFn: buffer.bufferViolation.bind(buffer),
7774
+ judge: makeProductionJudgeDeps(),
7775
+ // v1.1 catch — OFF by default; opt in with CYBEDEFEND_JUDGE_CATCH=1 once
7776
+ // the call-rate gate is validated in the field.
7777
+ judgeCatch: process.env.CYBEDEFEND_JUDGE_CATCH === "1"
7039
7778
  });
7040
7779
  try {
7041
7780
  await buffer.flushViolations(
@@ -7082,6 +7821,9 @@ function toApiViolation(r) {
7082
7821
  if (r.sessionId) {
7083
7822
  out.session_id = r.sessionId;
7084
7823
  }
7824
+ if (r.extra && Object.keys(r.extra).length > 0) {
7825
+ out.extra = r.extra;
7826
+ }
7085
7827
  return out;
7086
7828
  }
7087
7829