From 1dc018d95668fcf6e2d2f5c3093eb808fde11c02 Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Thu, 26 Mar 2026 16:40:12 -0700
Subject: [PATCH 1/9] =?UTF-8?q?fix:=20scoring=20recalibration=20=E2=80=94?=
=?UTF-8?q?=20critical=20findings=20now=20properly=20tank=20scores?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes the #1 trust-destroying bug: a secure agent scored worse (B:85)
than a vulnerable agent with shell injection (B:87).
Scoring changes:
- Severity deductions increased (critical 20→40, high 10→18, medium 4→6)
- Critical-floor clamp: 1 crit→max C, 2→max D, 3+ criticals→max F
- Unknown reachability 0.6→0.85 (assume reachable until proven safe)
- Not-assessed exploitability 0.7→0.85 (same principle)
- Correlation bonus capped at 50% of remaining domain score
Finding categorization:
- Added FindingCategory type: vulnerability | hardening | informational
- Improved isAbsenceBased() with title-pattern fallback detection
Rule severity downgrades (absence → hardening):
- AA-HO-005 "No emergency stop": critical → medium
- AA-IA-030 "No RBAC enforcement": high → medium
- AA-RA-011 "No kill switch": critical → medium
Results: Vulnerable agent B:87→D:69, Secure agent B:85→C:72
Also includes:
- guard0 CLI alias (guard0 + g0 both work)
- guard0 npm wrapper package for `npm install guard0`
- Version sync script (scripts/version.mjs)
- Updated release workflow to publish guard0 wrapper
- Updated banner to show GUARD0 branding
---
.github/workflows/release.yml | 18 +++-
.gitignore | 2 +
package.json | 10 +-
packages/guard0/README.md | 40 ++++++++
packages/guard0/package.json | 50 +++++++++
scripts/version.mjs | 137 +++++++++++++++++++++++++
src/analyzers/rules/human-oversight.ts | 5 +-
src/analyzers/rules/identity-access.ts | 5 +-
src/analyzers/rules/rogue-agent.ts | 5 +-
src/cli/branding.ts | 16 +--
src/cli/index.ts | 9 +-
src/scoring/engine.ts | 39 +++++--
src/scoring/weights.ts | 29 ++++--
src/types/finding.ts | 10 ++
tests/unit/exploitability.test.ts | 4 +-
15 files changed, 343 insertions(+), 36 deletions(-)
create mode 100644 packages/guard0/README.md
create mode 100644 packages/guard0/package.json
create mode 100644 scripts/version.mjs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 93707a5..ba83255 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -28,7 +28,23 @@ jobs:
- run: npm install -g npm@latest
- - run: npm publish --access public --provenance
+ - name: Verify version sync
+ run: node scripts/version.mjs --check
+
+ - name: Publish @guard0/g0
+ run: npm publish --access public --provenance
+
+ - name: Publish guard0 wrapper
+ working-directory: packages/guard0
+ run: |
+ LOCAL_VERSION=$(node -p "require('./package.json').version")
+ REMOTE_VERSION=$(npm view guard0 version 2>/dev/null || echo "0.0.0")
+ if [ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]; then
+ echo "guard0 wrapper v${LOCAL_VERSION} already published — skipping"
+ else
+ echo "Publishing guard0 wrapper v${LOCAL_VERSION} (registry has v${REMOTE_VERSION})"
+ npm publish --access public --provenance
+ fi
- name: Publish OpenClaw plugin
working-directory: packages/g0-openclaw-plugin
diff --git a/.gitignore b/.gitignore
index 032315a..ade1aa0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,6 @@ GAPS.md
docs/openclaw-plugin-validation.md
docs/v1.5.0-validation-runbook.md
docs/v1.5.0-customer-brief.md
+docs/EVALUATION.md
+docs/ARCHITECTURE_REVIEW.md
tests/integration/openclaw-plugin-hooks.test.mjs
diff --git a/package.json b/package.json
index 9920a1e..77cfe35 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
"description": "The control layer for AI agents — discover, assess, and govern your agent infrastructure",
"type": "module",
"bin": {
- "g0": "dist/bin/g0.js"
+ "g0": "dist/bin/g0.js",
+ "guard0": "dist/bin/g0.js"
},
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
@@ -28,7 +29,10 @@
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"g0": "tsx bin/g0.ts",
- "prepublishOnly": "tsup && vitest run"
+ "guard0": "tsx bin/g0.ts",
+ "version:check": "node scripts/version.mjs --check",
+ "version:bump": "node scripts/version.mjs",
+ "prepublishOnly": "node scripts/version.mjs --check && tsup && vitest run"
},
"repository": {
"type": "git",
@@ -92,7 +96,7 @@
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.1.0",
-"handlebars": "^4.7.8",
+ "handlebars": "^4.7.8",
"ignore": "^6.0.2",
"ora": "^8.1.0",
"yaml": "^2.5.0",
diff --git a/packages/guard0/README.md b/packages/guard0/README.md
new file mode 100644
index 0000000..94b3939
--- /dev/null
+++ b/packages/guard0/README.md
@@ -0,0 +1,40 @@
+
+
+
+
+Guard0 — The Control Layer for AI Agents
+
+
+
+
+
+
+
+This is the convenience package for **[Guard0](https://guard0.ai)** (`@guard0/g0`). It lets you install and run Guard0 without the scoped package name.
+
+## Install
+
+```bash
+npm install -g guard0
+```
+
+## Usage
+
+```bash
+guard0 scan ./my-agent # Security assessment
+guard0 scan https://github.com/org/repo # Remote repo scan
+guard0 inventory . # AI Bill of Materials
+guard0 test --target http://localhost:3000/api/chat # Adversarial testing
+guard0 endpoint # Developer machine assessment
+guard0 detect # AI tool & MDM detection
+```
+
+The `g0` shorthand also works:
+
+```bash
+g0 scan .
+```
+
+## Documentation
+
+See the full documentation at **[@guard0/g0](https://github.com/guard0-ai/g0)**.
diff --git a/packages/guard0/package.json b/packages/guard0/package.json
new file mode 100644
index 0000000..ff0e460
--- /dev/null
+++ b/packages/guard0/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "guard0",
+ "version": "1.7.2",
+ "description": "The control layer for AI agents — discover, assess, and govern your agent infrastructure",
+ "dependencies": {
+ "@guard0/g0": "1.7.2"
+ },
+ "bin": {
+ "guard0": "./node_modules/@guard0/g0/dist/bin/g0.js",
+ "g0": "./node_modules/@guard0/g0/dist/bin/g0.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/guard0-ai/g0.git",
+ "directory": "packages/guard0"
+ },
+ "homepage": "https://guard0.ai",
+ "bugs": {
+ "url": "https://github.com/guard0-ai/g0/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "keywords": [
+ "ai",
+ "agent",
+ "security",
+ "guard0",
+ "ai-security",
+ "agent-security",
+ "mcp",
+ "mcp-security",
+ "owasp",
+ "owasp-agentic",
+ "agentic",
+ "sast",
+ "dast",
+ "adversarial-testing",
+ "control-layer",
+ "ai-governance",
+ "compliance",
+ "langchain",
+ "crewai",
+ "openai",
+ "vercel-ai",
+ "llm-security"
+ ],
+ "author": "Rakshan AI Inc. (dba Guard0)",
+ "license": "AGPL-3.0-or-later"
+}
diff --git a/scripts/version.mjs b/scripts/version.mjs
new file mode 100644
index 0000000..ed64f5a
--- /dev/null
+++ b/scripts/version.mjs
@@ -0,0 +1,137 @@
+#!/usr/bin/env node
+
+/**
+ * Version management script for guard0 monorepo.
+ *
+ * Usage:
+ * node scripts/version.mjs
+ * node scripts/version.mjs patch|minor|major
+ * node scripts/version.mjs --check # verify all packages are in sync
+ *
+ * Updates version across:
+ * - package.json (@guard0/g0 — main package)
+ * - packages/guard0/ (guard0 — wrapper / vanity package)
+ *
+ * The wrapper's @guard0/g0 dependency is pinned to the exact version (no caret)
+ * so `npm install guard0@1.8.0` always resolves to `@guard0/g0@1.8.0`.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = resolve(__dirname, '..');
+
+const PACKAGES = [
+ { path: 'package.json', label: '@guard0/g0' },
+ { path: 'packages/guard0/package.json', label: 'guard0 (wrapper)' },
+];
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+function readPkg(rel) {
+ const abs = resolve(root, rel);
+ return JSON.parse(readFileSync(abs, 'utf-8'));
+}
+
+function writePkg(rel, obj) {
+ const abs = resolve(root, rel);
+ writeFileSync(abs, JSON.stringify(obj, null, 2) + '\n');
+}
+
+function bumpVersion(current, level) {
+ const [major, minor, patch] = current.split('.').map(Number);
+ switch (level) {
+ case 'major': return `${major + 1}.0.0`;
+ case 'minor': return `${major}.${minor + 1}.0`;
+ case 'patch': return `${major}.${minor}.${patch + 1}`;
+ default: throw new Error(`Unknown bump level: ${level}`);
+ }
+}
+
+function isValidSemver(v) {
+ return /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v);
+}
+
+// ---------------------------------------------------------------------------
+// --check mode
+// ---------------------------------------------------------------------------
+
+function checkSync() {
+ const mainPkg = readPkg('package.json');
+ const wrapperPkg = readPkg('packages/guard0/package.json');
+ const mainVersion = mainPkg.version;
+ const wrapperVersion = wrapperPkg.version;
+ const wrapperDep = wrapperPkg.dependencies?.['@guard0/g0'];
+
+ let ok = true;
+
+ if (wrapperVersion !== mainVersion) {
+ console.error(` MISMATCH guard0 wrapper is ${wrapperVersion}, main is ${mainVersion}`);
+ ok = false;
+ }
+ if (wrapperDep !== mainVersion) {
+ console.error(` MISMATCH guard0 wrapper depends on @guard0/g0@${wrapperDep}, main is ${mainVersion}`);
+ ok = false;
+ }
+
+ if (ok) {
+ console.log(` OK all packages at v${mainVersion}`);
+ } else {
+ console.error('\nRun: node scripts/version.mjs to fix');
+ process.exit(1);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// main
+// ---------------------------------------------------------------------------
+
+const arg = process.argv[2];
+
+if (!arg) {
+ console.error('Usage: node scripts/version.mjs ');
+ process.exit(1);
+}
+
+if (arg === '--check') {
+ checkSync();
+ process.exit(0);
+}
+
+const mainPkg = readPkg('package.json');
+const currentVersion = mainPkg.version;
+
+const newVersion = ['patch', 'minor', 'major'].includes(arg)
+ ? bumpVersion(currentVersion, arg)
+ : arg;
+
+if (!isValidSemver(newVersion)) {
+ console.error(`Invalid version: ${newVersion}`);
+ process.exit(1);
+}
+
+if (newVersion === currentVersion) {
+ console.log(`Already at v${currentVersion} — nothing to do`);
+ process.exit(0);
+}
+
+console.log(`\n ${currentVersion} → ${newVersion}\n`);
+
+// 1. Main package
+mainPkg.version = newVersion;
+writePkg('package.json', mainPkg);
+console.log(` updated @guard0/g0 → ${newVersion}`);
+
+// 2. Wrapper package — version + pinned dependency
+const wrapperPkg = readPkg('packages/guard0/package.json');
+wrapperPkg.version = newVersion;
+wrapperPkg.dependencies['@guard0/g0'] = newVersion;
+writePkg('packages/guard0/package.json', wrapperPkg);
+console.log(` updated guard0 (wrapper) → ${newVersion}`);
+console.log(` pinned guard0 → @guard0/g0@${newVersion}`);
+
+console.log(`\n Done. All packages at v${newVersion}\n`);
diff --git a/src/analyzers/rules/human-oversight.ts b/src/analyzers/rules/human-oversight.ts
index deedb32..257db50 100644
--- a/src/analyzers/rules/human-oversight.ts
+++ b/src/analyzers/rules/human-oversight.ts
@@ -126,7 +126,7 @@ export const humanOversightRules: Rule[] = [
/* ---------- Override Mechanisms ---------- */
{
id: 'AA-HO-005', name: 'No emergency stop mechanism', domain: 'human-oversight',
- severity: 'critical', confidence: 'medium',
+ severity: 'medium', confidence: 'medium',
description: 'Agent system has no emergency stop or kill switch for human operators.',
frameworks: ['all'], owaspAgentic: ['ASI09'], standards: STD_EXT,
check: (graph: AgentGraph): Finding[] => {
@@ -142,8 +142,9 @@ export const humanOversightRules: Rule[] = [
}
if (!hasKillSwitch && graph.agents.length > 0) {
findings.push({ id: 'AA-HO-005-0', ruleId: 'AA-HO-005', title: 'No emergency stop mechanism',
- description: 'No kill switch or emergency stop found in agent system', severity: 'critical', confidence: 'medium', domain: 'human-oversight',
+ description: 'No kill switch or emergency stop found in agent system', severity: 'medium', confidence: 'medium', domain: 'human-oversight',
location: { file: graph.agents[0]?.file ?? 'unknown', line: 1 },
+ checkType: 'absence_check', category: 'hardening',
remediation: 'Implement an emergency stop mechanism accessible to operators', standards: STD_EXT });
}
return findings;
diff --git a/src/analyzers/rules/identity-access.ts b/src/analyzers/rules/identity-access.ts
index 6b5497a..b3c90e2 100644
--- a/src/analyzers/rules/identity-access.ts
+++ b/src/analyzers/rules/identity-access.ts
@@ -1010,7 +1010,7 @@ export const identityAccessRules: Rule[] = [
id: 'AA-IA-030',
name: 'No RBAC enforcement',
domain: 'identity-access',
- severity: 'high',
+ severity: 'medium',
confidence: 'medium',
description: 'Codebase lacks role-based access control patterns for agent or user actions.',
frameworks: ['all'],
@@ -1033,8 +1033,9 @@ export const identityAccessRules: Rule[] = [
id: 'AA-IA-030-0', ruleId: 'AA-IA-030',
title: 'No RBAC enforcement detected',
description: 'No role-based access control patterns found in the codebase. Agent actions may lack authorization checks.',
- severity: 'high', confidence: 'medium', domain: 'identity-access',
+ severity: 'medium', confidence: 'medium', domain: 'identity-access',
location: { file: graph.files.all[0]?.relativePath ?? 'project', line: 1 },
+ checkType: 'absence_check', category: 'hardening',
remediation: 'Implement role-based access control to restrict agent and user actions based on assigned roles.',
standards: { owaspAgentic: ['ASI03'], aiuc1: ['B001'], iso42001: ['A.6.2'], nistAiRmf: ['MANAGE-2.1'] },
});
diff --git a/src/analyzers/rules/rogue-agent.ts b/src/analyzers/rules/rogue-agent.ts
index 83212aa..a257c69 100644
--- a/src/analyzers/rules/rogue-agent.ts
+++ b/src/analyzers/rules/rogue-agent.ts
@@ -274,7 +274,7 @@ export const rogueAgentRules: Rule[] = [
/* ---------- Kill Switch ---------- */
{
id: 'AA-RA-011', name: 'No kill switch mechanism', domain: 'rogue-agent',
- severity: 'critical', confidence: 'medium',
+ severity: 'medium', confidence: 'medium',
description: 'Agent system has no mechanism for immediate shutdown by operators.',
frameworks: ['all'], owaspAgentic: ['ASI10'], standards: STD_EXT,
check: (graph: AgentGraph): Finding[] => {
@@ -290,8 +290,9 @@ export const rogueAgentRules: Rule[] = [
}
if (!hasKillSwitch && graph.agents.length > 0) {
findings.push({ id: 'AA-RA-011-0', ruleId: 'AA-RA-011', title: 'No kill switch',
- description: 'No kill switch or emergency shutdown mechanism found', severity: 'critical', confidence: 'medium', domain: 'rogue-agent',
+ description: 'No kill switch or emergency shutdown mechanism found', severity: 'medium', confidence: 'medium', domain: 'rogue-agent',
location: { file: graph.agents[0]?.file ?? 'unknown', line: 1 },
+ checkType: 'absence_check', category: 'hardening',
remediation: 'Implement a kill switch accessible to operators', standards: STD_EXT });
}
return findings;
diff --git a/src/cli/branding.ts b/src/cli/branding.ts
index 8d34a29..ba67e9a 100644
--- a/src/cli/branding.ts
+++ b/src/cli/branding.ts
@@ -17,15 +17,15 @@ function loadVersion(): string {
export function printBanner(): void {
const logo = chalk.bold.cyan(`
- ██████╗ ██████╗
- ██╔════╝ ██╔═████╗
- ██║ ███╗██║██╔██║
- ██║ ██║████╔╝██║
- ╚██████╔╝╚██████╔╝
- ╚═════╝ ╚═════╝
+ ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ ██████╗
+ ██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔═████╗
+ ██║ ███╗██║ ██║███████║██████╔╝██║ ██║██║██╔██║
+ ██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║████╔╝██║
+ ╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝╚██████╔╝
+ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝
`);
- const tagline = chalk.dim(' Security Control Layer for AI Agents');
- const version = chalk.dim(` v${loadVersion()} by Guard0`);
+ const tagline = chalk.dim(' The Control Layer for AI Agents');
+ const version = chalk.dim(` v${loadVersion()} — guard0.ai`);
console.log(logo + tagline + '\n' + version + '\n');
}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index e96806d..adbc262 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -1,4 +1,5 @@
import { Command } from 'commander';
+import { basename } from 'node:path';
import { printBanner, getVersion } from './branding.js';
import { scanCommand } from './commands/scan.js';
import { initCommand } from './commands/init.js';
@@ -12,11 +13,17 @@ import { daemonCommand } from './commands/daemon.js';
import { endpointCommand } from './commands/endpoint.js';
import { detectCommand } from './commands/detect.js';
+/** Detect whether CLI was invoked as `guard0` or `g0`. */
+function cliName(): string {
+ const invoked = basename(process.argv[1] ?? '');
+ return invoked.startsWith('guard0') ? 'guard0' : 'g0';
+}
+
export function createCli(): Command {
const program = new Command();
program
- .name('g0')
+ .name(cliName())
.description('Open-source security assessment for AI agents')
.version(getVersion())
.hook('preAction', (thisCommand, actionCommand) => {
diff --git a/src/scoring/engine.ts b/src/scoring/engine.ts
index b15c19b..f40720e 100644
--- a/src/scoring/engine.ts
+++ b/src/scoring/engine.ts
@@ -31,6 +31,8 @@ const ALL_DOMAINS: SecurityDomain[] = [
const ABSENCE_CHECK_TYPES = new Set([
'prompt_missing',
'tool_missing_property',
+ 'project_missing',
+ 'absence_check',
]);
/**
@@ -39,12 +41,21 @@ const ABSENCE_CHECK_TYPES = new Set([
* Absence-based: something good is MISSING (no guarding, no validation, no boundary).
*/
function isAbsenceBased(finding: Finding): boolean {
+ // Use explicit category if already set
+ if (finding.category === 'hardening') return true;
+ if (finding.category === 'vulnerability') return false;
+ // Fallback to checkType-based heuristic
if (finding.checkType && ABSENCE_CHECK_TYPES.has(finding.checkType)) return true;
- // agent_property with "missing" in the title/description is absence-based
- if (finding.checkType === 'agent_property' &&
- (finding.title.toLowerCase().includes('missing') || finding.title.toLowerCase().includes('no '))) {
- return true;
+ // agent_property with absence-indicating title
+ if (finding.checkType === 'agent_property') {
+ const t = finding.title.toLowerCase();
+ if (t.startsWith('no ') || t.includes('missing') || t.includes('lacks') || t.includes('without')) {
+ return true;
+ }
}
+ // TS rules that don't set checkType — detect by title pattern
+ const t = finding.title.toLowerCase();
+ if (t.startsWith('no ') || t.startsWith('missing ') || t.startsWith('lacks ')) return true;
return false;
}
@@ -103,10 +114,10 @@ export function calculateScore(
let score = computeDomainScore(domainFindings, thresholds);
- // Apply attack chain bonus deduction
+ // Apply attack chain bonus deduction, capped at 50% of remaining score
const bonus = chainBonus.get(domain) ?? 0;
if (bonus > 0) {
- score = Math.max(0, score - bonus);
+ score = Math.max(0, score - Math.min(bonus, score * 0.5));
}
const weight = domainWeightOverrides?.[domain] ?? DOMAIN_WEIGHTS[domain];
@@ -126,7 +137,21 @@ export function calculateScore(
const totalWeight = domains.reduce((sum, d) => sum + d.weight, 0);
const weightedSum = domains.reduce((sum, d) => sum + d.score * d.weight, 0);
- const overall = Math.round(weightedSum / totalWeight);
+ let overall = Math.round(weightedSum / totalWeight);
+
+ // Critical-floor clamp: presence-of-vulnerability criticals cap the overall grade.
+ // This prevents a project with shell injection from getting an A/B just because
+ // 10 other domains are clean.
+ const criticalVulnCount = findings.filter(f =>
+ f.severity === 'critical' && !isAbsenceBased(f)
+ ).length;
+ if (criticalVulnCount >= 3) {
+ overall = Math.min(overall, 59); // max grade F
+ } else if (criticalVulnCount >= 2) {
+ overall = Math.min(overall, 69); // max grade D
+ } else if (criticalVulnCount >= 1) {
+ overall = Math.min(overall, 79); // max grade C
+ }
// Split scoring: separate presence-based (security) from absence-based (hardening)
const securityFindings = findings.filter(f => !isAbsenceBased(f));
diff --git a/src/scoring/weights.ts b/src/scoring/weights.ts
index 9c79efb..c8f47f3 100644
--- a/src/scoring/weights.ts
+++ b/src/scoring/weights.ts
@@ -30,27 +30,40 @@ export const DOMAIN_LABELS: Record = {
'rogue-agent': 'Rogue Agent',
};
+/**
+ * Base score deductions per finding severity.
+ * A single agent-reachable critical should drop a domain to ~60 (grade D).
+ * Two agent-reachable criticals should crater a domain to ~20 (grade F).
+ */
export const SEVERITY_DEDUCTIONS = {
- critical: 20,
- high: 10,
- medium: 4,
- low: 1,
+ critical: 40,
+ high: 18,
+ medium: 6,
+ low: 2,
info: 0,
} as const;
-/** Reachability multipliers — utility code gets 70% reduction */
+/**
+ * Reachability multipliers — how close the finding is to agent execution.
+ * Unknown defaults to 0.85 (assume reachable until proven otherwise).
+ * Utility code gets heavy reduction since it's not on the agent execution path.
+ */
export const REACHABILITY_MULTIPLIERS: Record = {
'agent-reachable': 1.0,
'tool-reachable': 1.0,
'endpoint-reachable': 0.8,
'utility-code': 0.3,
- 'unknown': 0.6,
+ 'unknown': 0.85,
};
-/** Exploitability multipliers — confirmed issues get amplified */
+/**
+ * Exploitability multipliers — how likely the finding can be exploited.
+ * Not-assessed defaults to 0.85 (assume exploitable until proven otherwise).
+ * Confirmed issues get amplified above 1.0.
+ */
export const EXPLOITABILITY_MULTIPLIERS: Record = {
'confirmed': 1.2,
'likely': 1.0,
'unlikely': 0.4,
- 'not-assessed': 0.7,
+ 'not-assessed': 0.85,
};
diff --git a/src/types/finding.ts b/src/types/finding.ts
index 55be4cb..1f7d5bb 100644
--- a/src/types/finding.ts
+++ b/src/types/finding.ts
@@ -3,6 +3,14 @@ import type { Severity, Confidence, SecurityDomain, Location } from './common.js
export type Reachability = 'agent-reachable' | 'tool-reachable' | 'endpoint-reachable' | 'utility-code' | 'unknown';
export type FindingExploitability = 'confirmed' | 'likely' | 'unlikely' | 'not-assessed';
+/**
+ * Finding category distinguishes actual vulnerabilities from missing best practices.
+ * - vulnerability: dangerous code/config IS present (shell injection, SQL injection, etc.)
+ * - hardening: a recommended control is MISSING (no kill switch, no RBAC, etc.)
+ * - informational: low-signal observation, no direct security impact
+ */
+export type FindingCategory = 'vulnerability' | 'hardening' | 'informational';
+
export interface Finding {
id: string;
ruleId: string;
@@ -17,6 +25,8 @@ export interface Finding {
reachability?: Reachability;
exploitability?: FindingExploitability;
checkType?: string;
+ /** Derived category: vulnerability (bad code present) vs hardening (good practice absent) */
+ category?: FindingCategory;
taintFlow?: {
stages: Array<{ command: string; taintTypes: string[]; line: number }>;
flowType: string;
diff --git a/tests/unit/exploitability.test.ts b/tests/unit/exploitability.test.ts
index 86b5bae..2c93555 100644
--- a/tests/unit/exploitability.test.ts
+++ b/tests/unit/exploitability.test.ts
@@ -48,14 +48,14 @@ result = eval(os.getenv("INPUT"))
expect(REACHABILITY_MULTIPLIERS['tool-reachable']).toBe(1.0);
expect(REACHABILITY_MULTIPLIERS['endpoint-reachable']).toBe(0.8);
expect(REACHABILITY_MULTIPLIERS['utility-code']).toBe(0.3);
- expect(REACHABILITY_MULTIPLIERS['unknown']).toBe(0.6);
+ expect(REACHABILITY_MULTIPLIERS['unknown']).toBe(0.85);
});
it('exploitability multipliers are defined correctly', () => {
expect(EXPLOITABILITY_MULTIPLIERS['confirmed']).toBe(1.2);
expect(EXPLOITABILITY_MULTIPLIERS['likely']).toBe(1.0);
expect(EXPLOITABILITY_MULTIPLIERS['unlikely']).toBe(0.4);
- expect(EXPLOITABILITY_MULTIPLIERS['not-assessed']).toBe(0.7);
+ expect(EXPLOITABILITY_MULTIPLIERS['not-assessed']).toBe(0.85);
});
it('scoring engine applies reachability multipliers', async () => {
From 56c7172ca1cbf0c222480461d229c67fbda5e08b Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Thu, 26 Mar 2026 16:49:56 -0700
Subject: [PATCH 2/9] =?UTF-8?q?fix:=20reduce=20false=20positives=20?=
=?UTF-8?q?=E2=80=94=20memory=20scoping,=20instruction=20guarding,=20impor?=
=?UTF-8?q?t=20skipping?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AA-DL-046 (shared memory):
- Skip import statements (only flag actual instantiation)
- Expand isolation patterns: thread_id, config.*thread, configurable,
memory_key=, chat_history
checkInstructionGuarding (shared parser):
- Add broader deny patterns: MUST NOT, you can only, do not disclose,
outside these boundaries, politely decline, refuse any requests
- Fixes FP where well-written system prompts weren't recognized as guarded
Results: Secure agent 0 criticals (was 1), score C:75 (was C:72)
---
src/analyzers/parsers/shared.ts | 8 ++++++++
src/analyzers/rules/data-leakage.ts | 10 +++++++---
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/src/analyzers/parsers/shared.ts b/src/analyzers/parsers/shared.ts
index bac4038..5d4eea6 100644
--- a/src/analyzers/parsers/shared.ts
+++ b/src/analyzers/parsers/shared.ts
@@ -117,6 +117,14 @@ export function checkInstructionGuarding(prompt: string): boolean {
/do\s+not\s+reveal\s+your\s+(instructions|prompt|system)/i,
/instruction\s+(boundary|boundaries)/i,
/prompt\s+injection\s+(protect|guard|prevent|detect)/i,
+ // Broader explicit deny patterns — common in well-written system prompts
+ /\bMUST\s+NOT\b/,
+ /\byou\s+(can\s+)?only\b/i,
+ /\b(do\s+not|don'?t)\s+(disclose|reveal|share|access|execute|modify)/i,
+ /\b(outside|beyond)\s+(these|those|the)\s+(boundaries|limits|scope|constraints)/i,
+ /\bpolitely\s+(decline|refuse|reject)/i,
+ /\bif\s+asked\s+to\s+(do\s+)?anything\s+(outside|beyond|other)/i,
+ /\brefuse\s+(any|all)\s+(requests?|instructions?)\s+(that|which|to)/i,
];
return guards.some(g => g.test(prompt));
}
diff --git a/src/analyzers/rules/data-leakage.ts b/src/analyzers/rules/data-leakage.ts
index 0e40e32..bff871b 100644
--- a/src/analyzers/rules/data-leakage.ts
+++ b/src/analyzers/rules/data-leakage.ts
@@ -1761,11 +1761,15 @@ export const dataLeakageRules: Rule[] = [
for (const file of [...graph.files.python, ...graph.files.typescript, ...graph.files.javascript]) {
let content: string;
try { content = fs.readFileSync(file.path, 'utf-8'); } catch { continue; }
- const pattern = /(?:ConversationBufferMemory|ConversationSummaryMemory|ChatMessageHistory|InMemoryChatMessageHistory|MemorySaver|memory\s*=\s*\{)/gi;
+ const memPattern = /(?:ConversationBufferMemory|ConversationSummaryMemory|ChatMessageHistory|InMemoryChatMessageHistory|MemorySaver|memory\s*=\s*\{)/gi;
let match: RegExpExecArray | null;
- while ((match = pattern.exec(content)) !== null) {
+ while ((match = memPattern.exec(content)) !== null) {
+ // Skip import/require statements — only flag actual usage
+ const lineStart = content.lastIndexOf('\n', match.index) + 1;
+ const lineText = content.substring(lineStart, content.indexOf('\n', match.index));
+ if (/^\s*(from\s+|import\s+|const\s+.*require)/.test(lineText)) continue;
const region = content.substring(Math.max(0, match.index - 400), Math.min(content.length, match.index + 400));
- if (!/user[_.]?id|tenant[_.]?id|session[_.]?id|per.?user|isolat|partition|namespace.*user/i.test(region)) {
+ if (!/user[_.]?id|tenant[_.]?id|session[_.]?id|per.?user|isolat|partition|namespace.*user|thread_id|config.*thread|configurable|memory_key\s*=|chat_history/i.test(region)) {
const line = content.substring(0, match.index).split('\n').length;
findings.push({
id: `AA-DL-046-${findings.length}`, ruleId: 'AA-DL-046',
From 378b04a6fba73f8365b3eeec762f0f29b0234a57 Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Thu, 26 Mar 2026 16:58:12 -0700
Subject: [PATCH 3/9] =?UTF-8?q?feat:=20enriched=20JSON=20output=20?=
=?UTF-8?q?=E2=80=94=20metadata,=20categories,=20graph=20nodes,=20message/?=
=?UTF-8?q?fix=20aliases?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
JSON reporter:
- Added metadata: frameworks, agentCount, toolCount, promptCount, modelCount, filesScanned
- Added score.securityScore and score.hardeningScore split
- Added finding.category (vulnerability | hardening | informational)
- Added finding.message (alias for title) and finding.fix (alias for remediation)
- Added graph.nodes[] (agents, tools, models with file/line)
- Added graph.edges[] (typed edges between nodes)
Analysis engine:
- Derives finding category from checkType and title patterns
- Absence-based rules (no X, missing Y, lacks Z) → hardening
- Code-pattern rules → vulnerability
- Info severity → informational
CLI:
- Banner now suppressed for --cyclonedx and --output flags
---
src/analyzers/engine.ts | 28 ++++++++++++++++++++++
src/cli/index.ts | 2 +-
src/reporters/json.ts | 51 ++++++++++++++++++++++++++++++++++++-----
3 files changed, 74 insertions(+), 7 deletions(-)
diff --git a/src/analyzers/engine.ts b/src/analyzers/engine.ts
index c03908e..fe161ee 100644
--- a/src/analyzers/engine.ts
+++ b/src/analyzers/engine.ts
@@ -191,6 +191,34 @@ export function runAnalysis(graph: AgentGraph, options?: AnalysisOptions): Findi
result = result.filter(f => SEVERITY_ORDER[f.severity] <= minLevel);
}
+ // Derive finding category (vulnerability vs hardening vs informational)
+ const absenceCheckTypes = new Set([
+ 'prompt_missing', 'tool_missing_property', 'project_missing', 'absence_check',
+ ]);
+ for (const f of result) {
+ if (f.category) continue; // already set by rule
+ if (f.severity === 'info') {
+ f.category = 'informational';
+ } else if (f.checkType && absenceCheckTypes.has(f.checkType)) {
+ f.category = 'hardening';
+ } else if (f.checkType === 'agent_property') {
+ const t = f.title.toLowerCase();
+ if (t.startsWith('no ') || t.includes('missing') || t.includes('lacks') || t.includes('without')) {
+ f.category = 'hardening';
+ } else {
+ f.category = 'vulnerability';
+ }
+ } else {
+ // Default: TS rules with absence-indicating titles
+ const t = f.title.toLowerCase();
+ if (t.startsWith('no ') || t.startsWith('missing ') || t.startsWith('lacks ')) {
+ f.category = 'hardening';
+ } else {
+ f.category = 'vulnerability';
+ }
+ }
+ }
+
return result;
}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index adbc262..57810b9 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -30,7 +30,7 @@ export function createCli(): Command {
const opts = actionCommand.opts();
// Suppress banner for machine-readable outputs
if (opts.json || opts.sarif || opts.quiet || opts.banner === false) return;
- if (opts.markdown) return;
+ if (opts.markdown || opts.cyclonedx || opts.output) return;
printBanner();
});
diff --git a/src/reporters/json.ts b/src/reporters/json.ts
index eff0f80..9b33e23 100644
--- a/src/reporters/json.ts
+++ b/src/reporters/json.ts
@@ -7,9 +7,19 @@ export interface JsonReport {
target: string;
framework: string;
duration: number;
+ metadata: {
+ frameworks: string[];
+ agentCount: number;
+ toolCount: number;
+ promptCount: number;
+ modelCount: number;
+ filesScanned: number;
+ };
score: {
overall: number;
grade: string;
+ securityScore?: number;
+ hardeningScore?: number;
domains: Array<{
domain: string;
label: string;
@@ -37,9 +47,14 @@ export interface JsonReport {
severity: string;
confidence: string;
domain: string;
+ category?: string;
file: string;
line: number;
+ /** Alias for title — backward compat for consumers expecting 'message' */
+ message: string;
remediation: string;
+ /** Alias for remediation — backward compat for consumers expecting 'fix' */
+ fix: string;
standards: { owaspAgentic: string[]; aiuc1?: string[]; iso42001?: string[]; nistAiRmf?: string[] };
snippet?: string;
reachability?: string;
@@ -50,6 +65,8 @@ export interface JsonReport {
tools: number;
prompts: number;
files: number;
+ nodes: Array<{ type: string; name: string; file: string; line?: number }>;
+ edges: Array<{ source: string; target: string; type: string }>;
};
analyzability?: {
score: number;
@@ -67,15 +84,28 @@ export interface JsonReport {
}
export function reportJson(result: ScanResult, outputPath?: string): string {
+ const g = result.graph;
+ const allFrameworks = [g.primaryFramework, ...g.secondaryFrameworks].filter(Boolean);
+
const report: JsonReport = {
version: '1.0.0',
timestamp: result.timestamp,
- target: result.graph.rootPath,
- framework: result.graph.primaryFramework,
+ target: g.rootPath,
+ framework: g.primaryFramework,
duration: result.duration,
+ metadata: {
+ frameworks: allFrameworks,
+ agentCount: g.agents.length,
+ toolCount: g.tools.length,
+ promptCount: g.prompts.length,
+ modelCount: g.models.length,
+ filesScanned: g.files.all.length,
+ },
score: {
overall: result.score.overall,
grade: result.score.grade,
+ securityScore: result.score.securityScore,
+ hardeningScore: result.score.hardeningScore,
domains: result.score.domains.map(d => ({
domain: d.domain,
label: d.label,
@@ -104,19 +134,28 @@ export function reportJson(result: ScanResult, outputPath?: string): string {
severity: f.severity,
confidence: f.confidence,
domain: f.domain,
+ category: f.category,
file: f.location.file,
line: f.location.line,
+ message: f.title,
remediation: f.remediation,
+ fix: f.remediation,
snippet: f.location.snippet || undefined,
standards: f.standards,
reachability: f.reachability,
exploitability: f.exploitability,
})),
graph: {
- agents: result.graph.agents.length,
- tools: result.graph.tools.length,
- prompts: result.graph.prompts.length,
- files: result.graph.files.all.length,
+ agents: g.agents.length,
+ tools: g.tools.length,
+ prompts: g.prompts.length,
+ files: g.files.all.length,
+ nodes: [
+ ...g.agents.map(a => ({ type: 'agent' as const, name: a.name, file: a.file, line: a.line })),
+ ...g.tools.map(t => ({ type: 'tool' as const, name: t.name, file: t.file, line: t.line })),
+ ...g.models.map(m => ({ type: 'model' as const, name: m.name, file: m.file, line: m.line })),
+ ],
+ edges: g.edges.map(e => ({ source: e.source, target: e.target, type: e.type })),
},
...(result.analyzability && {
analyzability: {
From 01ba2cd84b45c1bde927191bae205da185289d6f Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Thu, 26 Mar 2026 17:36:02 -0700
Subject: [PATCH 4/9] =?UTF-8?q?feat:=20add=20Zod=20validation=20schemas=20?=
=?UTF-8?q?for=20all=20CLI=20=E2=86=92=20Platform=20upload=20payloads?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New file: src/platform/schemas/upload.ts
- 8 upload payload schemas (scan, inventory, mcp, test, flows, endpoint,
host-hardening, openclaw-audit) as a discriminated union on 'type'
- Endpoint register + heartbeat schemas
- Shared sub-schemas: Finding, ScanScore, ProjectMeta, MachineMeta, CIMeta
- Validates: severity enums, score ranges (0-100), grade enums, required fields
All schemas exported from @guard0/g0 package index so the platform can:
import { UploadPayloadSchema } from '@guard0/g0'
const result = UploadPayloadSchema.safeParse(payload)
This is the shared schema contract between CLI and platform — single
source of truth, runtime validation, no more silent schema drift.
---
src/index.ts | 25 +++
src/platform/schemas/upload.ts | 300 +++++++++++++++++++++++++++++++++
2 files changed, 325 insertions(+)
create mode 100644 src/platform/schemas/upload.ts
diff --git a/src/index.ts b/src/index.ts
index fa0da27..64dc26e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -55,3 +55,28 @@ export type {
HeartbeatResponse,
PlatformConfig,
} from './platform/types.js';
+
+// Platform Zod schemas (for guard0-platform to validate uploads)
+export {
+ UploadPayloadSchema,
+ ScanUploadSchema,
+ InventoryUploadSchema,
+ MCPUploadSchema,
+ TestUploadSchema,
+ FlowsUploadSchema,
+ EndpointUploadSchema,
+ HostHardeningUploadSchema,
+ OpenClawAuditUploadSchema,
+ EndpointRegisterSchema,
+ HeartbeatSchema,
+ // Shared sub-schemas
+ FindingSchema,
+ ScanScoreSchema,
+ ProjectMetaSchema,
+ MachineMetaSchema,
+ CIMetaSchema,
+ SeveritySchema,
+ ConfidenceSchema,
+ GradeSchema,
+} from './platform/schemas/upload.js';
+export type { ValidatedUploadPayload } from './platform/schemas/upload.js';
diff --git a/src/platform/schemas/upload.ts b/src/platform/schemas/upload.ts
new file mode 100644
index 0000000..5cda939
--- /dev/null
+++ b/src/platform/schemas/upload.ts
@@ -0,0 +1,300 @@
+/**
+ * Zod schemas for all CLI → Platform upload payloads.
+ *
+ * These are the single source of truth for the upload API contract.
+ * Both the CLI (for construction) and the platform (for validation)
+ * should use these schemas.
+ */
+import { z } from 'zod';
+
+// ─── Shared Enums ─────────────────────────────────────────────────────────────
+
+export const SeveritySchema = z.enum(['critical', 'high', 'medium', 'low', 'info']);
+export const ConfidenceSchema = z.enum(['high', 'medium', 'low']);
+export const GradeSchema = z.enum(['A', 'B', 'C', 'D', 'F']);
+export const StatusSchema = z.enum(['healthy', 'degraded', 'error', 'offline']);
+
+// ─── Metadata ─────────────────────────────────────────────────────────────────
+
+export const GitMetaSchema = z.object({
+ remote: z.string().optional(),
+ branch: z.string().optional(),
+ commit: z.string().optional(),
+ dirty: z.boolean().optional(),
+});
+
+export const ProjectMetaSchema = z.object({
+ name: z.string().min(1),
+ path: z.string(),
+ git: GitMetaSchema.optional(),
+});
+
+export const MachineMetaSchema = z.object({
+ machineId: z.string().min(1),
+ hostname: z.string().optional(),
+ platform: z.string().optional(),
+ arch: z.string().optional(),
+ nodeVersion: z.string().optional(),
+ g0Version: z.string().optional(),
+});
+
+export const CIMetaSchema = z.object({
+ provider: z.string().min(1),
+ buildId: z.string().optional(),
+ buildUrl: z.string().optional(),
+ pipelineId: z.string().optional(),
+});
+
+// ─── Finding ──────────────────────────────────────────────────────────────────
+
+export const FindingLocationSchema = z.object({
+ file: z.string(),
+ line: z.number().int().min(0),
+ snippet: z.string().optional(),
+});
+
+export const FindingSchema = z.object({
+ id: z.string(),
+ ruleId: z.string(),
+ title: z.string(),
+ severity: SeveritySchema,
+ confidence: ConfidenceSchema,
+ domain: z.string(),
+ location: FindingLocationSchema,
+ description: z.string().optional(),
+ remediation: z.string().optional(),
+ category: z.enum(['vulnerability', 'hardening', 'informational']).optional(),
+ reachability: z.string().optional(),
+ exploitability: z.string().optional(),
+ standards: z.record(z.array(z.string())).optional(),
+});
+
+// ─── Score ────────────────────────────────────────────────────────────────────
+
+export const DomainScoreSchema = z.object({
+ domain: z.string(),
+ label: z.string(),
+ score: z.number().min(0).max(100),
+ findings: z.number().int().min(0),
+ critical: z.number().int().min(0),
+ high: z.number().int().min(0),
+ medium: z.number().int().min(0),
+ low: z.number().int().min(0),
+});
+
+export const ScanScoreSchema = z.object({
+ overall: z.number().min(0).max(100),
+ grade: GradeSchema,
+ securityScore: z.number().min(0).max(100).optional(),
+ hardeningScore: z.number().min(0).max(100).optional(),
+ domains: z.array(DomainScoreSchema),
+ correlations: z.unknown().optional(),
+});
+
+// ─── Upload Payloads ──────────────────────────────────────────────────────────
+
+export const ScanUploadSchema = z.object({
+ type: z.literal('scan'),
+ project: ProjectMetaSchema,
+ machine: MachineMetaSchema,
+ ci: CIMetaSchema.optional(),
+ result: z.object({
+ score: ScanScoreSchema,
+ findings: z.array(FindingSchema),
+ duration: z.number(),
+ timestamp: z.string(),
+ graph: z.unknown().optional(),
+ analyzability: z.unknown().optional(),
+ }).passthrough(),
+});
+
+export const InventoryUploadSchema = z.object({
+ type: z.literal('inventory'),
+ project: ProjectMetaSchema,
+ machine: MachineMetaSchema,
+ ci: CIMetaSchema.optional(),
+ result: z.object({
+ summary: z.object({
+ totalModels: z.number().int().min(0),
+ totalFrameworks: z.number().int().min(0),
+ totalTools: z.number().int().min(0),
+ totalAgents: z.number().int().min(0),
+ totalMCPServers: z.number().int().min(0),
+ }),
+ }).passthrough(),
+});
+
+export const MCPUploadSchema = z.object({
+ type: z.literal('mcp'),
+ project: ProjectMetaSchema.optional(),
+ machine: MachineMetaSchema,
+ ci: CIMetaSchema.optional(),
+ result: z.object({
+ servers: z.array(z.object({ name: z.string(), command: z.string(), status: z.string() }).passthrough()),
+ tools: z.array(z.object({ name: z.string() }).passthrough()),
+ findings: z.array(z.object({ severity: SeveritySchema }).passthrough()),
+ summary: z.object({
+ totalServers: z.number().int(),
+ totalTools: z.number().int(),
+ totalFindings: z.number().int(),
+ }),
+ }).passthrough(),
+});
+
+export const TestUploadSchema = z.object({
+ type: z.literal('test'),
+ project: ProjectMetaSchema,
+ machine: MachineMetaSchema,
+ ci: CIMetaSchema.optional(),
+ result: z.object({
+ target: z.object({ type: z.string(), endpoint: z.string() }),
+ summary: z.object({
+ total: z.number().int(),
+ vulnerable: z.number().int(),
+ resistant: z.number().int(),
+ inconclusive: z.number().int(),
+ overallStatus: z.string(),
+ }),
+ }).passthrough(),
+});
+
+export const FlowsUploadSchema = z.object({
+ type: z.literal('flows'),
+ project: ProjectMetaSchema,
+ machine: MachineMetaSchema,
+ ci: CIMetaSchema.optional(),
+ result: z.object({
+ nodes: z.array(z.object({ id: z.string(), label: z.string(), type: z.string() })),
+ edges: z.array(z.object({ from: z.string(), to: z.string(), label: z.string().optional() })),
+ paths: z.array(z.object({ nodes: z.array(z.string()), riskScore: z.number(), description: z.string() })),
+ toxicFlows: z.array(z.object({
+ severity: SeveritySchema,
+ title: z.string(),
+ description: z.string(),
+ path: z.array(z.string()),
+ riskScore: z.number(),
+ })),
+ summary: z.object({
+ totalNodes: z.number().int(),
+ totalEdges: z.number().int(),
+ totalPaths: z.number().int(),
+ toxicFlowCount: z.number().int(),
+ maxRiskScore: z.number(),
+ riskLevel: z.string(),
+ }),
+ }).passthrough(),
+});
+
+export const EndpointUploadSchema = z.object({
+ type: z.literal('endpoint'),
+ machine: MachineMetaSchema,
+ result: z.object({
+ machineId: z.string(),
+ hostname: z.string(),
+ timestamp: z.string(),
+ tools: z.array(z.object({
+ name: z.string(),
+ installed: z.boolean(),
+ running: z.boolean(),
+ mcpServerCount: z.number().int(),
+ }).passthrough()),
+ summary: z.object({
+ totalTools: z.number().int(),
+ runningTools: z.number().int(),
+ totalServers: z.number().int(),
+ totalFindings: z.number().int(),
+ overallStatus: z.string(),
+ }).passthrough(),
+ score: z.object({
+ total: z.number().min(0).max(100),
+ grade: GradeSchema,
+ }).passthrough(),
+ duration: z.number(),
+ }).passthrough(),
+});
+
+export const HostHardeningUploadSchema = z.object({
+ type: z.literal('host-hardening'),
+ machine: MachineMetaSchema,
+ result: z.object({
+ checks: z.array(z.object({
+ id: z.string(),
+ name: z.string(),
+ severity: SeveritySchema,
+ status: z.string(),
+ detail: z.string(),
+ })),
+ platform: z.string(),
+ summary: z.object({
+ total: z.number().int(),
+ passed: z.number().int(),
+ failed: z.number().int(),
+ skipped: z.number().int(),
+ errors: z.number().int(),
+ }),
+ }),
+});
+
+export const OpenClawAuditUploadSchema = z.object({
+ type: z.literal('openclaw-audit'),
+ machine: MachineMetaSchema,
+ result: z.object({
+ checks: z.array(z.object({
+ id: z.string(),
+ name: z.string(),
+ severity: SeveritySchema,
+ status: z.string(),
+ detail: z.string(),
+ })),
+ summary: z.object({
+ total: z.number().int(),
+ pass: z.number().int(),
+ fail: z.number().int(),
+ warn: z.number().int(),
+ skip: z.number().int(),
+ error: z.number().int(),
+ }),
+ timestamp: z.string(),
+ duration: z.number(),
+ }),
+});
+
+// ─── Discriminated Union ──────────────────────────────────────────────────────
+
+export const UploadPayloadSchema = z.discriminatedUnion('type', [
+ ScanUploadSchema,
+ InventoryUploadSchema,
+ MCPUploadSchema,
+ TestUploadSchema,
+ FlowsUploadSchema,
+ EndpointUploadSchema,
+ HostHardeningUploadSchema,
+ OpenClawAuditUploadSchema,
+]);
+
+export type ValidatedUploadPayload = z.infer;
+
+// ─── Endpoint API Schemas ─────────────────────────────────────────────────────
+
+export const EndpointRegisterSchema = z.object({
+ machineId: z.string().min(1),
+ hostname: z.string(),
+ platform: z.string(),
+ arch: z.string(),
+ g0Version: z.string(),
+ watchPaths: z.array(z.string()),
+});
+
+export const HeartbeatSchema = z.object({
+ endpointId: z.string().min(1),
+ machineId: z.string().min(1),
+ timestamp: z.string(),
+ status: StatusSchema,
+ lastScanAt: z.string().optional(),
+ score: z.number().min(0).max(100).optional(),
+ scoreDelta: z.number().optional(),
+ issues: z.array(z.string()).optional(),
+ openclawStatus: z.enum(['secure', 'warn', 'critical']).optional(),
+ openclawFailedChecks: z.number().int().optional(),
+ openclawDriftEvents: z.number().int().optional(),
+});
From 578a7832976667cbae111c9b3b97ad2540fd8d3b Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Thu, 26 Mar 2026 18:28:33 -0700
Subject: [PATCH 5/9] fix: FP reduction, type safety, and AI provider
null-safety
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FP fixes:
- agent_property YAML rules now skip when framework filter doesn't match
(fixes AA-TS-184 MCP rule firing on LangChain agents)
- Non-agent project guard: drop hardening findings when no agents/tools
detected (Flask app: 5 findings → 2, score 99 → 100)
Architecture fixes:
- AI provider throws explicit error when --ai set but no API key configured
(was silent console.error, now fails loudly)
- Rule interface: added suppressedBy/requiresControl fields directly,
removed unsafe `as Rule & Record` type assertions
- ModelNode: added maxTokens/temperature/topP optional fields
---
src/analyzers/engine.ts | 22 ++++++++++++++++------
src/pipeline.ts | 10 +++++++---
src/rules/yaml-compiler.ts | 8 ++++++++
src/types/agent-graph.ts | 3 +++
src/types/control.ts | 5 +++++
5 files changed, 39 insertions(+), 9 deletions(-)
diff --git a/src/analyzers/engine.ts b/src/analyzers/engine.ts
index fe161ee..efc7ace 100644
--- a/src/analyzers/engine.ts
+++ b/src/analyzers/engine.ts
@@ -94,15 +94,13 @@ export function runAnalysis(graph: AgentGraph, options?: AnalysisOptions): Findi
for (const rule of rules) {
// For project_missing rules, skip if the control registry shows the control exists
- const ruleRecord = rule as Rule & Record;
- if (ruleRecord.requiresControl && registry) {
- if (registry.hasControl(ruleRecord.requiresControl as SecurityControlType)) continue;
+ if (rule.requiresControl && registry) {
+ if (registry.hasControl(rule.requiresControl)) continue;
}
// For rules with suppressed_by, skip if ALL listed controls are present
- if (ruleRecord.suppressedBy && registry) {
- const suppressors = ruleRecord.suppressedBy as string[];
- const allPresent = suppressors.every((s: string) => registry.hasControl(s as SecurityControlType));
+ if (rule.suppressedBy && registry) {
+ const allPresent = rule.suppressedBy.every(s => registry.hasControl(s));
if (allPresent) continue;
}
@@ -191,6 +189,18 @@ export function runAnalysis(graph: AgentGraph, options?: AnalysisOptions): Findi
result = result.filter(f => SEVERITY_ORDER[f.severity] <= minLevel);
}
+ // Non-agent project guard: if no agents or tools detected, drop hardening findings
+ if (graph.agents.length === 0 && graph.tools.length === 0) {
+ result = result.filter(f => {
+ // Keep findings that are already categorized as vulnerability
+ if (f.category === 'vulnerability') return true;
+ // For uncategorized findings, use title heuristic
+ const t = f.title.toLowerCase();
+ const isAbsence = t.startsWith('no ') || t.startsWith('missing ') || t.startsWith('lacks ');
+ return !isAbsence;
+ });
+ }
+
// Derive finding category (vulnerability vs hardening vs informational)
const absenceCheckTypes = new Set([
'prompt_missing', 'tool_missing_property', 'project_missing', 'absence_check',
diff --git a/src/pipeline.ts b/src/pipeline.ts
index 80ecc2a..a201225 100644
--- a/src/pipeline.ts
+++ b/src/pipeline.ts
@@ -214,10 +214,14 @@ export async function runScan(options: ScanOptions): Promise {
// Meta-analysis is optional
}
} else {
- console.error(' Warning: --ai flag set but no API key found (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY)');
+ throw new Error(
+ 'AI analysis requested (--ai) but no provider configured.\n' +
+ 'Set one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY'
+ );
}
- } catch {
- // AI analysis is purely additive; failures don't affect base results
+ } catch (err) {
+ if (err instanceof Error && err.message.startsWith('AI analysis requested')) throw err;
+ // Other AI analysis failures are purely additive; don't affect base results
}
}
diff --git a/src/rules/yaml-compiler.ts b/src/rules/yaml-compiler.ts
index cffa0ef..e755571 100644
--- a/src/rules/yaml-compiler.ts
+++ b/src/rules/yaml-compiler.ts
@@ -88,6 +88,9 @@ function buildCheckFunction(yaml: YamlRule): (graph: AgentGraph) => Finding[] {
const severity = yaml.info.severity;
const confidence = yaml.info.confidence;
const standards = mapStandards(yaml.info.standards);
+ const frameworks = yaml.info.frameworks.includes('all')
+ ? ['langchain', 'crewai', 'mcp', 'openai', 'vercel-ai', 'bedrock', 'autogen', 'langchain4j', 'spring-ai', 'golang-ai', 'generic']
+ : yaml.info.frameworks;
switch (check.type) {
case 'prompt_contains':
@@ -241,6 +244,11 @@ function buildCheckFunction(yaml: YamlRule): (graph: AgentGraph) => Finding[] {
case 'agent_property':
return (graph) => {
const findings: Finding[] = [];
+ // Skip if framework filter doesn't match the project
+ if (!frameworks.includes('generic') && !frameworks.includes('all')) {
+ const projectFrameworks = [graph.primaryFramework, ...graph.secondaryFrameworks];
+ if (!frameworks.some(f => projectFrameworks.includes(f as typeof graph.primaryFramework))) return findings;
+ }
// Inter-agent rules only apply when multiple agents exist
if (domain === 'inter-agent' && graph.agents.length < 2) return findings;
// Cap agent_property findings at 3 per rule per scan to reduce noise.
diff --git a/src/types/agent-graph.ts b/src/types/agent-graph.ts
index 438f145..817fe35 100644
--- a/src/types/agent-graph.ts
+++ b/src/types/agent-graph.ts
@@ -152,6 +152,9 @@ export interface ModelNode {
framework: FrameworkId;
file: string;
line: number;
+ maxTokens?: number;
+ temperature?: number;
+ topP?: number;
}
export interface VectorDBNode {
diff --git a/src/types/control.ts b/src/types/control.ts
index 49f5a37..4d70efb 100644
--- a/src/types/control.ts
+++ b/src/types/control.ts
@@ -1,6 +1,7 @@
import type { SecurityDomain, Severity, Confidence } from './common.js';
import type { AgentGraph } from './agent-graph.js';
import type { Finding } from './finding.js';
+import type { SecurityControlType } from '../analyzers/control-registry.js';
export interface Rule {
id: string;
@@ -13,4 +14,8 @@ export interface Rule {
owaspAgentic: string[];
standards: import('./finding.js').StandardsMapping;
check: (graph: AgentGraph) => Finding[];
+ /** Controls that suppress this rule when present */
+ suppressedBy?: SecurityControlType[];
+ /** Control that must be present for this rule to fire */
+ requiresControl?: SecurityControlType;
}
From 081203d5a15a3f0defbb5c8df08eb598fa07563c Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Thu, 26 Mar 2026 22:11:20 -0700
Subject: [PATCH 6/9] refactor: enable code splitting, add BaseFinding
interface
Build:
- Split tsup config into CLI (single bundle + shebang) and SDK/daemon
(code-split for smaller imports)
- SDK consumers importing { runScan } no longer pull in CLI code
Types:
- Added BaseFinding interface (severity, title, description, category)
- Finding now extends BaseFinding
- Exported BaseFinding and FindingCategory from package index
---
src/index.ts | 2 +-
src/types/finding.ts | 19 +++++++++++-----
tsup.config.ts | 52 +++++++++++++++++++++++++-------------------
3 files changed, 44 insertions(+), 29 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index 64dc26e..73e6519 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,7 @@
export { runScan, runDiscovery, runGraphBuild } from './pipeline.js';
export type { ScanOptions, DiscoveryResult } from './pipeline.js';
export type { ScanResult, ScanScore, DomainScore } from './types/score.js';
-export type { Finding, FindingSummary } from './types/finding.js';
+export type { BaseFinding, Finding, FindingCategory, FindingSummary } from './types/finding.js';
export type { AgentGraph, AgentNode, ToolNode, PromptNode, ModelNode, VectorDBNode, FrameworkInfo } from './types/agent-graph.js';
export type { Severity, Confidence, FrameworkId, Grade, SecurityDomain } from './types/common.js';
export type { Rule } from './types/control.js';
diff --git a/src/types/finding.ts b/src/types/finding.ts
index 1f7d5bb..56ea502 100644
--- a/src/types/finding.ts
+++ b/src/types/finding.ts
@@ -11,12 +11,21 @@ export type FindingExploitability = 'confirmed' | 'likely' | 'unlikely' | 'not-a
*/
export type FindingCategory = 'vulnerability' | 'hardening' | 'informational';
-export interface Finding {
- id: string;
- ruleId: string;
+/**
+ * Base finding interface shared by all finding types (scan, endpoint, MCP, test).
+ * Provides the minimal set of fields needed for rendering, sorting, and deduplication.
+ */
+export interface BaseFinding {
+ severity: Severity;
title: string;
description: string;
- severity: Severity;
+ category?: FindingCategory;
+}
+
+/** Scan finding — the primary finding type from static and dynamic analysis. */
+export interface Finding extends BaseFinding {
+ id: string;
+ ruleId: string;
confidence: Confidence;
domain: SecurityDomain;
location: Location;
@@ -25,8 +34,6 @@ export interface Finding {
reachability?: Reachability;
exploitability?: FindingExploitability;
checkType?: string;
- /** Derived category: vulnerability (bad code present) vs hardening (good practice absent) */
- category?: FindingCategory;
taintFlow?: {
stages: Array<{ command: string; taintTypes: string[]; line: number }>;
flowType: string;
diff --git a/tsup.config.ts b/tsup.config.ts
index a078692..85de395 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -1,26 +1,34 @@
import { defineConfig } from 'tsup';
-export default defineConfig({
- entry: {
- 'bin/g0': 'bin/g0.ts',
- 'src/index': 'src/index.ts',
- 'src/daemon/runner': 'src/daemon/runner.ts',
+export default defineConfig([
+ // CLI entry point — needs shebang, no splitting (single executable)
+ {
+ entry: { 'bin/g0': 'bin/g0.ts' },
+ format: ['esm'],
+ target: 'node20',
+ sourcemap: true,
+ splitting: false,
+ external: [
+ 'tree-sitter', 'tree-sitter-python', 'tree-sitter-typescript',
+ 'tree-sitter-javascript', 'tree-sitter-java', 'tree-sitter-go',
+ ],
+ banner: { js: '#!/usr/bin/env node' },
},
- format: ['esm'],
- target: 'node20',
- dts: true,
- sourcemap: true,
- clean: true,
- splitting: false,
- external: [
- 'tree-sitter',
- 'tree-sitter-python',
- 'tree-sitter-typescript',
- 'tree-sitter-javascript',
- 'tree-sitter-java',
- 'tree-sitter-go',
- ],
- banner: {
- js: '#!/usr/bin/env node',
+ // SDK + daemon — code-split for smaller imports
+ {
+ entry: {
+ 'src/index': 'src/index.ts',
+ 'src/daemon/runner': 'src/daemon/runner.ts',
+ },
+ format: ['esm'],
+ target: 'node20',
+ dts: true,
+ sourcemap: true,
+ clean: true,
+ splitting: true,
+ external: [
+ 'tree-sitter', 'tree-sitter-python', 'tree-sitter-typescript',
+ 'tree-sitter-javascript', 'tree-sitter-java', 'tree-sitter-go',
+ ],
},
-});
+]);
From 33c299a278621352c0c259432d48954d65d75f96 Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Thu, 26 Mar 2026 22:13:18 -0700
Subject: [PATCH 7/9] feat: add structured logger (TTY-aware, JSON in CI)
New: src/utils/logger.ts
- Human-readable colored output in TTY mode
- JSON lines on stderr in non-TTY/CI mode
- Log levels via G0_LOG_LEVEL env var (error/warn/info/debug)
- Replaced console.error in platform/upload.ts with logger.error
---
src/platform/upload.ts | 3 ++-
src/utils/logger.ts | 43 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
create mode 100644 src/utils/logger.ts
diff --git a/src/platform/upload.ts b/src/platform/upload.ts
index 290b16d..8ab5c14 100644
--- a/src/platform/upload.ts
+++ b/src/platform/upload.ts
@@ -3,6 +3,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import * as childProcess from 'node:child_process';
import { PlatformClient } from './client.js';
+import { logger } from '../utils/logger.js';
import { getMachineId } from './machine-id.js';
import { isAuthenticated, ensureAuthenticated } from './auth.js';
import type {
@@ -48,7 +49,7 @@ export async function uploadResults(
} catch (err) {
// Non-fatal: log warning but don't fail the scan
const msg = err instanceof Error ? err.message : String(err);
- console.error(` Upload failed: ${msg}`);
+ logger.error('Upload failed', { error: msg });
return null;
}
}
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
new file mode 100644
index 0000000..820f050
--- /dev/null
+++ b/src/utils/logger.ts
@@ -0,0 +1,43 @@
+/**
+ * Lightweight structured logger for g0.
+ *
+ * - TTY: human-readable colored output
+ * - Non-TTY (CI/pipes): JSON lines for machine parsing
+ * - Respects G0_LOG_LEVEL env var (error/warn/info/debug)
+ */
+
+const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const;
+type LogLevel = keyof typeof LEVELS;
+
+const currentLevel: LogLevel = (process.env.G0_LOG_LEVEL as LogLevel) ?? 'warn';
+const isTTY = process.stderr.isTTY ?? false;
+
+function shouldLog(level: LogLevel): boolean {
+ return LEVELS[level] <= LEVELS[currentLevel];
+}
+
+function formatTTY(level: LogLevel, message: string, data?: Record): string {
+ const prefix = level === 'error' ? '\x1b[31m[ERROR]\x1b[0m'
+ : level === 'warn' ? '\x1b[33m[WARN]\x1b[0m'
+ : level === 'info' ? '\x1b[36m[INFO]\x1b[0m'
+ : '\x1b[90m[DEBUG]\x1b[0m';
+ const extra = data ? ` ${JSON.stringify(data)}` : '';
+ return `${prefix} ${message}${extra}`;
+}
+
+function formatJSON(level: LogLevel, message: string, data?: Record): string {
+ return JSON.stringify({ level, message, ...data, ts: new Date().toISOString() });
+}
+
+function log(level: LogLevel, message: string, data?: Record): void {
+ if (!shouldLog(level)) return;
+ const formatted = isTTY ? formatTTY(level, message, data) : formatJSON(level, message, data);
+ process.stderr.write(formatted + '\n');
+}
+
+export const logger = {
+ error: (message: string, data?: Record) => log('error', message, data),
+ warn: (message: string, data?: Record) => log('warn', message, data),
+ info: (message: string, data?: Record) => log('info', message, data),
+ debug: (message: string, data?: Record) => log('debug', message, data),
+};
From 0061129a8b19eb185db2361a6f877e85a647e5b7 Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Fri, 27 Mar 2026 14:23:31 -0700
Subject: [PATCH 8/9] fix: command is always g0, guard0 is package name only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Removed guard0 bin entry from main package (conflicts with enterprise pip)
- Removed guard0 bin entry from wrapper package
- Removed cliName() detection — always 'g0'
- guard0 npm package still exists for discoverability (npm install guard0)
but only installs the g0 command
---
package.json | 4 +---
packages/guard0/README.md | 20 +++++++-------------
packages/guard0/package.json | 1 -
src/cli/index.ts | 9 +--------
4 files changed, 9 insertions(+), 25 deletions(-)
diff --git a/package.json b/package.json
index 77cfe35..db1881e 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,7 @@
"description": "The control layer for AI agents — discover, assess, and govern your agent infrastructure",
"type": "module",
"bin": {
- "g0": "dist/bin/g0.js",
- "guard0": "dist/bin/g0.js"
+ "g0": "dist/bin/g0.js"
},
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
@@ -29,7 +28,6 @@
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"g0": "tsx bin/g0.ts",
- "guard0": "tsx bin/g0.ts",
"version:check": "node scripts/version.mjs --check",
"version:bump": "node scripts/version.mjs",
"prepublishOnly": "node scripts/version.mjs --check && tsup && vitest run"
diff --git a/packages/guard0/README.md b/packages/guard0/README.md
index 94b3939..2b577b3 100644
--- a/packages/guard0/README.md
+++ b/packages/guard0/README.md
@@ -10,7 +10,7 @@
-This is the convenience package for **[Guard0](https://guard0.ai)** (`@guard0/g0`). It lets you install and run Guard0 without the scoped package name.
+This is the convenience package for **[Guard0](https://guard0.ai)** (`@guard0/g0`). It lets you install Guard0 without the scoped package name.
## Install
@@ -21,18 +21,12 @@ npm install -g guard0
## Usage
```bash
-guard0 scan ./my-agent # Security assessment
-guard0 scan https://github.com/org/repo # Remote repo scan
-guard0 inventory . # AI Bill of Materials
-guard0 test --target http://localhost:3000/api/chat # Adversarial testing
-guard0 endpoint # Developer machine assessment
-guard0 detect # AI tool & MDM detection
-```
-
-The `g0` shorthand also works:
-
-```bash
-g0 scan .
+g0 scan ./my-agent # Security assessment
+g0 scan https://github.com/org/repo # Remote repo scan
+g0 inventory . # AI Bill of Materials
+g0 test --target http://localhost:3000/api/chat # Adversarial testing
+g0 endpoint # Developer machine assessment
+g0 detect # AI tool & MDM detection
```
## Documentation
diff --git a/packages/guard0/package.json b/packages/guard0/package.json
index ff0e460..6547617 100644
--- a/packages/guard0/package.json
+++ b/packages/guard0/package.json
@@ -6,7 +6,6 @@
"@guard0/g0": "1.7.2"
},
"bin": {
- "guard0": "./node_modules/@guard0/g0/dist/bin/g0.js",
"g0": "./node_modules/@guard0/g0/dist/bin/g0.js"
},
"repository": {
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 57810b9..d68aacd 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -1,5 +1,4 @@
import { Command } from 'commander';
-import { basename } from 'node:path';
import { printBanner, getVersion } from './branding.js';
import { scanCommand } from './commands/scan.js';
import { initCommand } from './commands/init.js';
@@ -13,17 +12,11 @@ import { daemonCommand } from './commands/daemon.js';
import { endpointCommand } from './commands/endpoint.js';
import { detectCommand } from './commands/detect.js';
-/** Detect whether CLI was invoked as `guard0` or `g0`. */
-function cliName(): string {
- const invoked = basename(process.argv[1] ?? '');
- return invoked.startsWith('guard0') ? 'guard0' : 'g0';
-}
-
export function createCli(): Command {
const program = new Command();
program
- .name(cliName())
+ .name('g0')
.description('Open-source security assessment for AI agents')
.version(getVersion())
.hook('preAction', (thisCommand, actionCommand) => {
From 6d457a3b02f1c7996f6136e27318363f44d0112c Mon Sep 17 00:00:00 2001
From: JBAhire
Date: Fri, 27 Mar 2026 14:35:23 -0700
Subject: [PATCH 9/9] fix: remaining FP and UX fixes
- AA-TS-021: narrow network access check to within 2000 chars of tool
definition (was file-wide, caused FPs on non-tool network calls)
- Self-scan: exclude .claude/worktrees/, advisories/, CVE docs from analysis
- Added --strict flag: exit code 2 if any critical finding exists
---
src/analyzers/engine.ts | 1 +
src/analyzers/rules/tool-safety.ts | 7 +++++--
src/cli/commands/scan.ts | 9 +++++++++
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/src/analyzers/engine.ts b/src/analyzers/engine.ts
index efc7ace..c5ecc50 100644
--- a/src/analyzers/engine.ts
+++ b/src/analyzers/engine.ts
@@ -29,6 +29,7 @@ const TEST_FILE_PATTERNS = [
/\/examples?\//, /\/docs?\//, /\/tutorials?\//, /\/notebooks?\//,
/\/demo\//, /\/samples?\//, /\/quickstart\//, /\/cookbook\//,
/\/benchmarks?\//, /\/e2e\//, /\/integration_tests?\//,
+ /\/\.claude\/worktrees\//, /\/advisories?\//, /\/cve[s-]?\//i,
];
const TEST_SEVERITY_DOWNGRADE: Record = {
diff --git a/src/analyzers/rules/tool-safety.ts b/src/analyzers/rules/tool-safety.ts
index db9c6dd..c589acf 100644
--- a/src/analyzers/rules/tool-safety.ts
+++ b/src/analyzers/rules/tool-safety.ts
@@ -801,11 +801,14 @@ export const toolSafetyRules: Rule[] = [
for (const file of [...graph.files.python, ...graph.files.typescript, ...graph.files.javascript]) {
let content: string;
try { content = fs.readFileSync(file.path, 'utf-8'); } catch { continue; }
- const toolContext = /(?:@tool|def\s+\w+_tool|StructuredTool|BaseTool|\.tool\()/;
- if (!toolContext.test(content)) continue;
+ const toolCtx = /(?:@tool|def\s+\w+_tool|StructuredTool|BaseTool|\.tool\()/;
+ if (!toolCtx.test(content)) continue;
const pattern = /(?:requests\.(?:get|post|put|delete)|fetch|urllib|httpx\.(?:get|post)|aiohttp)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
+ // Narrow: network call must be near a tool definition (within ~2000 chars)
+ const nearby = content.substring(Math.max(0, match.index - 2000), Math.min(content.length, match.index + 500));
+ if (!toolCtx.test(nearby)) continue;
const region = content.substring(Math.max(0, match.index - 500), Math.min(content.length, match.index + 500));
if (!/allowlist|whitelist|allowed_url|allowed_domain|url_filter|domain_check/i.test(region)) {
const line = content.substring(0, match.index).split('\n').length;
diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts
index eb261ce..3103ca2 100644
--- a/src/cli/commands/scan.ts
+++ b/src/cli/commands/scan.ts
@@ -42,6 +42,7 @@ export const scanCommand = new Command('scan')
.option('--fix', 'Auto-fix failed deployment audit checks (use with --openclaw-audit)')
.option('--ci', 'CI/CD gate mode — evaluate against .g0-policy.yaml and exit with policy-based exit code')
.option('--host-audit', 'Run OS-level host hardening audit (firewall, encryption, SSH, etc.)')
+ .option('--strict', 'Exit with code 2 if any critical finding exists')
.option('--no-banner', 'Suppress the g0 banner')
.action(async (targetPath: string, options: {
json?: boolean;
@@ -67,6 +68,7 @@ export const scanCommand = new Command('scan')
fix?: boolean;
ci?: boolean;
hostAudit?: boolean;
+ strict?: boolean;
banner?: boolean;
preset?: string;
aiConsensus?: number;
@@ -590,6 +592,13 @@ export const scanCommand = new Command('scan')
}
}
}
+ // --strict: exit with code 2 if any critical finding exists
+ if (options.strict && result.findings.some(f => f.severity === 'critical')) {
+ if (!options.quiet) {
+ console.error(`\n g0 strict mode: ${result.findings.filter(f => f.severity === 'critical').length} critical finding(s) — exiting with code 2`);
+ }
+ process.exit(2);
+ }
} catch (error) {
spinner?.stop();
const scanMsg = error instanceof Error ? error.message : String(error);