diff --git a/CHANGELOG.md b/CHANGELOG.md index 0488089..f45d7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Versions follow [Semantic Versioning](https://semver.org/). --- +## v3.2.0 — 2026-05-16 + +**Governance Evidence Artifact.** + +- Added `tools/governance-report.js` — single-pass governance report generator that runs file length, secrets, behavioral tests, source integrity, and runtime hook checks, producing a structured JSON artifact (`.code-warden-report.json`) and optional Markdown output +- Three output modes: default (writes JSON artifact + prints summary), `--format=json` (JSON to stdout), `--format=md` (Markdown table to stdout for `$GITHUB_STEP_SUMMARY`) +- Report includes git metadata (branch, commit), check results with violation details, and runtime hook registration status (Claude Code, Codex) +- Exit code reflects overall result: `0` = all checks pass, `1` = one or more failures +- Updated `templates/ci/github-actions.yml` — replaces individual lint/secrets steps with governance report, adds `$GITHUB_STEP_SUMMARY` Markdown publishing, adds artifact upload with 90-day retention +- Added npm scripts: `report`, `report:json`, `report:md` +- Updated README with "Governance Evidence" section and new positioning +- New positioning: "Verifiable governance for AI-assisted development — checks, hooks, and evidence that agents stayed within policy" + +--- + ## v3.1.1 — 2026-05-15 **Stabilization — behavioral tests, shared policy modules, line-count fix.** diff --git a/README.md b/README.md index 9a4618d..68c48bb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Code-Warden Quality Gate - Version 3.1.1 + Version 3.2.0 MIT License Claude Code PreToolUse Hooks AI Governance Enforced @@ -148,7 +148,9 @@ node install.js --uninstall-hooks=codex # remove Codex hooks ```bash npm run lint # scan full project tree for oversized files npm run check-secrets # scan full project tree for hardcoded credentials -npm run ci # lint + secrets + doctor +npm run report # governance report — writes .code-warden-report.json +npm run report:md # governance report as Markdown (pipe to PR summary) +npm run ci # lint + secrets + test + doctor npm run install-auto # node install.js npm run install-doctor # node install.js --doctor ``` @@ -198,20 +200,40 @@ Codex exposes `apply_patch` and `Bash` at `PreToolUse` — not `Write`/`Edit`. T Doctor and `--verify-target=` validate hook script paths when hooks are registered. +## Governance Evidence + +Code-Warden produces a machine-readable governance report — verifiable evidence that checks ran and passed: + +```bash +node tools/governance-report.js . # writes .code-warden-report.json +node tools/governance-report.js . --format=md # Markdown table for PR summaries +``` + +The report covers file length, hardcoded credentials, behavioral tests, source integrity, and runtime hook status in a single pass. In CI, it pipes directly into `$GITHUB_STEP_SUMMARY` so every PR shows what was checked. + ## CI Integration ```yaml - name: Install Code-Warden run: | curl -fsSL -o cw.zip \ - https://github.com/Kodaxadev/Code-Warden/releases/download/v3.1.0/code-warden-v3.1.0.zip + https://github.com/Kodaxadev/Code-Warden/releases/download/v3.2.0/code-warden-v3.2.0.zip unzip -q cw.zip -d .code-warden-ci -- name: Lint — file length limits - run: node .code-warden-ci/tools/warden-lint.js . +- name: Governance report + run: node .code-warden-ci/tools/governance-report.js . + +- name: Publish governance summary + if: always() + run: node .code-warden-ci/tools/governance-report.js . --format=md >> $GITHUB_STEP_SUMMARY -- name: Secrets — zero-trust scan - run: node .code-warden-ci/tools/verify-secrets.js . +- name: Upload governance artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: code-warden-report + path: .code-warden-report.json + retention-days: 90 ``` Full template: [`code-warden/templates/ci/github-actions.yml`](code-warden/templates/ci/github-actions.yml) @@ -234,7 +256,7 @@ Full template: [`code-warden/templates/ci/github-actions.yml`](code-warden/templ ## Version -v3.1.0 — See [`code-warden/SKILL.md`](code-warden/SKILL.md) for full changelog. +v3.2.0 — See [`CHANGELOG.md`](CHANGELOG.md) for full changelog. ## Author diff --git a/code-warden/README.md b/code-warden/README.md index dec42df..2adb83e 100644 --- a/code-warden/README.md +++ b/code-warden/README.md @@ -2,7 +2,10 @@ > Portable AI Coding Governance Layer -Code-Warden is a portable governance layer for AI coding agents. It enforces scoped planning, patch discipline, file-size limits, the zero-trust secrets policy, verification evidence, install health, and optional Claude Code pre-tool-use blocking. +Code-Warden provides verifiable governance for AI-assisted development. +It does not just ask agents to follow rules — it adds Scope Gates, Plan Gates, +local checks, CI enforcement, runtime hooks where supported, and governance +artifacts that show what was checked before code was accepted. ## Four Layers @@ -17,6 +20,43 @@ Code-Warden is a portable governance layer for AI coding agents. It enforces sco | **Installer and health** | Cross-app auto-installer, manifest-backed installs, `--doctor`, `--verify-target`, Windsurf adapter | | **Hard enforcement** | Claude Code `PreToolUse` hooks — block oversized writes and hardcoded secrets before the file system is touched | +## Governance Evidence + +Generate a machine-readable governance report that can be stored in CI, attached to PRs, or used as audit evidence: + +```bash +node tools/governance-report.js . # write .code-warden-report.json + summary +node tools/governance-report.js . --format=json # JSON to stdout +node tools/governance-report.js . --format=md # Markdown to stdout +``` + +The report runs all checks in a single pass (file length, secrets, behavioral tests, source integrity) and produces a structured artifact: + +```json +{ + "tool": "code-warden", + "version": "3.2.0", + "checks": { + "fileLength": { "status": "pass", "filesScanned": 34, "violations": 0 }, + "secrets": { "status": "pass", "filesScanned": 34, "violations": 0 }, + "behavioralTests": { "status": "pass", "tests": 8, "failures": 0 }, + "installHealth": { "status": "pass" } + }, + "result": "pass" +} +``` + +In CI, the Markdown format pipes directly into `$GITHUB_STEP_SUMMARY` for PR-visible evidence: + +| Check | Result | Details | +|-------|--------|---------| +| File length | PASS | 34 files scanned, 0 violations | +| Hardcoded credentials | PASS | 34 files scanned, 0 violations | +| Behavioral tests | PASS | 8 tests, 0 failures | +| Install health | PASS | All source files present | + +See [`templates/ci/github-actions.yml`](templates/ci/github-actions.yml) for the full CI template with artifact upload. + ## Install ```bash @@ -48,6 +88,9 @@ Each install writes a `.code-warden-install.json` manifest (version, target, for ```bash npm run lint # warden-lint on full project tree npm run check-secrets # verify-secrets on full project tree +npm run report # governance report, writes .code-warden-report.json +npm run report:json # governance report as JSON to stdout +npm run report:md # governance report as Markdown to stdout npm run install-auto # node install.js npm run install-dry-run # node install.js --dry-run npm run install-list # node install.js --list diff --git a/code-warden/package.json b/code-warden/package.json index 7798b1a..88cb449 100644 --- a/code-warden/package.json +++ b/code-warden/package.json @@ -1,12 +1,15 @@ { "name": "code-warden", - "version": "3.1.1", - "description": "Production-grade AI development governance skill for Codex, Claude Code, and Cowork.", + "version": "3.2.0", + "description": "Verifiable governance for AI-assisted development — checks, hooks, and evidence.", "main": "SKILL.md", "scripts": { "lint": "node tools/warden-lint.js .", "check-secrets": "node tools/verify-secrets.js .", "get-context": "node tools/get-context.js", + "report": "node tools/governance-report.js .", + "report:json": "node tools/governance-report.js . --format=json", + "report:md": "node tools/governance-report.js . --format=md", "install-auto": "node install.js", "install-dry-run": "node install.js --dry-run", "install-list": "node install.js --list", diff --git a/code-warden/templates/ci/github-actions.yml b/code-warden/templates/ci/github-actions.yml index ab351b1..8a6ac89 100644 --- a/code-warden/templates/ci/github-actions.yml +++ b/code-warden/templates/ci/github-actions.yml @@ -4,8 +4,15 @@ # Copy this file to .github/workflows/code-warden.yml in your project. # # What it enforces: -# - File length limits (warden-lint.js, default 400 lines per codewarden.json) -# - Zero-trust secrets (verify-secrets.js, hardcoded-credential patterns) +# - File length limits (default 400 lines per codewarden.json) +# - Zero-trust secrets (hardcoded-credential patterns) +# - Behavioral tests (scanner and hook pass/fail verification) +# - Source integrity (required files present) +# +# What it produces: +# - .code-warden-report.json — machine-readable governance artifact +# - Markdown summary on the workflow run / PR (via GITHUB_STEP_SUMMARY) +# - Uploaded artifact for audit trail (90-day retention) # # How code-warden is made available in CI (choose one): # @@ -33,7 +40,7 @@ on: branches: [main, master] env: - CODE_WARDEN_VERSION: v3.1.0 + CODE_WARDEN_VERSION: v3.2.0 CODE_WARDEN_PATH: .code-warden-ci FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -59,8 +66,18 @@ jobs: mkdir -p ${{ env.CODE_WARDEN_PATH }} unzip -q cw.zip -d ${{ env.CODE_WARDEN_PATH }} - - name: Lint — enforce file length limits - run: node ${{ env.CODE_WARDEN_PATH }}/tools/warden-lint.js . + - name: Governance report + run: node ${{ env.CODE_WARDEN_PATH }}/tools/governance-report.js . + + - name: Publish governance summary + if: always() + run: node ${{ env.CODE_WARDEN_PATH }}/tools/governance-report.js . --format=md >> $GITHUB_STEP_SUMMARY - - name: Secrets — zero-trust scan - run: node ${{ env.CODE_WARDEN_PATH }}/tools/verify-secrets.js . + - name: Upload governance artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: code-warden-report + path: .code-warden-report.json + if-no-files-found: ignore + retention-days: 90 diff --git a/code-warden/tools/governance-report.js b/code-warden/tools/governance-report.js new file mode 100644 index 0000000..ae85f45 --- /dev/null +++ b/code-warden/tools/governance-report.js @@ -0,0 +1,302 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawnSync } = require('child_process'); +const { countLines } = require('./lib/line-count'); +const { collectFiles } = require('./lib/file-collection'); +const { scanForSecrets } = require('./lib/secret-patterns'); +const { loadConfig } = require('./lib/config'); + +const ROOT = path.join(__dirname, '..'); +const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +const VERSION = PKG.version; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const args = argv.slice(2); + const formatArg = args.find(a => a.startsWith('--format=')); + const format = formatArg ? formatArg.split('=')[1] : null; + const scanPath = args.find(a => !a.startsWith('--')) || '.'; + return { format, scanPath }; +} + +// --------------------------------------------------------------------------- +// Git metadata +// --------------------------------------------------------------------------- + +function gitInfo() { + const run = (gitArgs) => { + const r = spawnSync('git', gitArgs, { encoding: 'utf8', timeout: 5000 }); + return r.status === 0 ? r.stdout.trim() : null; + }; + return { + branch: run(['rev-parse', '--abbrev-ref', 'HEAD']), + commit: run(['rev-parse', '--short', 'HEAD']), + }; +} + +// --------------------------------------------------------------------------- +// File length + secrets (single pass over all files) +// --------------------------------------------------------------------------- + +function runScans(scanPath) { + const { maxFileLength } = loadConfig(); + const resolved = path.resolve(scanPath); + + if (!fs.existsSync(resolved)) { + console.error(`[CodeWarden] Error: scan path not found: ${scanPath}`); + process.exit(1); + } + + const files = []; + if (fs.statSync(resolved).isDirectory()) { + collectFiles(resolved, files); + } else { + files.push(resolved); + } + + const lengthViolations = []; + const secretViolations = []; + + for (const f of files) { + let content; + try { content = fs.readFileSync(f, 'utf8'); } catch { continue; } + + const rel = path.relative(resolved, f); + + const lineCount = countLines(content); + if (lineCount > maxFileLength) { + lengthViolations.push({ file: rel, lines: lineCount, limit: maxFileLength }); + } + + const hit = scanForSecrets(content); + if (hit) { + secretViolations.push({ file: rel, pattern: hit.label }); + } + } + + return { + fileLength: { + status: lengthViolations.length === 0 ? 'pass' : 'fail', + filesScanned: files.length, + violations: lengthViolations.length, + details: lengthViolations.length > 0 ? lengthViolations : undefined, + }, + secrets: { + status: secretViolations.length === 0 ? 'pass' : 'fail', + filesScanned: files.length, + violations: secretViolations.length, + details: secretViolations.length > 0 ? secretViolations : undefined, + }, + }; +} + +// --------------------------------------------------------------------------- +// Behavioral tests +// --------------------------------------------------------------------------- + +function checkTests() { + const testScript = path.join(__dirname, 'tests', 'run-tests.js'); + if (!fs.existsSync(testScript)) { + return { status: 'skip', tests: 0, failures: 0 }; + } + + const r = spawnSync(process.execPath, [testScript], { + encoding: 'utf8', + timeout: 30000, + cwd: ROOT, + }); + + const out = (r.stdout || '') + (r.stderr || ''); + const passMatch = out.match(/pass\s+(\d+)/); + const failMatch = out.match(/fail\s+(\d+)/); + + let passed, failed; + if (passMatch || failMatch) { + passed = parseInt(passMatch?.[1] || '0', 10); + failed = parseInt(failMatch?.[1] || '0', 10); + } else { + passed = (out.match(/^(?:ok \d+|✔)/gm) || []).length; + failed = (out.match(/^(?:not ok \d+|✖)/gm) || []).length; + } + + return { + status: r.status === 0 ? 'pass' : 'fail', + tests: passed + failed, + failures: failed, + }; +} + +// --------------------------------------------------------------------------- +// Source integrity +// --------------------------------------------------------------------------- + +function checkInstallHealth() { + const required = [ + 'SKILL.md', + 'references', + 'tools/warden-lint.js', + 'tools/verify-secrets.js', + 'tools/get-context.js', + ]; + const missing = required.filter(f => !fs.existsSync(path.join(ROOT, f))); + return { + status: missing.length === 0 ? 'pass' : 'fail', + missing: missing.length > 0 ? missing : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Runtime hook detection +// --------------------------------------------------------------------------- + +function checkRuntimeHooks() { + const home = os.homedir(); + const result = {}; + + const claudeSettings = path.join(home, '.claude', 'settings.json'); + if (fs.existsSync(claudeSettings)) { + try { + const s = JSON.parse(fs.readFileSync(claudeSettings, 'utf8')); + const hooks = (s?.hooks?.PreToolUse || []) + .flatMap(m => m.hooks || []) + .filter(h => String(h.description || '').startsWith('code-warden:')); + if (hooks.length > 0) { + const valid = hooks.every(h => h.args?.[0] && fs.existsSync(h.args[0])); + result.claude = valid ? 'registered' : 'registered_broken'; + } else { + result.claude = 'not_registered'; + } + } catch { result.claude = 'error'; } + } else { + result.claude = 'not_configured'; + } + + const codexHooksPath = path.join(home, '.codex', 'hooks.json'); + if (fs.existsSync(codexHooksPath)) { + try { + const h = JSON.parse(fs.readFileSync(codexHooksPath, 'utf8')); + const cw = (h?.PreToolUse || []) + .filter(e => String(e.description || '').startsWith('code-warden:')); + if (cw.length > 0) { + const valid = cw.every(e => e.args?.[0] && fs.existsSync(e.args[0])); + result.codex = valid ? 'registered' : 'registered_broken'; + } else { + result.codex = 'not_registered'; + } + } catch { result.codex = 'error'; } + } else { + result.codex = 'not_configured'; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Report assembly +// --------------------------------------------------------------------------- + +function generateReport(scanPath) { + const repo = gitInfo(); + const { fileLength, secrets } = runScans(scanPath); + const behavioralTests = checkTests(); + const installHealth = checkInstallHealth(); + const runtimeHooks = checkRuntimeHooks(); + + const checks = { fileLength, secrets, behavioralTests, installHealth }; + const result = Object.values(checks).every(c => c.status === 'pass' || c.status === 'skip') + ? 'pass' : 'fail'; + + return { + tool: 'code-warden', + version: VERSION, + timestamp: new Date().toISOString(), + repository: { branch: repo.branch, commit: repo.commit }, + checks, + governance: { + scopeGate: 'session_only', + planGate: 'session_only', + runtimeHooks, + }, + result, + }; +} + +// --------------------------------------------------------------------------- +// Markdown formatter +// --------------------------------------------------------------------------- + +function formatMarkdown(report) { + const badge = s => s === 'pass' ? 'PASS' : s === 'skip' ? 'SKIP' : 'FAIL'; + const hookLabel = (id) => { + const s = report.governance.runtimeHooks[id]; + if (s === 'registered') return 'verified'; + if (s === 'registered_broken') return 'broken'; + if (s === 'not_registered') return 'none'; + return 'n/a'; + }; + + const healthDetail = report.checks.installHealth.missing + ? 'Missing: ' + report.checks.installHealth.missing.join(', ') + : 'All source files present'; + + const lines = [ + '## Code-Warden Governance Report', + '', + '| Check | Result | Details |', + '|-------|--------|---------|', + `| File length | ${badge(report.checks.fileLength.status)} | ${report.checks.fileLength.filesScanned} files scanned, ${report.checks.fileLength.violations} violations |`, + `| Hardcoded credentials | ${badge(report.checks.secrets.status)} | ${report.checks.secrets.filesScanned} files scanned, ${report.checks.secrets.violations} violations |`, + `| Behavioral tests | ${badge(report.checks.behavioralTests.status)} | ${report.checks.behavioralTests.tests} tests, ${report.checks.behavioralTests.failures} failures |`, + `| Install health | ${badge(report.checks.installHealth.status)} | ${healthDetail} |`, + `| Runtime hooks | — | Claude: ${hookLabel('claude')} / Codex: ${hookLabel('codex')} |`, + '', + `**Result:** ${report.result === 'pass' ? 'All governed checks passed.' : 'One or more checks failed.'}`, + '', + `> Generated by Code-Warden v${report.version} at ${report.timestamp}`, + ]; + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// One-line summary (default mode stdout) +// --------------------------------------------------------------------------- + +function formatSummary(report) { + const c = report.checks; + const parts = [ + `lint:${c.fileLength.status}`, + `secrets:${c.secrets.status}`, + `tests:${c.behavioralTests.status}`, + `health:${c.installHealth.status}`, + ]; + return `[CodeWarden] Governance report: ${report.result.toUpperCase()} (${parts.join(', ')})`; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const { format, scanPath } = parseArgs(process.argv); +const report = generateReport(scanPath); + +if (format === 'md') { + console.log(formatMarkdown(report)); +} else if (format === 'json') { + console.log(JSON.stringify(report, null, 2)); +} else { + const json = JSON.stringify(report, null, 2); + const outPath = path.resolve('.code-warden-report.json'); + fs.writeFileSync(outPath, json, 'utf8'); + console.log(formatSummary(report)); + console.log(`[CodeWarden] Report written to ${outPath}`); +} + +process.exit(report.result === 'pass' ? 0 : 1);