Extra Commands

commands no always-loaded rules -- loads on demand updated 2026-08-04

Additional slash commands: /audit (codebase audit), /pwv (Playwright visual verify), /walkthrough (step-by-step guide), /promote-rule (promote repo rules to global), /freeze + /unfreeze + /guard (safety-hook state management), /checkpoint (save/resume WIP session state).

Tags

  • commands
  • audit
  • verification
  • walkthrough
  • safety
  • checkpoint

README

commands-extra

Additional slash commands for codebase audits, visual verification, guided walkthroughs, rule promotion, safety-hook state management, and session-state checkpoints.

What It Does

This module installs eight slash command files:

  • /audit - Run a comprehensive codebase audit across 21 packs (security, secrets, dependencies, code quality, correctness, architecture, TypeScript/React, testing, documentation, performance, privacy, observability, reliability, CI/CD hardening, data migrations, infra/IaC, accessibility, API contract, CCGM hygiene, CCGM standards, and terms-of-service compliance) with auto-fix capabilities
  • /pwv - Playwright Visual Verification for testing UI in a headless browser with screenshots, viewport checks, and theme verification
  • /walkthrough - Enter step-by-step guide mode where Claude presents one step at a time and waits for confirmation before proceeding
  • /promote-rule - Review repo-level CLAUDE.md files and suggest rules that should be promoted to the global configuration
  • /freeze - Scope-lock Edit/Write to a directory by writing ~/.claude/freeze-dir.txt. Reads by check-freeze.py (see the hooks module)
  • /unfreeze - Clear the freeze scope by deleting ~/.claude/freeze-dir.txt
  • /guard - Compose careful + freeze for focused, safe sessions. Activates the freeze state file and confirms both safety hooks are installed
  • /checkpoint - Save or resume a structured WIP snapshot (working-on / decisions / remaining work / notes) under ~/.claude/checkpoints/{repo}/. Complements the session-history /recall: recall surfaces recent transcripts, checkpoints are compact handoff state

/audit Pack Model

The audit skill uses a pack-based architecture. Each pack is a self-contained unit that defines which checks to apply, how to detect whether the pack is relevant (via applies_when rules), and which deterministic tools to run.

21 packs

Pack Description Gating
accessibility WCAG 2.1 AA compliance, ARIA roles, color contrast language:javascript
api-contract REST/GraphQL contract validation, breaking changes language:javascript
architecture Circular dependencies, layer violations, coupling always
ccgm-hygiene CCGM configuration health always
ccgm-standards CCGM coding standards compliance always
ci-cd GitHub Actions security (unpinned actions, dangerous triggers, permissions) has_workflows
code-quality Dead code, complexity, naming, error handling always
correctness Logic bugs, type errors, linting rule violations language:javascript
data-migrations SQL safety, migration anti-patterns, RLS policy gaps has_migrations
dependencies Outdated/vulnerable packages, CVEs (npm/pip/cargo/gem) language:javascript
documentation Missing README sections, stale docs, JSDoc gaps always
infra-iac Terraform/Checkov misconfigurations has_iac
observability Logging gaps, missing error tracking always
performance Bundle size, N+1 queries, missing caching always
privacy PII handling, data retention, GDPR/CCPA always
reliability Error boundaries, retry logic, timeout handling language:javascript
secrets Leaked credentials, hardcoded tokens, gitleaks always
security Injection, auth gaps, CVEs, semgrep rules always
testing Coverage gaps, flaky tests, missing edge cases always
tos-compliance OSS license compliance, API/service ToS, store policy always
typescript-react Type safety, hook rules, key props, Fast Refresh language:javascript

Flags

/audit                    # Full audit — prompts for scope and execution strategy
/audit --single           # Single-session (all packs, one session, 8 subagents)
/audit --diff             # Audit only files changed vs detected base branch
/audit --diff main        # Audit only files changed vs a specific ref
/audit --staged           # Audit only staged files (always read-only)
/audit --baseline <file>  # Classify findings vs a prior run's findings.jsonl
/audit --new-only         # With --baseline: report only newly introduced findings
/audit --fix              # Apply auto-fixes and create a PR

Suppression

Findings can be suppressed at two levels:

  • Inline: add a # audit-ignore: <check-id> [optional reason] comment on the triggering line (or the line above it)
  • File-level: create .auditignore.yaml at the repo root with path/check-id patterns

Provenance, CODEOWNERS, and per-package scoping

The audit output includes a provenance record (tool versions, timestamp, repo path) for every run. When the repo has a CODEOWNERS file, findings are annotated with the owning team so issues can be routed automatically. For monorepos, the --repo flag scopes the run to a specific package subtree.

Manual Installation

Copy the command files into your Claude configuration:

# Global (all projects)
mkdir -p ~/.claude/commands
cp commands/audit.md ~/.claude/commands/audit.md
cp commands/pwv.md ~/.claude/commands/pwv.md
cp commands/walkthrough.md ~/.claude/commands/walkthrough.md
cp commands/promote-rule.md ~/.claude/commands/promote-rule.md
cp commands/freeze.md ~/.claude/commands/freeze.md
cp commands/unfreeze.md ~/.claude/commands/unfreeze.md
cp commands/guard.md ~/.claude/commands/guard.md
cp commands/checkpoint.md ~/.claude/commands/checkpoint.md

# Skill (pack registry, detectors/wrappers, schemas, reference docs)
# Copies exactly what module.json declares under skills/audit/ -- not a
# blanket `cp -R skills/audit/*`, which would also sweep in the skill's own
# bundled test suite (skills/audit/tests/) and any __pycache__/ droppings,
# neither of which start.sh installs.
mkdir -p ~/.claude/skills/audit
cp skills/audit/SKILL.md ~/.claude/skills/audit/SKILL.md
cp -R skills/audit/packs ~/.claude/skills/audit/packs
cp -R skills/audit/reference ~/.claude/skills/audit/reference
cp -R skills/audit/schemas ~/.claude/skills/audit/schemas
cp -R skills/audit/scripts ~/.claude/skills/audit/scripts
find ~/.claude/skills/audit/scripts -type d -name '__pycache__' -exec rm -rf {} +

# Project-level
mkdir -p .claude/commands
cp commands/audit.md .claude/commands/audit.md
cp commands/pwv.md .claude/commands/pwv.md
cp commands/walkthrough.md .claude/commands/walkthrough.md
cp commands/promote-rule.md .claude/commands/promote-rule.md
cp commands/freeze.md .claude/commands/freeze.md
cp commands/unfreeze.md .claude/commands/unfreeze.md
cp commands/guard.md .claude/commands/guard.md
cp commands/checkpoint.md .claude/commands/checkpoint.md

Files

File Description
commands/audit.md Codebase audit command with 21 packs and auto-fix
commands/pwv.md Playwright visual verification command
commands/walkthrough.md Step-by-step guided walkthrough command
commands/promote-rule.md Rule promotion from repo to global config
commands/freeze.md Activate the freeze scope (writes ~/.claude/freeze-dir.txt)
commands/unfreeze.md Clear the freeze scope (deletes ~/.claude/freeze-dir.txt)
commands/guard.md Compose careful + freeze for focused, safe sessions
commands/checkpoint.md Save or resume a WIP session-state checkpoint under ~/.claude/checkpoints/{repo}/
skills/audit/SKILL.md Skill definition backing the /audit command (pack model, orchestration, flags)
skills/audit/packs/*/pack.json Pack manifests: check-ids, applies_when gating, tool bindings
skills/audit/packs/*/checks.md Per-pack check descriptions with severity, confidence, and fix guidance
skills/audit/scripts/detect-ecosystems.sh Phase-0 ecosystem detector (outputs JSON consumed by the registry)
skills/audit/scripts/registry.py Pack registry: reads detector output, applies gating, returns selected packs
skills/audit/scripts/assign-packs.py Distributes selected packs across parallel worker agents
skills/audit/scripts/lint-pack.py Validates pack.json + checks.md against schemas and rubric
skills/audit/scripts/merge-findings.py Merges spine JSONL + LLM findings, applies rubric severity
skills/audit/scripts/spine/run.sh Deterministic tool spine: runs 18 wrapped tools, reports per-tool progress to stderr, applies the junk-path post-filter, emits finding JSONL
skills/audit/scripts/spine/wrap-*.sh Per-tool wrappers (gitleaks, semgrep, knip, eslint, trivy, …)
skills/audit/scripts/spine/exclude-dirs.txt Canonical excluded-dir list (node_modules, worktrees, build output) — single source of truth
skills/audit/scripts/spine/exclude-file-globs.txt Canonical excluded file-glob list (*.min.js, *.bundle.js, *.map) — catches vendored/minified files by name regardless of directory
skills/audit/scripts/spine/exclude.sh / exclude.py Build per-tool exclusion flags (sh) and the gitleaks config + always-on post-filter (py) from the dir + file-glob lists. The post-filter also drops findings on .gitignored paths and a looks-minified backstop drops lint/SAST findings on minified vendored files (e.g. js-dos.js); the gitleaks config allowlists gitignored files so a never-committed .env.local is not reported as a leaked credential
skills/audit/schemas/finding.schema.json JSON schema for normalized finding records
skills/audit/schemas/severity-rubric.json Per-check severity + confidence overrides
skills/audit/reference/*.md Runtime reference docs: security patterns, fix-patterns, architecture guides, output templates

Will install

Path Action Target Type
commands/audit.md commands/audit.md command
commands/pwv.md commands/pwv.md command
commands/walkthrough.md commands/walkthrough.md command
commands/promote-rule.md commands/promote-rule.md command
commands/freeze.md commands/freeze.md command
commands/unfreeze.md commands/unfreeze.md command
commands/guard.md commands/guard.md command
commands/checkpoint.md commands/checkpoint.md command
skills/audit/SKILL.md skills/audit/SKILL.md skill
skills/audit/reference/security-patterns.md skills/audit/reference/security-patterns.md doc
skills/audit/reference/code-quality.md skills/audit/reference/code-quality.md doc
skills/audit/reference/architecture.md skills/audit/reference/architecture.md doc
skills/audit/reference/fix-patterns.md skills/audit/reference/fix-patterns.md doc
skills/audit/reference/multi-agent-config.md skills/audit/reference/multi-agent-config.md doc
skills/audit/reference/output-template.md skills/audit/reference/output-template.md doc
skills/audit/schemas/pack.schema.json skills/audit/schemas/pack.schema.json doc
skills/audit/schemas/finding.schema.json skills/audit/schemas/finding.schema.json doc
skills/audit/scripts/registry.py skills/audit/scripts/registry.py script
skills/audit/scripts/assign-packs.py skills/audit/scripts/assign-packs.py script
skills/audit/scripts/detect-ecosystems.sh skills/audit/scripts/detect-ecosystems.sh script
skills/audit/scripts/emit-findings.py skills/audit/scripts/emit-findings.py script
skills/audit/scripts/merge-findings.py skills/audit/scripts/merge-findings.py script
skills/audit/scripts/baseline.py skills/audit/scripts/baseline.py script
skills/audit/scripts/suppress.py skills/audit/scripts/suppress.py script
skills/audit/schemas/severity-rubric.json skills/audit/schemas/severity-rubric.json doc
skills/audit/scripts/lint-rubric.py skills/audit/scripts/lint-rubric.py script
skills/audit/scripts/lint-pack.py skills/audit/scripts/lint-pack.py script
skills/audit/scripts/provenance.py skills/audit/scripts/provenance.py script
skills/audit/packs/_TEMPLATE/checks.md skills/audit/packs/_TEMPLATE/checks.md doc
skills/audit/packs/security/pack.json skills/audit/packs/security/pack.json doc
skills/audit/packs/security/checks.md skills/audit/packs/security/checks.md doc
skills/audit/packs/dependencies/pack.json skills/audit/packs/dependencies/pack.json doc
skills/audit/packs/dependencies/checks.md skills/audit/packs/dependencies/checks.md doc
skills/audit/packs/tos-compliance/pack.json skills/audit/packs/tos-compliance/pack.json doc
skills/audit/packs/tos-compliance/checks.md skills/audit/packs/tos-compliance/checks.md doc
skills/audit/reference/pack-quality-bar.md skills/audit/reference/pack-quality-bar.md doc
skills/audit/packs/testing/pack.json skills/audit/packs/testing/pack.json doc
skills/audit/packs/testing/checks.md skills/audit/packs/testing/checks.md doc
skills/audit/packs/documentation/pack.json skills/audit/packs/documentation/pack.json doc
skills/audit/packs/documentation/checks.md skills/audit/packs/documentation/checks.md doc
skills/audit/packs/performance/pack.json skills/audit/packs/performance/pack.json doc
skills/audit/packs/performance/checks.md skills/audit/packs/performance/checks.md doc
skills/audit/scripts/spine/run.sh skills/audit/scripts/spine/run.sh script
skills/audit/scripts/spine/exclude-dirs.txt skills/audit/scripts/spine/exclude-dirs.txt doc
skills/audit/scripts/spine/exclude-file-globs.txt skills/audit/scripts/spine/exclude-file-globs.txt doc
skills/audit/scripts/spine/exclude.sh skills/audit/scripts/spine/exclude.sh script
skills/audit/scripts/spine/exclude.py skills/audit/scripts/spine/exclude.py script
skills/audit/scripts/spine/normalize.py skills/audit/scripts/spine/normalize.py script
skills/audit/scripts/spine/wrap-gitleaks.sh skills/audit/scripts/spine/wrap-gitleaks.sh script
skills/audit/scripts/spine/parse-gitleaks.py skills/audit/scripts/spine/parse-gitleaks.py script
skills/audit/scripts/spine/wrap-semgrep.sh skills/audit/scripts/spine/wrap-semgrep.sh script
skills/audit/scripts/spine/parse-semgrep.py skills/audit/scripts/spine/parse-semgrep.py script
skills/audit/scripts/spine/wrap-dep-audit.sh skills/audit/scripts/spine/wrap-dep-audit.sh script
skills/audit/scripts/spine/parse-dep-audit.py skills/audit/scripts/spine/parse-dep-audit.py script
skills/audit/scripts/spine/wrap-knip.sh skills/audit/scripts/spine/wrap-knip.sh script
skills/audit/scripts/spine/parse-knip.py skills/audit/scripts/spine/parse-knip.py script
skills/audit/scripts/spine/wrap-eslint.sh skills/audit/scripts/spine/wrap-eslint.sh script
skills/audit/scripts/spine/parse-eslint.py skills/audit/scripts/spine/parse-eslint.py script
skills/audit/scripts/spine/wrap-govulncheck.sh skills/audit/scripts/spine/wrap-govulncheck.sh script
skills/audit/scripts/spine/parse-govulncheck.py skills/audit/scripts/spine/parse-govulncheck.py script
skills/audit/scripts/spine/wrap-bandit.sh skills/audit/scripts/spine/wrap-bandit.sh script
skills/audit/scripts/spine/parse-bandit.py skills/audit/scripts/spine/parse-bandit.py script
skills/audit/scripts/spine/wrap-hadolint.sh skills/audit/scripts/spine/wrap-hadolint.sh script
skills/audit/scripts/spine/parse-hadolint.py skills/audit/scripts/spine/parse-hadolint.py script
skills/audit/scripts/spine/wrap-actionlint.sh skills/audit/scripts/spine/wrap-actionlint.sh script
skills/audit/scripts/spine/parse-actionlint.py skills/audit/scripts/spine/parse-actionlint.py script
skills/audit/scripts/spine/wrap-trivy.sh skills/audit/scripts/spine/wrap-trivy.sh script
skills/audit/scripts/spine/parse-trivy.py skills/audit/scripts/spine/parse-trivy.py script
skills/audit/scripts/spine/wrap-zizmor.sh skills/audit/scripts/spine/wrap-zizmor.sh script
skills/audit/scripts/spine/parse-zizmor.py skills/audit/scripts/spine/parse-zizmor.py script
skills/audit/scripts/spine/wrap-pinact.sh skills/audit/scripts/spine/wrap-pinact.sh script
skills/audit/scripts/spine/parse-pinact.py skills/audit/scripts/spine/parse-pinact.py script
skills/audit/packs/ci-cd/pack.json skills/audit/packs/ci-cd/pack.json doc
skills/audit/packs/ci-cd/checks.md skills/audit/packs/ci-cd/checks.md doc
skills/audit/packs/code-quality/pack.json skills/audit/packs/code-quality/pack.json doc
skills/audit/packs/code-quality/checks.md skills/audit/packs/code-quality/checks.md doc
skills/audit/packs/typescript-react/pack.json skills/audit/packs/typescript-react/pack.json doc
skills/audit/packs/typescript-react/checks.md skills/audit/packs/typescript-react/checks.md doc
skills/audit/packs/architecture/pack.json skills/audit/packs/architecture/pack.json doc
skills/audit/packs/architecture/checks.md skills/audit/packs/architecture/checks.md doc
skills/audit/packs/accessibility/pack.json skills/audit/packs/accessibility/pack.json doc
skills/audit/packs/accessibility/checks.md skills/audit/packs/accessibility/checks.md doc
skills/audit/packs/reliability/pack.json skills/audit/packs/reliability/pack.json doc
skills/audit/packs/reliability/checks.md skills/audit/packs/reliability/checks.md doc
skills/audit/packs/correctness/pack.json skills/audit/packs/correctness/pack.json doc
skills/audit/packs/correctness/checks.md skills/audit/packs/correctness/checks.md doc
skills/audit/packs/secrets/pack.json skills/audit/packs/secrets/pack.json doc
skills/audit/packs/secrets/checks.md skills/audit/packs/secrets/checks.md doc
skills/audit/scripts/spine/wrap-squawk.sh skills/audit/scripts/spine/wrap-squawk.sh script
skills/audit/scripts/spine/parse-squawk.py skills/audit/scripts/spine/parse-squawk.py script
skills/audit/scripts/spine/wrap-sqlfluff.sh skills/audit/scripts/spine/wrap-sqlfluff.sh script
skills/audit/scripts/spine/parse-sqlfluff.py skills/audit/scripts/spine/parse-sqlfluff.py script
skills/audit/packs/data-migrations/pack.json skills/audit/packs/data-migrations/pack.json doc
skills/audit/packs/data-migrations/checks.md skills/audit/packs/data-migrations/checks.md doc
skills/audit/packs/api-contract/pack.json skills/audit/packs/api-contract/pack.json doc
skills/audit/packs/api-contract/checks.md skills/audit/packs/api-contract/checks.md doc
skills/audit/packs/privacy/pack.json skills/audit/packs/privacy/pack.json doc
skills/audit/packs/privacy/checks.md skills/audit/packs/privacy/checks.md doc
skills/audit/packs/observability/pack.json skills/audit/packs/observability/pack.json doc
skills/audit/packs/observability/checks.md skills/audit/packs/observability/checks.md doc
skills/audit/packs/ccgm-hygiene/pack.json skills/audit/packs/ccgm-hygiene/pack.json doc
skills/audit/packs/ccgm-hygiene/checks.md skills/audit/packs/ccgm-hygiene/checks.md doc
skills/audit/packs/ccgm-standards/pack.json skills/audit/packs/ccgm-standards/pack.json doc
skills/audit/packs/ccgm-standards/checks.md skills/audit/packs/ccgm-standards/checks.md doc
skills/audit/scripts/spine/wrap-pip-audit.sh skills/audit/scripts/spine/wrap-pip-audit.sh script
skills/audit/scripts/spine/parse-pip-audit.py skills/audit/scripts/spine/parse-pip-audit.py script
skills/audit/scripts/spine/wrap-cargo-audit.sh skills/audit/scripts/spine/wrap-cargo-audit.sh script
skills/audit/scripts/spine/parse-cargo-audit.py skills/audit/scripts/spine/parse-cargo-audit.py script
skills/audit/scripts/spine/wrap-bundler-audit.sh skills/audit/scripts/spine/wrap-bundler-audit.sh script
skills/audit/scripts/spine/parse-bundler-audit.py skills/audit/scripts/spine/parse-bundler-audit.py script
skills/audit/scripts/spine/wrap-checkov.sh skills/audit/scripts/spine/wrap-checkov.sh script
skills/audit/scripts/spine/parse-checkov.py skills/audit/scripts/spine/parse-checkov.py script
skills/audit/packs/infra-iac/pack.json skills/audit/packs/infra-iac/pack.json doc
skills/audit/packs/infra-iac/checks.md skills/audit/packs/infra-iac/checks.md doc

Dependencies

No dependencies.

Required by

No other module depends on this one.

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/commands-extra.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install commands-extra@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

46 further files are available as raw text.

command (8)

commands/audit.md

---
description: Pack-based codebase audit across 21 packs (security, secrets, deps, quality, correctness, architecture, TS/React, testing, docs, performance, privacy, observability, reliability, CI/CD, data-migrations, infra-iac, accessibility, api-contract, ccgm-hygiene, ccgm-standards, tos-compliance) with optional auto-fix
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Agent, WebSearch, WebFetch, AskUserQuestion
---

# /audit - Codebase Audit

Run a comprehensive codebase audit using 21 self-contained packs. See the full skill for all phases and options: `~/.claude/skills/audit/SKILL.md`.

## Usage

```
/audit                    # Full audit — prompts for scope and execution strategy
/audit --single           # Single-session (all packs, one session, 8 subagents)
/audit --diff             # Audit only files changed vs the detected base branch
/audit --diff main        # Audit only files changed vs a specific ref
/audit --staged           # Audit only files currently staged for commit (always read-only)
/audit --baseline <file>            # Classify findings vs a previous run's findings.jsonl
/audit --baseline <file> --new-only # Report only newly introduced findings
/audit --fix              # Apply auto-fixes and create a PR
/audit --max-fixes 10     # Limit number of auto-fixes (only with --fix)
/audit --manual           # Set up tasks + output launch commands for manual orchestration
/audit --worker           # Worker mode (run from worktree/clone after --manual setup)
/audit --collect          # Compile results + create issues (after workers complete)
/audit --collect --force  # Collect even if some agents haven't completed
```

## Packs (21 total)

Packs are gated by `applies_when` rules — only packs matching the detected ecosystems run.

| Pack | What it checks | Gating |
|------|----------------|--------|
| `security` | Injection, auth gaps, CVEs, semgrep | always |
| `secrets` | Leaked credentials, hardcoded tokens, gitleaks | always |
| `dependencies` | Outdated/vulnerable packages (npm/pip/cargo/gem) | `language:javascript` |
| `code-quality` | Dead code, complexity, naming, error handling | always |
| `correctness` | Logic bugs, type errors, linting violations | `language:javascript` |
| `architecture` | Circular deps, layer violations, coupling | always |
| `typescript-react` | Type safety, hook rules, key props, Fast Refresh | `language:javascript` |
| `testing` | Coverage gaps, flaky tests, missing edge cases | always |
| `documentation` | Missing README sections, stale docs, JSDoc gaps | always |
| `performance` | Bundle size, N+1 queries, missing caching | always |
| `privacy` | PII handling, data retention, GDPR/CCPA | always |
| `observability` | Logging gaps, missing error tracking | always |
| `reliability` | Error boundaries, retry logic, timeout handling | `language:javascript` |
| `ci-cd` | Unpinned actions, dangerous triggers, permissions | `has_workflows` |
| `data-migrations` | SQL safety, migration anti-patterns, RLS gaps | `has_migrations` |
| `infra-iac` | Terraform/Checkov misconfigurations | `has_iac` |
| `accessibility` | WCAG 2.1 AA, ARIA roles, color contrast | `language:javascript` |
| `api-contract` | REST/GraphQL contract validation, breaking changes | `language:javascript` |
| `ccgm-hygiene` | CCGM configuration health | always |
| `ccgm-standards` | CCGM coding standards compliance | always |
| `tos-compliance` | OSS license, API ToS, store policy | always |

## Severity Levels

Findings are classified as: **critical**, **high**, **medium**, or **low**.

## Suppression

- **Inline**: add `# audit-ignore: <check-id> [optional reason]` on the triggering line (or `// audit-ignore: <check-id> [reason]` for JS/TS)
- **File-level**: create `.auditignore.yaml` at the repo root with path/check-id patterns

## Interactive Mode (no flags)

When called without flags, the skill prompts with two questions:

1. **Audit scope** — Read-only (findings report) or Analyze + auto-fix (applies safe fixes, creates PR)
2. **Execution strategy** — Parallel worktrees, single session, multi-clone, or manual setup

## Output

- Findings JSONL at `.audit/current/findings.jsonl` (stable fingerprint per finding)
- Audit report at `.audit/current/audit-report.md`
- PR with fixes (only with `--fix`)

commands/pwv.md

---
description: Playwright Visual Verification
allowed-tools: Agent
---

# /pwv - Playwright Visual Verification

Use the Agent tool to execute this workflow on a cheaper model:

- **model**: sonnet
- **description**: playwright visual verify

Pass the agent all workflow instructions below. Include the received arguments: `$ARGUMENTS`

After the agent completes, relay its verification report to the user exactly as received.

---

## Workflow Instructions

Launch a Playwright browser session to visually verify UI changes with screenshots and interaction testing.

Arguments: $ARGUMENTS

### 1. Determine What to Verify
- Ask the user what URL/page to verify (or infer from recent changes)
- Identify specific elements, states, or flows to check
- Determine required auth state (logged in vs logged out)

### 2. Ensure Dev Server is Running
- Check if a dev server is already running on the expected port
- If not, start it in the background
- Wait for the server to be ready before proceeding

### 3. Navigate and Verify
- Open the target URL in Playwright
- Take a full-page screenshot as baseline
- Check for console errors and network failures
- Verify expected elements are present and visible

### 4. Debug Loop
If issues are found:
1. Take a targeted screenshot of the problem area
2. Check the browser console for errors
3. Inspect network requests for failed API calls
4. Report findings with evidence (screenshots + error messages)
5. If the fix is obvious, apply it and re-verify

### 5. Close Browser
- Close the Playwright browser session
- Stop the dev server if it was started for this verification

### 6. Report
- Present screenshots showing the verified state
- List any issues found and their status (fixed or needs attention)
- Confirm pass/fail for each verification point

## Key Rules

- **Always take screenshots** - visual evidence is the whole point of this command
- **Check multiple viewports** - desktop (1280x720) and mobile (375x667) at minimum
- **Check both themes** - if the project supports light/dark mode, verify both
- **Be specific about failures** - include the exact element, expected state, and actual state
- **Don't skip error checks** - always check console and network before declaring success

## Usage

```
/pwv                           # Verify current page / recent changes
/pwv https://localhost:5173    # Verify specific URL
/pwv /dashboard --mobile       # Verify specific route on mobile viewport
/pwv --dark                    # Verify dark mode specifically
```

commands/walkthrough.md

# /walkthrough - Step-by-Step Guide Mode

Enter guided walkthrough mode where Claude presents one step at a time and waits for confirmation before advancing.

## Trigger

Activate when the user says "walk me through", "guide me through", "step me through", or uses `/walkthrough`.

## Behavior

1. **Identify the task** - Understand what the user wants to accomplish
2. **Break it down** - Divide the task into discrete, sequential steps
3. **Present one step at a time** with clear instructions
4. **Show progress** - Display "**Step N/Total**" at the start of each step
5. **STOP and wait** for the user to confirm completion, ask questions, or provide information
6. **Never skip ahead** or present multiple steps at once - one step, one confirmation, then next
7. **Adapt** - If the user provides info (API keys, account IDs, URLs), incorporate it into subsequent steps
8. **Resolve blockers** - If the user is stuck on a step, help resolve the issue before moving on
9. **Only advance when confirmed** - Do not proceed until the user explicitly signals they are ready

## Step Format

Each step should follow this format:

```
**Step N/Total: [Brief title]**

[Clear instructions for this step]

[Any code blocks, commands, or configuration needed]

[What to expect / how to verify this step succeeded]

---
Ready to continue? Let me know when this step is done, or ask if you have questions.
```

## Guidelines

- Keep each step focused on ONE action or decision
- Provide enough context so the user understands WHY they are doing this step
- Include verification criteria so the user knows the step succeeded
- If a step requires the user to perform an action in a third-party UI (dashboard, browser), describe exactly where to click and what to look for
- If a step has prerequisites, state them clearly at the top
- Estimate complexity: mark steps as quick (< 1 min), moderate (1-5 min), or involved (5+ min)

## Usage

```
/walkthrough deploy to cloudflare    # Guided deployment walkthrough
/walkthrough setup supabase auth     # Guided auth configuration
/walkthrough migrate database        # Guided migration process
```

commands/promote-rule.md

---
description: Promote Repo Rules to Global
allowed-tools: Agent
---

# /promote-rule - Promote Repo Rules to Global

Use the Agent tool to execute this workflow on a cheaper model:

- **model**: sonnet
- **description**: promote repo rules

Pass the agent all workflow instructions below.

After the agent completes, relay its findings to the user exactly as received.

---

## Workflow Instructions

Review repo-level CLAUDE.md files and suggest rules that should be promoted to the global configuration.

### 1. Find Explicit Candidates

Search the current repo's CLAUDE.md for the `CANDIDATE:GLOBAL` marker:

```
<!-- CANDIDATE:GLOBAL - [reason] -->
```

Any instruction marked with this tag has been explicitly flagged for promotion. Collect these first.

### 2. Detect Implicit Candidates

Scan the repo's CLAUDE.md for rules that appear to be repo-agnostic. Good candidates for global promotion include:
- Workflow conventions (git, PR, issues)
- Code style rules that apply across all projects
- Security practices
- Error handling patterns
- Testing requirements
- Any rule that doesn't reference project-specific paths, commands, or technologies

### 3. Check for Duplicates Across Repos

If multiple repo CLAUDE.md files contain similar rules, that is a strong signal for promotion. Check sibling repos for overlapping instructions:
```bash
# Search for similar headings across repos
grep -r "## Rule Heading" ~/code/*/CLAUDE.md ~/code/*-repos/*/CLAUDE.md
```

### 4. Check Against Global

Before suggesting a promotion, verify the rule is not already covered in the global CLAUDE.md (`~/.claude/CLAUDE.md`). If a similar rule exists globally:
- Check if the repo version adds anything new
- If yes, suggest merging the additions into the global version
- If no, suggest removing the duplicate from the repo

### 5. Present Findings

For each candidate, present:

```
**Candidate: [Rule Title]**
- Source: [repo]/CLAUDE.md
- Type: Explicit (CANDIDATE:GLOBAL) | Implicit (repo-agnostic pattern)
- Already in global: Yes (partial) | No
- Recommendation: Promote | Merge | Skip (already covered)
- Content preview: [first 2-3 lines of the rule]
```

### 6. Take Action

For approved promotions:
1. Add the rule to `~/.claude/CLAUDE.md` in the appropriate section
2. Remove the rule from the repo's CLAUDE.md (or replace with a reference to global)
3. Remove the `CANDIDATE:GLOBAL` marker if present
4. Verify no project-specific references leaked into the global file

## Usage

```
/promote-rule                  # Scan current repo's CLAUDE.md
/promote-rule --all            # Scan all repos for candidates
/promote-rule --dry-run        # Show candidates without making changes
```

commands/freeze.md

---
description: Scope-lock Edit/Write to a directory until /unfreeze
---

# /freeze - Scope-Lock Writes to a Directory

Activate the `check-freeze.py` PreToolUse hook by writing a directory path to
`~/.claude/freeze-dir.txt`. While a freeze is active, Edit and Write operations
outside that directory are blocked with a `deny` permission decision.

Use freeze during debugging or focused investigation to prevent scope creep.

## Usage

```
/freeze                      # Freeze to the current working directory
/freeze <absolute-or-relative-path>
```

## Workflow

1. Resolve the argument to an absolute path:
   - No argument: use the current working directory.
   - Relative path: resolve against the current working directory.
   - Absolute path: use as-is.
2. Verify the path exists and is a directory. If not, report the error and stop.
3. Write the resolved absolute path to `~/.claude/freeze-dir.txt` (overwriting
   any previous freeze).
4. Confirm to the user: `Frozen to: <path>`. Remind them that `/unfreeze`
   clears the scope.

## Example

```
/freeze modules/hooks
# -> Frozen to: /home/user/code/ccgm/modules/hooks
# Subsequent Edit/Write calls outside modules/hooks are denied.
```

## Notes

- The freeze state is a single file at `~/.claude/freeze-dir.txt`. Only one
  directory can be frozen at a time; `/freeze` overwrites the previous value.
- Paths with symlinks and `..` are resolved before the containment check, so
  trivial escapes are caught.
- Bash commands are NOT scope-locked - only Edit and Write. Pair with
  `/guard` if you also want destructive-command warnings.

commands/unfreeze.md

---
description: Clear the active freeze scope
---

# /unfreeze - Clear the Freeze Scope

Deactivate the `check-freeze.py` PreToolUse hook by deleting
`~/.claude/freeze-dir.txt`. After `/unfreeze`, Edit and Write operations are
no longer scope-locked.

## Usage

```
/unfreeze
```

## Workflow

1. Check whether `~/.claude/freeze-dir.txt` exists.
2. If it exists, delete it and confirm: `Unfrozen (was: <previous-path>)`.
3. If it does not exist, report: `No freeze active`.

## Notes

- This does not disable the `check-freeze.py` hook itself - it only clears the
  state file the hook reads. The hook is a no-op when no freeze is set.
- Use `/freeze <dir>` to re-activate scope locking.

commands/guard.md

---
description: Compose careful + freeze for focused, safe sessions
---

# /guard - Compose Careful + Freeze

`guard` combines the two safety hooks shipped by the `hooks` module:

- **check-careful.py** (PreToolUse:Bash) - prompts on destructive commands
  (`rm -rf`, SQL DROP/TRUNCATE, force push, hard reset, etc.).
- **check-freeze.py** (PreToolUse:Edit|Write) - denies writes outside the
  frozen directory.

`/guard` activates both for a named scope. Use it during investigation or
refactors where you want to stay inside one module and avoid destructive
surprises.

## Usage

```
/guard                       # Guard the current working directory
/guard <absolute-or-relative-path>
```

## Workflow

1. Resolve the argument to an absolute path (same rules as `/freeze`).
2. Activate freeze by writing the path to `~/.claude/freeze-dir.txt`.
3. Confirm both hooks are installed (`~/.claude/hooks/check-careful.py` and
   `~/.claude/hooks/check-freeze.py` exist). If either is missing, warn the
   user and point to the `hooks` module README.
4. Report: `Guarded: <path>. Edit/Write scoped; destructive Bash commands will prompt.`
5. Remind the user that `/unfreeze` clears the freeze half. The careful hook
   stays active (it has no state file to clear; it runs on every Bash call).

## When Other Commands Auto-Guard

Slash commands that encourage focused scope (for example `/investigate` when
adopted) should call `/guard <target-dir>` at the start of the session so the
user does not have to remember.

## Notes

- `check-careful.py` has no enable/disable state - it inspects every Bash
  command. The only way to quiet it is to not call destructive commands.
- `check-freeze.py` is gated by the state file, so `/unfreeze` fully clears
  the scope lock.

commands/checkpoint.md

---
description: Save or resume a structured WIP checkpoint (current task, decisions, remaining work)
---

# /checkpoint - Save or Resume Session State

Capture a structured "pick up here next time" snapshot, or resume from the most
recent one. Different from the session log: session logs are chronological
narrative, checkpoints are compact WIP state meant for handoff between
sessions - or between clones in a multi-agent workspace.

Checkpoints are stored under `~/.claude/checkpoints/{repo}/` as YAML-fronted
markdown, so they survive across clones and can be grepped by branch or date.

## Usage

```
/checkpoint save [title]     # Write a checkpoint now
/checkpoint resume           # Load the most recent checkpoint for this repo
/checkpoint resume [query]   # Load by title substring or YYYYMMDD date
/checkpoint list             # Show checkpoints for this repo, newest first
```

`save` is the default verb: `/checkpoint some title` is equivalent to
`/checkpoint save some title`.

## When to Use

- About to end a session mid-task. Write a checkpoint so the next session can
  resume without rereading the whole log.
- Switching clones in a workspace. Save on clone A, resume on clone B to pick
  up the same WIP.
- Context is about to compact. Checkpoint the essentials before they get
  summarized away.
- Parking a branch to switch to a hotfix. Save, switch, come back, resume.

## Save Workflow

### 1. Derive Identifiers

```bash
REPO_NAME=$(git remote get-url origin 2>/dev/null | xargs basename | sed 's/\.git$//')
[ -z "$REPO_NAME" ] && REPO_NAME=$(basename "$PWD")
BRANCH=$(git branch --show-current 2>/dev/null || echo "detached")
TS=$(date +%Y%m%d-%H%M%S)
CKPT_DIR="$HOME/.claude/checkpoints/${REPO_NAME}"
mkdir -p "$CKPT_DIR"
```

Build a filename-safe slug from the title argument (or the current branch if no
title was given):

```bash
TITLE="${ARG:-$BRANCH}"
# printf '%s', not echo: bash's builtin echo flag-parses a leading -n/-e,
# so a title of exactly "-n" would silently produce an empty slug.
SLUG=$(printf '%s' "$TITLE" | tr '[:upper:]' '[:lower:]' \
  | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g' \
  | cut -c1-60)
[ -z "$SLUG" ] && SLUG="checkpoint"
CKPT_FILE="${CKPT_DIR}/${TS}-${SLUG}.md"
```

### 2. Collect State

Gather the facts you will write into the checkpoint. Do NOT invent details -
only capture what is actually observable right now.

- **status**: one-line summary of where this task stands (e.g. `in-progress`,
  `blocked-on-review`, `ready-to-push`).
- **branch**: `git branch --show-current`.
- **files_modified**: `git status --porcelain` (working copy) plus
  `git diff --name-only origin/main...HEAD` (committed deltas on this branch).
  Deduplicate. List absolute paths or repo-relative paths consistently.
- **session_duration_s**: if you can read the session start time from the
  agent log or your own memory of this session, compute it. Otherwise omit.
- **timestamp**: ISO 8601 local time.

### 3. Write the Checkpoint File

```markdown
---
title: {title}
status: {status}
branch: {branch}
timestamp: {iso8601}
session_duration_s: {integer-or-null}
files_modified:
  - {path}
  - {path}
---

# {title}

## Working on

{One to three sentences. What is the active task? What issue or PR? What is
the immediate next action?}

## Decisions Made

{Bulleted list of non-obvious choices made so far in this session - approach
selected, alternatives rejected, constraints discovered. Skip the obvious.}

## Remaining Work

{Bulleted list of concrete next steps in order. Each item should be small
enough to execute without more planning. Mark blockers with `BLOCKED:`.}

## Notes

{Anything else the next session needs: URLs, test outputs, open questions, a
command to run first, a file to reread. Keep it brief.}
```

### 4. Confirm to the User

Print:

```
Checkpoint saved: {CKPT_FILE}
Branch: {branch}  |  Files touched: {N}
Resume with: /checkpoint resume {slug}
```

## Resume Workflow

### 1. Locate the Checkpoint

```bash
REPO_NAME=$(git remote get-url origin 2>/dev/null | xargs basename | sed 's/\.git$//')
[ -z "$REPO_NAME" ] && REPO_NAME=$(basename "$PWD")
CKPT_DIR="$HOME/.claude/checkpoints/${REPO_NAME}"

# No argument: newest checkpoint for this repo.
# Argument looks like YYYYMMDD: newest checkpoint from that date.
# Otherwise: newest checkpoint whose filename contains the query substring.
```

Resolution order:

1. If the query is an 8-digit date, filter files starting with that date prefix.
2. Otherwise treat the query as a case-insensitive substring match against the
   filename (which contains the slug).
3. If no query, take the newest file by name.
4. If still nothing, report `No checkpoints found for {REPO_NAME}` and stop.

Checkpoints saved on other branches ARE valid matches. That is the point - the
user may be resuming from a parked branch.

### 2. Read and Summarize

Read the checkpoint file. Render a concise summary to the user:

```
Resumed: {filename}
Branch: {branch from frontmatter} (current: {current branch})
Status: {status}
Saved: {timestamp}

Working on:
{Working-on section, verbatim}

Remaining Work:
{Remaining-work section, verbatim}
```

### 3. Branch Reconciliation

If the checkpoint's `branch` differs from the current branch, surface the
mismatch and ASK before switching. Never auto-checkout.

```
Checkpoint was saved on `{ckpt-branch}`, you are on `{current-branch}`.
Switch with: git checkout {ckpt-branch}
Or continue on the current branch if intentional.
```

### 4. File Staleness Check

For each path in `files_modified`, check whether it still exists and whether
the file's current state differs from what was likely present at checkpoint
time. A cheap heuristic: if the file is in `git status --porcelain` output
now AND was listed in the checkpoint, flag it as "may have drifted."

Report anything suspicious but do not block. The checkpoint is a hint, not a
lock.

### 5. Next Step

Propose the first item from `Remaining Work` as the next action. Do not
execute it automatically - the user may want to re-scope after resuming.

## List Workflow

```bash
ls -1t "$HOME/.claude/checkpoints/${REPO_NAME}/" 2>/dev/null
```

Render each as `{timestamp}  {branch}  {title}` by reading the first few
frontmatter lines of each file. Limit to the 20 most recent by default.

## Cross-Clone Usage

In the workspace model, `~/.claude/checkpoints/` is shared across all clones
of the user's machine. A checkpoint saved from `myrepo-w0-c0` is visible to
`myrepo-w0-c1`. The only prerequisite is that the other clone is on a branch
that contains the same commits referenced by `files_modified` - otherwise
paths may not exist yet.

Checkpoints are local-only by default. If you want remote backup, symlink
`~/.claude/checkpoints/` into a private git repo yourself; this command
does not do that automatically.

## Conventions

- One checkpoint per invocation. Never overwrite an existing checkpoint -
  timestamps in filenames guarantee uniqueness.
- Checkpoints are ephemeral state, not audit history. It is fine to delete
  `~/.claude/checkpoints/{repo}/` when the repo is done.
- Do not include secrets, tokens, or credentials in any section. If the
  current task touches secrets, reference them by variable name only
  (e.g. `SUPABASE_SECRET_KEY`).
- Checkpoints are markdown. The frontmatter is authoritative; the prose
  sections are for the human (and the next agent) to read.
skill (1)

skills/audit/SKILL.md

# Codebase Audit

Comprehensive codebase audit. Produces a findings document and creates GitHub issues. Prompts for configuration when invoked.

## Usage

```bash
# Interactive (prompts for configuration)
/audit                    # Asks: scope (read-only vs auto-fix) + execution strategy

# Direct flags (skip the prompt)
/audit --fix              # Audit WITH auto-fixes (uses worktrees, creates PR)
/audit --single           # Single-session audit (one subagent per selected pack, read-only)
/audit --manual           # Set up tasks + output launch commands for manual orchestration
/audit --worker           # Worker mode (run from worktree/clone after --manual setup)
/audit --collect          # Compile results + create issues (after workers complete)
/audit --collect --force  # Collect even …

View raw (89826 bytes)

script (50)

skills/audit/scripts/registry.py

#!/usr/bin/env python3
"""
CCGM /audit pack registry loader.

Input  (stdin or first positional arg): detector JSON produced by detect-ecosystems.sh
  {
    "detected_ecosystems": ["javascript", "typescript"],
    "project_shape": {
      "has_migrations": true,
      "has_dockerfile": false,
      "has_workflows": true,
      "is_extension": false,
      "is_mobile": false,
      "monorepo_packages": [],
      "frameworks": ["react"]
    },
    "available_tools": ["semgrep", "gitleaks"]
  }

Output (stdout): JSON array of applicable pack objects, each validated against
  pack.schema.json (stdlib validation — no jsonschema dep).

Exit codes:
  0  success
  1  input/schema error
"""

import json
import os
import re
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Minimal stdlib JSON Schema validator (subset used by pack.schema.json)
# Covers: type, required, additionalProperties, enum, const, pattern,
#         minItems, anyOf, $ref (local defs only), array items.
# ---------------------------------------------------------------------------

_VALID_SEVERITIES = {"critical", "high", "medium", "low", "info"}
_VALID_CONFIDENCES = {"high", "medium", "low"}
_VALID_DETECTIONS = {"tool", "llm", "hybrid"}
_VALID_SHAPE_FLAGS = {
    "has_migrations", "has_dockerfile", "has_workflows", "is_extension", "is_mobile",
    "has_iac"
}


class ValidationError(Exception):
    pass


def _validate_check(check: object, path: str) -> None:
    """Validate a single check object against the check $def."""
    if not isinstance(check, dict):
        raise ValidationError(f"{path}: must be an object")

    required = {"id", "severity", "confidence", "detection"}
    missing = required - check.keys()
    if missing:
        raise ValidationError(f"{path}: missing required fields: {sorted(missing)}")

    allowed = {"id", "severity", "confidence", "detection", "tool", "rule", "fallback", "auto_fixable"}
    extra = check.keys() - allowed
    if extra:
        raise ValidationError(f"{path}: unexpected fields: {sorted(extra)}")

    _id = check["id"]
    if not isinstance(_id, str) or not re.fullmatch(r"[a-z0-9_-]+/[a-z0-9_.-]+", _id):
        raise ValidationError(f"{path}.id: must match pattern ^[a-z0-9_-]+/[a-z0-9_.-]+$ (got {_id!r})")

    if check["severity"] not in _VALID_SEVERITIES:
        raise ValidationError(f"{path}.severity: must be one of {sorted(_VALID_SEVERITIES)}")

    if check["confidence"] not in _VALID_CONFIDENCES:
        raise ValidationError(f"{path}.confidence: must be one of {sorted(_VALID_CONFIDENCES)}")

    if check["detection"] not in _VALID_DETECTIONS:
        raise ValidationError(f"{path}.detection: must be one of {sorted(_VALID_DETECTIONS)}")

    if "auto_fixable" in check and not isinstance(check["auto_fixable"], bool):
        raise ValidationError(f"{path}.auto_fixable: must be a boolean")

    for field in ("tool", "rule", "fallback"):
        if field in check and not isinstance(check[field], str):
            raise ValidationError(f"{path}.{field}: must be a string")


def _validate_applies_when_item(item: object, path: str) -> None:
    """Validate a single applies_when item."""
    if not isinstance(item, str):
        raise ValidationError(f"{path}: must be a string")

    # const "always"
    if item == "always":
        return

    # project-shape flags
    if item in _VALID_SHAPE_FLAGS:
        return

    # language predicates
    if re.fullmatch(r"language:[a-z][a-z0-9_-]*", item):
        return

    raise ValidationError(
        f"{path}: {item!r} is not a valid applies_when item. "
        f"Must be 'always', a project-shape flag {sorted(_VALID_SHAPE_FLAGS)}, "
        "or a 'language:<lang>' predicate."
    )


def validate_pack(pack: object, pack_path: str = "<unknown>") -> None:
    """
    Validate a pack manifest against the rules encoded in pack.schema.json.
    Raises ValidationError with a descriptive message on failure.
    """
    if not isinstance(pack, dict):
        raise ValidationError(f"{pack_path}: pack must be a JSON object")

    required = {"id", "name", "version", "applies_when", "checks"}
    missing = required - pack.keys()
    if missing:
        raise ValidationError(f"{pack_path}: missing required fields: {sorted(missing)}")

    allowed = {"id", "name", "version", "applies_when", "tags", "severity_floor", "tools", "checks"}
    extra = pack.keys() - allowed
    if extra:
        raise ValidationError(f"{pack_path}: unexpected fields: {sorted(extra)}")

    # id
    _id = pack["id"]
    if not isinstance(_id, str) or not re.fullmatch(r"[a-z0-9_-]+/[a-z0-9_-]+", _id):
        raise ValidationError(f"{pack_path}.id: must match pattern ^[a-z0-9_-]+/[a-z0-9_-]+$ (got {_id!r})")

    # name
    if not isinstance(pack["name"], str) or not pack["name"].strip():
        raise ValidationError(f"{pack_path}.name: must be a non-empty string")

    # version
    if not isinstance(pack["version"], str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", pack["version"]):
        raise ValidationError(f"{pack_path}.version: must be a semver string like '1.0.0'")

    # applies_when
    aw = pack["applies_when"]
    if not isinstance(aw, list) or len(aw) < 1:
        raise ValidationError(f"{pack_path}.applies_when: must be a non-empty array")
    for i, item in enumerate(aw):
        _validate_applies_when_item(item, f"{pack_path}.applies_when[{i}]")

    # tags (optional)
    if "tags" in pack:
        if not isinstance(pack["tags"], list):
            raise ValidationError(f"{pack_path}.tags: must be an array")
        for i, t in enumerate(pack["tags"]):
            if not isinstance(t, str):
                raise ValidationError(f"{pack_path}.tags[{i}]: must be a string")

    # severity_floor (optional)
    if "severity_floor" in pack:
        if pack["severity_floor"] not in _VALID_SEVERITIES:
            raise ValidationError(f"{pack_path}.severity_floor: must be one of {sorted(_VALID_SEVERITIES)}")

    # tools (optional)
    if "tools" in pack:
        if not isinstance(pack["tools"], list):
            raise ValidationError(f"{pack_path}.tools: must be an array")
        for i, t in enumerate(pack["tools"]):
            if not isinstance(t, str):
                raise ValidationError(f"{pack_path}.tools[{i}]: must be a string")

    # checks
    checks = pack["checks"]
    if not isinstance(checks, list) or len(checks) < 1:
        raise ValidationError(f"{pack_path}.checks: must be a non-empty array")
    for i, check in enumerate(checks):
        _validate_check(check, f"{pack_path}.checks[{i}]")


# ---------------------------------------------------------------------------
# Pack applicability selection
# ---------------------------------------------------------------------------

def build_truthy_conditions(detector: dict) -> set:
    """
    Build the set of truthy condition tokens from detector output.

    Rules (plan §3.3):
    - Each detected ecosystem → "language:<ecosystem>" (lowercased)
    - Each project_shape flag that is True → that flag name
    - Always include the literal "always"
    """
    conditions = {"always"}

    ecosystems = detector.get("detected_ecosystems", [])
    for eco in ecosystems:
        if isinstance(eco, str):
            conditions.add(f"language:{eco.lower()}")

    shape = detector.get("project_shape", {})
    for flag in _VALID_SHAPE_FLAGS:
        if shape.get(flag) is True:
            conditions.add(flag)

    return conditions


def is_pack_applicable(pack: dict, conditions: set) -> bool:
    """
    A pack is applicable iff EVERY item in its applies_when[] is in the
    truthy condition set.
    """
    applies_when = pack.get("applies_when", [])
    return all(item in conditions for item in applies_when)


# ---------------------------------------------------------------------------
# Pack discovery
# ---------------------------------------------------------------------------

def discover_packs(packs_dir: Path) -> list:
    """
    Discover all pack.json files under packs_dir, validate each,
    and return the list of valid pack dicts.

    Emits a warning to stderr for any pack that fails validation
    (does not abort — lets the registry proceed with valid packs).
    """
    packs = []
    if not packs_dir.is_dir():
        return packs

    for pack_file in sorted(packs_dir.rglob("pack.json")):
        try:
            with open(pack_file, "r", encoding="utf-8") as fh:
                pack = json.load(fh)
        except json.JSONDecodeError as exc:
            print(f"WARNING: {pack_file}: invalid JSON: {exc}", file=sys.stderr)
            continue

        try:
            validate_pack(pack, str(pack_file))
        except ValidationError as exc:
            print(f"WARNING: {pack_file}: validation failed: {exc}", file=sys.stderr)
            continue

        packs.append(pack)

    return packs


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def main(argv: list) -> int:
    # Read detector JSON from a file arg or stdin
    if len(argv) > 1:
        path = argv[1]
        try:
            with open(path, "r", encoding="utf-8") as fh:
                raw = fh.read()
        except OSError as exc:
            print(f"ERROR: cannot read {path!r}: {exc}", file=sys.stderr)
            return 1
    else:
        raw = sys.stdin.read()

    try:
        detector = json.loads(raw)
    except json.JSONDecodeError as exc:
        print(f"ERROR: detector input is not valid JSON: {exc}", file=sys.stderr)
        return 1

    if not isinstance(detector, dict):
        print("ERROR: detector input must be a JSON object", file=sys.stderr)
        return 1

    # Build truthy condition set
    conditions = build_truthy_conditions(detector)

    # Locate packs directory. CCGM_PACKS_DIR env var overrides the default
    # (used by tests to inject fixture packs without touching the real packs dir).
    packs_dir_env = os.environ.get("CCGM_PACKS_DIR")
    if packs_dir_env:
        packs_dir = Path(packs_dir_env)
    else:
        script_dir = Path(__file__).parent
        packs_dir = script_dir.parent / "packs"

    # Discover and validate packs
    all_packs = discover_packs(packs_dir)

    # Select applicable packs
    applicable = [p for p in all_packs if is_pack_applicable(p, conditions)]

    print(json.dumps(applicable, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))

skills/audit/scripts/assign-packs.py

#!/usr/bin/env python3
"""
CCGM /audit pack-to-worker load balancer (Epic 1.7b).

Reads the registry-selected packs JSON (from stdin or a file argument) and
outputs a JSON mapping worker ids (0..N-1) to ordered pack-id lists.

Usage
-----
  assign-packs.py [selected-packs.json] [--workers N]

  selected-packs.json   Path to the JSON file produced by registry.py.
                        If omitted, reads from stdin.
  --workers N           Number of workers to distribute across (default: 4).
                        Must be >= 1.

Output
------
  {
    "0": ["pack/id-a", "pack/id-b"],
    "1": ["pack/id-c"],
    "2": [],
    "3": []
  }

  Keys are string integers 0..N-1 (JSON object keys are always strings).
  Workers with no packs get an empty list -- the caller should launch only
  workers whose list is non-empty.

Algorithm
---------
  1. Sort packs by (checks_count DESC, pack_id ASC) for a stable weight proxy.
     More checks = more work; alphabetical tiebreak = deterministic.
  2. Assign greedily to the currently lightest worker (lowest current load).
     Ties in load -> lowest worker id wins.

  Same input always produces byte-identical output.

Exit codes
----------
  0  success
  1  input error (bad JSON, missing keys)
"""

import json
import sys


def _parse_args(argv: list) -> tuple:
    """
    Parse argv[1:] and return (input_path_or_none, num_workers).
    Raises SystemExit(1) on bad arguments.
    """
    workers = 4
    input_path = None
    i = 1
    while i < len(argv):
        arg = argv[i]
        if arg == "--workers":
            i += 1
            if i >= len(argv):
                print("ERROR: --workers requires a value", file=sys.stderr)
                sys.exit(1)
            try:
                workers = int(argv[i])
            except ValueError:
                print(f"ERROR: --workers value must be an integer, got {argv[i]!r}",
                      file=sys.stderr)
                sys.exit(1)
            if workers < 1:
                print(f"ERROR: --workers must be >= 1, got {workers}", file=sys.stderr)
                sys.exit(1)
        elif arg.startswith("--"):
            print(f"ERROR: unknown flag {arg!r}", file=sys.stderr)
            sys.exit(1)
        else:
            if input_path is not None:
                print("ERROR: at most one positional argument (input file) allowed",
                      file=sys.stderr)
                sys.exit(1)
            input_path = arg
        i += 1
    return input_path, workers


def _load_packs(input_path) -> list:
    """Load and validate the selected-packs JSON. Returns the list of pack dicts."""
    if input_path is not None:
        try:
            with open(input_path, "r", encoding="utf-8") as fh:
                raw = fh.read()
        except OSError as exc:
            print(f"ERROR: cannot read {input_path!r}: {exc}", file=sys.stderr)
            sys.exit(1)
    else:
        raw = sys.stdin.read()

    try:
        packs = json.loads(raw)
    except json.JSONDecodeError as exc:
        print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
        sys.exit(1)

    if not isinstance(packs, list):
        print("ERROR: input must be a JSON array of pack objects", file=sys.stderr)
        sys.exit(1)

    for i, p in enumerate(packs):
        if not isinstance(p, dict):
            print(f"ERROR: packs[{i}] is not an object", file=sys.stderr)
            sys.exit(1)
        if "id" not in p:
            print(f"ERROR: packs[{i}] missing required 'id' field", file=sys.stderr)
            sys.exit(1)

    return packs


def assign_packs(packs: list, num_workers: int) -> dict:
    """
    Greedy load-balanced assignment.

    Returns a dict: {worker_id_str: [pack_id, ...]} for worker ids 0..N-1.
    Workers with no packs get an empty list.

    Sorting key: (checks_count DESC, pack_id ASC)
    Assignment: greedily to the lightest worker; ties -> lowest worker id.
    """
    # Sort packs for deterministic assignment: most checks first, alpha tiebreak
    sorted_packs = sorted(
        packs,
        key=lambda p: (-len(p.get("checks", [])), p.get("id", "")),
    )

    # Worker loads: keyed by index 0..N-1
    worker_loads = [0] * num_workers       # current load (check count)
    worker_packs = [[] for _ in range(num_workers)]   # assigned pack ids

    for pack in sorted_packs:
        pack_id = pack["id"]
        pack_weight = len(pack.get("checks", []))

        # Find the lightest worker; break ties by lowest index (= lowest worker id)
        lightest = 0
        for idx in range(1, num_workers):
            if worker_loads[idx] < worker_loads[lightest]:
                lightest = idx

        worker_packs[lightest].append(pack_id)
        worker_loads[lightest] += pack_weight

    # Build output dict with 0-based string keys (JSON object keys are strings)
    result = {}
    for idx in range(num_workers):
        result[str(idx)] = worker_packs[idx]

    return result


def main(argv: list) -> int:
    input_path, num_workers = _parse_args(argv)
    packs = _load_packs(input_path)
    assignment = assign_packs(packs, num_workers)
    print(json.dumps(assignment, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))

skills/audit/scripts/detect-ecosystems.sh

#!/usr/bin/env bash
# detect-ecosystems.sh
# Phase-0 ecosystem detector for /audit (Epic 1.3)
#
# Usage: bash detect-ecosystems.sh [TARGET_DIR]
#   TARGET_DIR defaults to git root (or cwd if not in a git repo)
#
# Output: JSON to stdout:
#   {
#     "detected_ecosystems": [...],
#     "project_shape": {
#       "monorepo_packages": [...],
#       "frameworks": [...],
#       "has_migrations": bool,
#       "has_dockerfile": bool,
#       "has_workflows": bool,
#       "is_extension": bool,
#       "is_mobile": bool,
#       "has_iac": bool
#     },
#     "available_tools": [...]
#   }
#
# has_iac detection heuristic (any one of):
#   - Dockerfile (also sets has_dockerfile)
#   - *.tf or *.tf.json files (Terraform HCL)
#   - k8s manifest heuristic: *.yaml under k8s/, manifests/, or .k8s/ di…

View raw (15869 bytes)

skills/audit/scripts/emit-findings.py

#!/usr/bin/env python3
"""
CCGM /audit findings JSONL emitter (Epic 1.6).

Gate decision #30: emits line-delimited JSON (one finding per line), NOT SARIF.

Input (stdin OR first positional arg): JSON array of raw finding objects.

Each raw finding MUST include at minimum:
  check_id    str   "pack/check"
  rule_id     str
  severity    str   critical|high|medium|low|info
  confidence  str   high|medium|low
  detection   str   tool|llm|hybrid
  source      str   tool|llm
  message     str
  location    obj   { path: str, line: int }

Optional on raw input:
  fingerprint   str   If present, kept VERBATIM (source-tool fingerprint)
  end_line      int   Forwarded to location if present
  fix_confidence str
  suppression   obj
  properties    obj

Output: .audit/current/findings.jsonl
  One JSON object per line, each conforming to finding.schema.json.
  Writes to the output file; directory is created if absent.

Fingerprint algorithm (section 3.7, when no source fingerprint is present):
  context = strip_all_whitespace(lower(primary_line +/- 2 lines))
  fingerprint = sha256(context.encode())[:16] + ":1"

  Primary line +/- 2 lines means lines [line-2, line+2] inclusive (1-based),
  clamped to the actual file length. If the file is missing or unreadable,
  fingerprint is computed from just the normalized message + location string
  so the caller always gets a stable, non-empty value.

  strip_all_whitespace removes ALL whitespace characters (spaces, tabs,
  newlines) before hashing, so reformatting the primary line does not change
  the fingerprint.

Exit codes:
  0  success
  1  input / validation error
"""

import hashlib
import json
import os
import re
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_VALID_SEVERITIES = {"critical", "high", "medium", "low", "info"}
_VALID_CONFIDENCES = {"high", "medium", "low"}
_VALID_DETECTIONS = {"tool", "llm", "hybrid"}
_VALID_SOURCES = {"tool", "llm"}
_FINGERPRINT_GEN = ":1"

# Compiled patterns matching finding.schema.json
_RE_CHECK_ID = re.compile(r"^[a-z0-9_-]+/[a-z0-9_.-]+$")
_RE_FINGERPRINT = re.compile(r"^[A-Za-z0-9_.:+/=-]{8,128}$")


# ---------------------------------------------------------------------------
# Fingerprint helpers
# ---------------------------------------------------------------------------


def _strip_all_whitespace(text: str) -> str:
    """Remove every whitespace character (spaces, tabs, newlines)."""
    return "".join(text.split())


def _read_context_lines(path: str, line: int) -> str:
    """
    Read lines [line-2, line+2] (1-based) from path, concatenated.
    Returns an empty string if the file cannot be read.
    """
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            all_lines = fh.readlines()
    except OSError:
        return ""

    total = len(all_lines)
    if total == 0:
        return ""

    # line is 1-based; convert to 0-based index for slicing
    start = max(0, line - 3)    # index of line-2
    end = min(total, line + 2)  # exclusive upper bound (line+2 inclusive)
    return "".join(all_lines[start:end])


def compute_fingerprint(path: str, line: int, message: str) -> str:
    """
    Compute a stable fingerprint per section 3.7.
    Falls back to message+location if the file is missing or unreadable.
    """
    raw_context = _read_context_lines(path, line)
    if raw_context:
        normalized = _strip_all_whitespace(raw_context.lower())
    else:
        # Graceful fallback: hash message+location so the value is still stable
        fallback = f"{path}:{line}:{message}"
        normalized = _strip_all_whitespace(fallback.lower())

    digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
    return digest[:16] + _FINGERPRINT_GEN


# ---------------------------------------------------------------------------
# Schema-level validation helpers
# ---------------------------------------------------------------------------


class ValidationError(Exception):
    pass


def _check_enum(value: object, allowed: set, field: str) -> None:
    if value not in allowed:
        raise ValidationError(
            f"{field}: '{value}' not in {sorted(allowed)}"
        )


def validate_finding(obj: dict) -> None:
    """Validate a coerced finding against finding.schema.json constraints."""
    required = {
        "check_id", "rule_id", "severity", "confidence",
        "location", "message", "fingerprint", "detection", "source",
    }
    missing = required - obj.keys()
    if missing:
        raise ValidationError(f"missing required fields: {sorted(missing)}")

    _check_enum(obj["severity"], _VALID_SEVERITIES, "severity")
    _check_enum(obj["confidence"], _VALID_CONFIDENCES, "confidence")
    _check_enum(obj["detection"], _VALID_DETECTIONS, "detection")
    _check_enum(obj["source"], _VALID_SOURCES, "source")

    if "fix_confidence" in obj:
        _check_enum(obj["fix_confidence"], _VALID_CONFIDENCES, "fix_confidence")

    loc = obj["location"]
    if not isinstance(loc, dict):
        raise ValidationError("location must be an object")
    if "path" not in loc or "line" not in loc:
        raise ValidationError("location requires 'path' and 'line'")
    if not isinstance(loc["line"], int) or loc["line"] < 1:
        raise ValidationError("location.line must be an integer >= 1")
    if "end_line" in loc:
        if not isinstance(loc["end_line"], int) or loc["end_line"] < loc["line"]:
            raise ValidationError(
                "location.end_line must be integer >= location.line"
            )

    if not _RE_CHECK_ID.match(obj["check_id"]):
        raise ValidationError(
            f"check_id '{obj['check_id']}' does not match "
            "^[a-z0-9_-]+/[a-z0-9_.-]+$"
        )

    if not _RE_FINGERPRINT.match(obj["fingerprint"]):
        raise ValidationError(
            f"fingerprint '{obj['fingerprint']}' does not match "
            "^[A-Za-z0-9_.:+/=-]{8,128}$"
        )


# ---------------------------------------------------------------------------
# Coercion
# ---------------------------------------------------------------------------


def coerce_finding(raw: dict, repo_root: str) -> dict:
    """
    Normalise a raw finding dict into a schema-conformant object.
    - Adds/computes fingerprint if absent.
    - Resolves location.path relative to repo_root for context reads.
    - Strips unknown top-level keys (forward-compat).
    """
    # Validate presence of required raw fields before coercing
    required_raw = {
        "check_id", "rule_id", "severity", "confidence",
        "detection", "source", "message", "location",
    }
    missing = required_raw - raw.keys()
    if missing:
        raise ValidationError(f"missing required raw fields: {sorted(missing)}")

    out: dict = {}

    # Required string fields
    for key in ("check_id", "rule_id", "severity", "confidence",
                "detection", "source", "message"):
        out[key] = raw[key]

    # Optional scalar
    if "fix_confidence" in raw:
        out["fix_confidence"] = raw["fix_confidence"]

    # location
    loc_raw = raw["location"]
    if not isinstance(loc_raw, dict):
        raise ValidationError("location must be an object")
    if "path" not in loc_raw or "line" not in loc_raw:
        raise ValidationError("location requires 'path' and 'line'")
    loc: dict = {"path": str(loc_raw["path"]), "line": int(loc_raw["line"])}
    if "end_line" in loc_raw:
        loc["end_line"] = int(loc_raw["end_line"])
    out["location"] = loc

    # fingerprint: keep source-tool value VERBATIM; compute otherwise
    if raw.get("fingerprint"):
        out["fingerprint"] = str(raw["fingerprint"])
    else:
        abs_path = (
            loc["path"]
            if os.path.isabs(loc["path"])
            else os.path.join(repo_root, loc["path"])
        )
        out["fingerprint"] = compute_fingerprint(
            abs_path, loc["line"], raw["message"]
        )

    # Optional structured fields
    if "suppression" in raw:
        out["suppression"] = raw["suppression"]
    if "properties" in raw:
        out["properties"] = raw["properties"]

    return out


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main() -> int:
    # --- Determine input source ---
    if len(sys.argv) > 1 and sys.argv[1] != "-":
        input_path = sys.argv[1]
        try:
            with open(input_path, "r", encoding="utf-8") as fh:
                raw_text = fh.read()
        except OSError as exc:
            print(f"emit-findings: cannot read input file: {exc}", file=sys.stderr)
            return 1
    else:
        raw_text = sys.stdin.read()

    # --- Parse input ---
    try:
        findings_raw = json.loads(raw_text)
    except json.JSONDecodeError as exc:
        print(f"emit-findings: invalid JSON input: {exc}", file=sys.stderr)
        return 1

    if not isinstance(findings_raw, list):
        print("emit-findings: input must be a JSON array", file=sys.stderr)
        return 1

    # --- Determine output path ---
    # Default: .audit/current/findings.jsonl relative to cwd (repo root)
    output_path = os.environ.get(
        "AUDIT_FINDINGS_PATH",
        os.path.join(os.getcwd(), ".audit", "current", "findings.jsonl"),
    )
    output_dir = os.path.dirname(output_path)
    os.makedirs(output_dir, exist_ok=True)

    repo_root = os.getcwd()

    # --- Coerce and validate ---
    errors: list = []
    lines: list = []

    for idx, raw in enumerate(findings_raw):
        if not isinstance(raw, dict):
            errors.append(f"finding[{idx}]: must be an object")
            continue
        try:
            finding = coerce_finding(raw, repo_root)
            validate_finding(finding)
            lines.append(json.dumps(finding, separators=(",", ":")))
        except (KeyError, ValidationError) as exc:
            errors.append(f"finding[{idx}]: {exc}")

    if errors:
        for err in errors:
            print(f"emit-findings: {err}", file=sys.stderr)
        return 1

    with open(output_path, "w", encoding="utf-8") as fh:
        for line in lines:
            fh.write(line + "\n")

    print(f"emit-findings: wrote {len(lines)} finding(s) to {output_path}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

skills/audit/scripts/merge-findings.py

#!/usr/bin/env python3
"""
CCGM /audit merge-findings (Epic 1.9) -- spine + LLM results merger.

Merges the deterministic spine JSONL with zero or more LLM results files,
applies triage verdicts, deduplicates by fingerprint, enforces the severity
rubric mechanically, and streams schema-valid JSONL to stdout (or --output).

Usage
-----
  merge-findings.py --spine <spine.jsonl> [--llm <results.json> ...] \\
                    [--rubric <path>] [--repo <abs-path>] [--output <path>]

Arguments
---------
  --spine   <path>   Required. JSONL produced by scripts/spine/run.sh.
  --llm     <path>   Zero or more LLM results JSON files (see contract below).
                     Pass the flag multiple times for multiple workers.
  --rubric  <path>   Path to severity-rubric.json.
                     …

View raw (34119 bytes)

skills/audit/scripts/baseline.py

#!/usr/bin/env python3
"""
CCGM /audit baseline classifier (Epic 3.2).

Classifies current audit findings as new, existing, or resolved by comparing
against a baseline findings.jsonl produced by a previous merge-findings run.

Usage
-----
  baseline.py --current <findings.jsonl> --baseline <file> \\
              [--new-only] [--output <file>] [--save-baseline <path>]

Arguments
---------
  --current   <path>  Required. Current findings.jsonl (merge-findings output).
  --baseline  <path>  Required. Baseline findings.jsonl to compare against.
                      Must be a file path to a previous merge-findings output.
                      Ref-resolution (e.g. a git SHA) is not supported by this
                      script; to compare against a ref, extract the findings.jsonl
                      from that ref first:
                        git show <ref>:.audit/current/findings.jsonl > baseline.jsonl
                      then pass the extracted file here.
  --new-only          Optional. If set, output only findings tagged "new" (plus
                      the baseline_summary record). Findings tagged "existing" and
                      resolved records are omitted from output.
  --output    <path>  Optional. Write output JSONL to this file instead of stdout.
  --save-baseline <path>
                      Optional. Copy the current findings.jsonl to <path> after
                      classification. Useful for persisting the current run as the
                      next baseline (e.g. --save-baseline .audit/history/YYYY-MM-DD.jsonl).
                      The copy is byte-identical to --current; the baseline_summary
                      record written to --output is NOT included in the saved file.

Input format (both --current and --baseline)
--------------------------------------------
JSONL produced by merge-findings.py. Each line is one of:
  - A finding record (no "type" field): has "rule_id" and "fingerprint".
  - A metadata record (has a "type" field): provenance, coverage_gap, etc.

Only finding records (those without a "type" field) participate in matching.
Metadata records in --current are passed through to output unchanged; metadata
records in --baseline are ignored.

Matching key
------------
(rule_id, fingerprint)

The key is composite: rule_id correctly disambiguates two different rules whose findings
land on the same line (same fingerprint but different rule_id → two distinct findings).
Do NOT reduce the key to fingerprint alone.

Stability caveat for LLM findings
----------------------------------
Tool/spine findings set rule_id deterministically from the check schema, so they classify
stably across runs.  LLM findings are different: when a worker omits rule_id,
merge-findings backfills it from check_id.  If rule_id emission is inconsistent across
runs — the worker emits it in one run but omits it in another, causing merge-findings to
backfill a different value — the SAME logical finding (identical location + fingerprint)
can carry two different rule_ids across runs.  The result is one phantom "new" finding
and one phantom "resolved" finding for a finding that did not actually change.  This is a
known limitation of LLM-source findings; the fix is to ensure workers consistently emit
rule_id.

Classification
--------------
Each current finding is tagged in properties.baseline_status:
  "new"      — present in current, absent from baseline.
  "existing" — present in both current and baseline.

Additionally, baseline findings absent from current are emitted as:
  {"type": "resolved", "rule_id": ..., "fingerprint": ..., "baseline": <path>}
These represent findings that were fixed since the baseline was captured.

Summary record
--------------
A {"type": "baseline_summary", "new": N, "existing": N, "resolved": N, "baseline": <path>}
record is always emitted as the first line of output.

Output order
------------
1. baseline_summary record
2. Metadata records from --current (provenance, coverage_gap, etc.) — passed through
3. Tagged finding records (all, or only "new" when --new-only)
4. Resolved records (omitted when --new-only)

History / save-baseline pattern
--------------------------------
To maintain a run history, pass --save-baseline each time:
  baseline.py --current .audit/current/findings.jsonl \\
              --baseline .audit/history/last.jsonl \\
              --save-baseline .audit/history/$(date +%Y-%m-%d).jsonl
The script copies --current to the save path AFTER classification, so the saved
file is the verbatim merge-findings output without baseline_status tags.

Exit codes
----------
  0  Success.
  1  Input error, file not found, or malformed JSONL.
"""

import argparse
import json
import shutil
import sys
from pathlib import Path


# ---------------------------------------------------------------------------
# JSONL loading helpers
# ---------------------------------------------------------------------------

def _load_jsonl(path: str, label: str):
    """
    Load a JSONL file and return (metadata_records, finding_records).

    metadata_records  list of dicts that have a "type" field
    finding_records   list of dicts without a "type" field

    Exits 1 with a clear stderr message on any IO or parse error.
    """
    try:
        with open(path, encoding="utf-8") as fh:
            raw_lines = fh.readlines()
    except OSError as exc:
        print(
            f"baseline: ERROR: cannot read {label} file '{path}': {exc}",
            file=sys.stderr,
        )
        sys.exit(1)

    metadata_records = []
    finding_records = []

    for lineno, raw_line in enumerate(raw_lines, 1):
        raw_line = raw_line.strip()
        if not raw_line:
            continue
        try:
            obj = json.loads(raw_line)
        except json.JSONDecodeError as exc:
            print(
                f"baseline: ERROR: {label} '{path}' line {lineno} is not valid JSON: {exc}",
                file=sys.stderr,
            )
            sys.exit(1)

        if not isinstance(obj, dict):
            print(
                f"baseline: ERROR: {label} '{path}' line {lineno} is not a JSON object",
                file=sys.stderr,
            )
            sys.exit(1)

        if "type" in obj:
            metadata_records.append(obj)
        else:
            finding_records.append(obj)

    return metadata_records, finding_records


# ---------------------------------------------------------------------------
# Matching key
# ---------------------------------------------------------------------------

def _match_key(finding: dict):
    """Return the (rule_id, fingerprint) tuple used for baseline matching."""
    return (finding.get("rule_id", ""), finding.get("fingerprint", ""))


def _validate_key(finding: dict, label: str, lineno: int) -> bool:
    """
    Warn if a finding is missing rule_id or fingerprint.
    Returns True if the finding has a usable key, False otherwise.
    """
    rule_id = finding.get("rule_id", "")
    fingerprint = finding.get("fingerprint", "")
    if not rule_id or not fingerprint:
        missing = []
        if not rule_id:
            missing.append("rule_id")
        if not fingerprint:
            missing.append("fingerprint")
        print(
            f"baseline: WARNING: {label} finding (index {lineno}) missing "
            f"{', '.join(missing)}; skipped in matching",
            file=sys.stderr,
        )
        return False
    return True


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> int:
    parser = argparse.ArgumentParser(
        description="Classify audit findings as new/existing/resolved vs a baseline.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--current",
        required=True,
        metavar="PATH",
        help="Current findings.jsonl (merge-findings output).",
    )
    parser.add_argument(
        "--baseline",
        required=True,
        metavar="FILE",
        help=(
            "Baseline findings.jsonl to compare against. Must be a file path. "
            "To compare against a git ref, extract the file first: "
            "git show <ref>:.audit/current/findings.jsonl > baseline.jsonl"
        ),
    )
    parser.add_argument(
        "--new-only",
        action="store_true",
        default=False,
        help="Output only findings tagged 'new' (plus the summary). Omits 'existing' and resolved.",
    )
    parser.add_argument(
        "--output",
        default=None,
        metavar="PATH",
        help="Write output JSONL to this file instead of stdout.",
    )
    parser.add_argument(
        "--save-baseline",
        default=None,
        metavar="PATH",
        help=(
            "Copy --current to this path after classification. "
            "Useful for persisting the current run as the next baseline."
        ),
    )
    args = parser.parse_args()

    # ------------------------------------------------------------------
    # Load both JSONL files
    # ------------------------------------------------------------------
    current_meta, current_findings = _load_jsonl(args.current, "--current")
    _baseline_meta, baseline_findings = _load_jsonl(args.baseline, "--baseline")

    # ------------------------------------------------------------------
    # Build the baseline key set
    # ------------------------------------------------------------------
    baseline_keys: set = set()
    for idx, f in enumerate(baseline_findings):
        if _validate_key(f, "--baseline", idx):
            baseline_keys.add(_match_key(f))

    # ------------------------------------------------------------------
    # Classify current findings
    # ------------------------------------------------------------------
    new_count = 0
    existing_count = 0
    tagged_findings = []
    # Dedup current-side by (rule_id, fingerprint): mirrors the resolved-side
    # dedup below.  merge-findings collapses fingerprints on its output, so
    # duplicates are not expected on the intended path, but the symmetric guard
    # prevents double-counting if the same key appears more than once.
    seen_current_keys: set = set()

    for idx, f in enumerate(current_findings):
        if not _validate_key(f, "--current", idx):
            # Emit unclassified findings unchanged rather than dropping them.
            tagged_findings.append(f)
            continue

        key = _match_key(f)
        if key in seen_current_keys:
            continue
        seen_current_keys.add(key)

        if key in baseline_keys:
            status = "existing"
            existing_count += 1
        else:
            status = "new"
            new_count += 1

        # Tag in properties.baseline_status — do not mutate the original dict.
        tagged = dict(f)
        props = dict(tagged.get("properties") or {})
        props["baseline_status"] = status
        tagged["properties"] = props
        tagged_findings.append(tagged)

    # ------------------------------------------------------------------
    # Compute resolved: baseline keys absent from current
    # ------------------------------------------------------------------
    current_keys: set = set()
    for f in current_findings:
        if f.get("rule_id") and f.get("fingerprint"):
            current_keys.add(_match_key(f))

    # Build resolved records from baseline findings in baseline order.
    resolved_records = []
    seen_resolved_keys: set = set()
    for f in baseline_findings:
        key = _match_key(f)
        if not key[0] or not key[1]:
            continue
        if key not in current_keys and key not in seen_resolved_keys:
            seen_resolved_keys.add(key)
            resolved_records.append({
                "type": "resolved",
                "rule_id": key[0],
                "fingerprint": key[1],
                "baseline": args.baseline,
            })

    resolved_count = len(resolved_records)

    # ------------------------------------------------------------------
    # Build summary record
    # ------------------------------------------------------------------
    summary = {
        "type": "baseline_summary",
        "new": new_count,
        "existing": existing_count,
        "resolved": resolved_count,
        "baseline": args.baseline,
    }

    # ------------------------------------------------------------------
    # Build output lines
    # ------------------------------------------------------------------
    output_lines = []

    # 1. Summary first
    output_lines.append(json.dumps(summary, separators=(",", ":")))

    # 2. Metadata records from current (provenance, coverage_gap, etc.)
    for rec in current_meta:
        output_lines.append(json.dumps(rec, separators=(",", ":")))

    # 3. Tagged findings — all, or new-only
    for f in tagged_findings:
        if args.new_only:
            status = (f.get("properties") or {}).get("baseline_status")
            if status == "existing":
                continue
        output_lines.append(json.dumps(f, separators=(",", ":")))

    # 4. Resolved records (omit when --new-only)
    if not args.new_only:
        for rec in resolved_records:
            output_lines.append(json.dumps(rec, separators=(",", ":")))

    # ------------------------------------------------------------------
    # Write output
    # ------------------------------------------------------------------
    out_text = "\n".join(output_lines)
    if output_lines:
        out_text += "\n"

    if args.output:
        out_path = Path(args.output)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            out_path.write_text(out_text, encoding="utf-8")
        except OSError as exc:
            print(
                f"baseline: ERROR: cannot write output '{args.output}': {exc}",
                file=sys.stderr,
            )
            return 1
        print(
            f"baseline: wrote {new_count} new, {existing_count} existing, "
            f"{resolved_count} resolved finding(s) to {args.output}",
            file=sys.stderr,
        )
    else:
        sys.stdout.write(out_text)

    # ------------------------------------------------------------------
    # Save baseline copy if requested
    # ------------------------------------------------------------------
    if args.save_baseline:
        save_path = Path(args.save_baseline)
        save_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            shutil.copy2(args.current, str(save_path))
        except OSError as exc:
            print(
                f"baseline: ERROR: cannot save baseline to '{args.save_baseline}': {exc}",
                file=sys.stderr,
            )
            return 1
        print(
            f"baseline: saved current findings as next baseline to {args.save_baseline}",
            file=sys.stderr,
        )

    return 0


if __name__ == "__main__":
    sys.exit(main())

skills/audit/scripts/suppress.py

#!/usr/bin/env python3
"""
CCGM /audit suppression applier (Epic 3.3).

Reads a JSONL findings file (merge-findings output) and applies suppression
rules from a .auditignore.yaml file and/or inline source-file comments.
Suppressed findings are KEPT in the output with a ``suppression`` field set;
they are never omitted.

Usage
-----
  suppress.py --findings <findings.jsonl>
              [--auditignore <path>]
              [--repo <root>]
              [--output <file>]
              [--today YYYY-MM-DD]

Arguments
---------
  --findings  <path>        Required. JSONL produced by merge-findings.py (or
                            baseline.py).
  --auditignore <path>      Path to .auditignore.yaml.  Default: <repo>/.auditignore.yaml
                            when that file exists, otherwis…

View raw (26574 bytes)

skills/audit/scripts/lint-rubric.py

#!/usr/bin/env python3
"""
lint-rubric.py — Validates schemas/severity-rubric.json structure and enums.

Checks:
  1. The file parses as valid JSON.
  2. Every entry under "checks" has exactly the required keys.
  3. All enum values are within the allowed sets.
  4. Orphan check-id gate: every check-id found in packs/**/pack.json has a rubric entry.

Exit codes:
  0 — all checks pass
  1 — validation failures found (details printed to stderr)

Usage:
  python3 lint-rubric.py [--rubric PATH] [--packs-dir PATH]

Defaults (relative to this script's parent directory, i.e. the audit skill root):
  --rubric    schemas/severity-rubric.json
  --packs-dir packs
"""

import argparse
import json
import sys
from pathlib import Path

VALID_SEVERITIES = {"critical", "high", "medium", "low", "info"}
VALID_CONFIDENCES = {"high", "medium", "low"}
REQUIRED_KEYS = {"severity", "confidence", "fix_confidence"}

# check_id must match the pattern from finding.schema.json: ^[a-z0-9_-]+/[a-z0-9_.-]+$
import re
CHECK_ID_RE = re.compile(r'^[a-z0-9_-]+/[a-z0-9_.-]+$')


def load_rubric(rubric_path: Path) -> dict:
    try:
        with open(rubric_path) as f:
            data = json.load(f)
    except FileNotFoundError:
        print(f"ERROR: rubric file not found: {rubric_path}", file=sys.stderr)
        sys.exit(1)
    except json.JSONDecodeError as e:
        print(f"ERROR: rubric file is not valid JSON: {e}", file=sys.stderr)
        sys.exit(1)
    return data


def validate_rubric(data: dict) -> list[str]:
    errors = []

    if not isinstance(data, dict):
        return ["rubric root must be a JSON object"]

    checks = data.get("checks")
    if checks is None:
        return ['rubric missing top-level "checks" key']
    if not isinstance(checks, dict):
        return ['"checks" must be a JSON object']

    for check_id, entry in checks.items():
        prefix = f"checks[{check_id!r}]"

        # Validate check_id format
        if not CHECK_ID_RE.match(check_id):
            errors.append(
                f"{prefix}: check_id does not match pattern ^[a-z0-9_-]+/[a-z0-9_.-]+$"
            )

        if not isinstance(entry, dict):
            errors.append(f"{prefix}: entry must be a JSON object, got {type(entry).__name__}")
            continue

        # Check required keys present
        missing = REQUIRED_KEYS - entry.keys()
        if missing:
            errors.append(f"{prefix}: missing required keys: {sorted(missing)}")

        extra = set(entry.keys()) - REQUIRED_KEYS
        if extra:
            errors.append(f"{prefix}: unexpected keys: {sorted(extra)}")

        # Validate enum values
        severity = entry.get("severity")
        if severity is not None and severity not in VALID_SEVERITIES:
            errors.append(
                f"{prefix}: severity={severity!r} not in {sorted(VALID_SEVERITIES)}"
            )

        confidence = entry.get("confidence")
        if confidence is not None and confidence not in VALID_CONFIDENCES:
            errors.append(
                f"{prefix}: confidence={confidence!r} not in {sorted(VALID_CONFIDENCES)}"
            )

        fix_confidence = entry.get("fix_confidence")
        if fix_confidence is not None and fix_confidence not in VALID_CONFIDENCES:
            errors.append(
                f"{prefix}: fix_confidence={fix_confidence!r} not in {sorted(VALID_CONFIDENCES)}"
            )

    return errors


def collect_pack_check_ids(packs_dir: Path) -> list[tuple[str, str]]:
    """Return list of (check_id, source_path) for every check in packs/**/pack.json.

    The orphan-check-id gate: every check-id shipped in a pack must have a rubric entry.
    With zero packs this returns an empty list and the gate passes trivially.
    Becomes meaningful as pack epics land.
    """
    results = []
    for pack_file in sorted(packs_dir.rglob("pack.json")):
        try:
            with open(pack_file) as f:
                pack = json.load(f)
        except (json.JSONDecodeError, OSError) as e:
            print(f"WARNING: could not read {pack_file}: {e}", file=sys.stderr)
            continue

        checks = pack.get("checks", [])
        if not isinstance(checks, list):
            continue
        for check in checks:
            if isinstance(check, dict):
                cid = check.get("check_id") or check.get("id")
            else:
                cid = str(check)
            if cid:
                results.append((cid, str(pack_file)))
    return results


def main() -> int:
    parser = argparse.ArgumentParser(description="Validate severity-rubric.json")
    parser.add_argument(
        "--rubric",
        default=None,
        help="Path to severity-rubric.json (default: <script_dir>/../schemas/severity-rubric.json)",
    )
    parser.add_argument(
        "--packs-dir",
        default=None,
        help="Path to packs/ directory (default: <script_dir>/../packs)",
    )
    args = parser.parse_args()

    script_dir = Path(__file__).parent
    skill_root = script_dir.parent

    rubric_path = Path(args.rubric) if args.rubric else skill_root / "schemas" / "severity-rubric.json"
    packs_dir = Path(args.packs_dir) if args.packs_dir else skill_root / "packs"

    data = load_rubric(rubric_path)
    errors = validate_rubric(data)

    rubric_ids = set(data.get("checks", {}).keys())

    # Orphan check-id gate
    pack_entries = collect_pack_check_ids(packs_dir)
    orphan_errors = []
    for check_id, source in pack_entries:
        if check_id not in rubric_ids:
            orphan_errors.append(
                f"ORPHAN check_id {check_id!r} (from {source}) has no rubric entry"
            )

    all_errors = errors + orphan_errors

    if not all_errors:
        check_count = len(rubric_ids)
        pack_count = len(pack_entries)
        print(
            f"OK: {check_count} rubric entries valid; "
            f"{pack_count} pack check-id(s) verified against rubric"
        )
        return 0

    for err in all_errors:
        print(f"ERROR: {err}", file=sys.stderr)
    print(
        f"\n{len(all_errors)} error(s) found in {rubric_path}",
        file=sys.stderr,
    )
    return 1


if __name__ == "__main__":
    sys.exit(main())

skills/audit/scripts/lint-pack.py

#!/usr/bin/env python3
"""
CCGM /audit pack linter.

For each packs/*/ directory (excluding _TEMPLATE), validates:
  1. pack.json conforms to pack.schema.json (via registry.py's validate_pack).
  2. checks.md exists and contains all required template sections.
  3. (Optional) If schemas/severity-rubric.json exists, every check-id in pack.json
     has an entry in the rubric. If the rubric is absent, this check is skipped with
     a note.

Exit codes:
  0  All packs pass.
  1  One or more packs have errors.

Usage:
  python3 scripts/lint-pack.py [--packs-dir PATH] [--rubric PATH]

  --packs-dir PATH   Override the packs directory (default: ../packs relative to script).
  --rubric PATH      Override the rubric path (default: ../schemas/severity-rubric.json).
"""

import argparse
import importlib.util
import json
import re
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Required sections that every checks.md must contain.
# Matched case-insensitively via regex against heading lines.
# ---------------------------------------------------------------------------
_REQUIRED_SECTIONS = [
    r"^##\s+Scope",
    r"^##\s+applies_when\s+Rationale",
    r"^##\s+Checks",
    r"^##\s+Quality\s+Checklist",
]

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _load_registry(scripts_dir: Path):
    """Import registry.py from the scripts directory and return the module."""
    registry_path = scripts_dir / "registry.py"
    if not registry_path.is_file():
        raise FileNotFoundError(f"registry.py not found at {registry_path}")
    spec = importlib.util.spec_from_file_location("registry", str(registry_path))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _load_rubric(rubric_path: Path):
    """
    Load the severity rubric if it exists.

    Returns:
        (dict, None)  — rubric loaded as {check_id: entry, ...}
        (None, str)   — rubric absent; second element is a human-readable note
    """
    if not rubric_path.exists():
        return None, f"severity-rubric.json not found at {rubric_path} (Epic 1.5 pending); rubric check skipped"
    try:
        with open(rubric_path, encoding="utf-8") as fh:
            raw = json.load(fh)
    except json.JSONDecodeError as exc:
        return None, f"severity-rubric.json is not valid JSON: {exc}; rubric check skipped"

    # Rubric is expected to be a list of entries each with an "id" field,
    # or a dict keyed by check-id. Accept both.
    if isinstance(raw, list):
        rubric = {entry["id"]: entry for entry in raw if isinstance(entry, dict) and "id" in entry}
    elif isinstance(raw, dict):
        # Could be {"checks": [...]} envelope, {"checks": {id: entry, ...}} envelope,
        # or a flat {id: entry} map. Accept all three.
        if "checks" in raw and isinstance(raw["checks"], list):
            rubric = {e["id"]: e for e in raw["checks"] if isinstance(e, dict) and "id" in e}
        elif "checks" in raw and isinstance(raw["checks"], dict):
            rubric = raw["checks"]
        else:
            rubric = raw
    else:
        return None, f"Unexpected rubric shape (not list or dict); rubric check skipped"

    return rubric, None


def _check_required_sections(checks_md_path: Path) -> list:
    """
    Return a list of error strings for any required section missing from checks.md.
    """
    try:
        text = checks_md_path.read_text(encoding="utf-8")
    except OSError as exc:
        return [f"checks.md: cannot read: {exc}"]

    lines = text.splitlines()
    errors = []
    for pattern in _REQUIRED_SECTIONS:
        compiled = re.compile(pattern, re.IGNORECASE)
        if not any(compiled.match(line) for line in lines):
            # Extract the human-readable section name from the pattern.
            section_name = re.sub(r"\\s\+", " ", pattern).lstrip(r"^#\s+").rstrip("$")
            errors.append(f"checks.md: missing required section matching '{pattern}'")
    return errors


# ---------------------------------------------------------------------------
# Per-pack linting
# ---------------------------------------------------------------------------

def lint_pack(pack_dir: Path, registry_mod, rubric, rubric_note: str) -> list:
    """
    Lint a single pack directory. Returns a (possibly empty) list of error strings.
    """
    errors = []

    # ---- 1. pack.json schema validation ----
    pack_json_path = pack_dir / "pack.json"
    if not pack_json_path.is_file():
        errors.append("pack.json: missing")
        # Without pack.json, skip checks that depend on its contents.
        # Still validate checks.md structure below.
    else:
        try:
            with open(pack_json_path, encoding="utf-8") as fh:
                pack = json.load(fh)
        except json.JSONDecodeError as exc:
            errors.append(f"pack.json: invalid JSON: {exc}")
            pack = None

        if pack is not None:
            try:
                registry_mod.validate_pack(pack, str(pack_json_path))
            except registry_mod.ValidationError as exc:
                errors.append(f"pack.json: schema validation failed: {exc}")

            # Collect check-ids for rubric cross-check (done below).
            check_ids = [c["id"] for c in pack.get("checks", []) if isinstance(c, dict) and "id" in c]
        else:
            check_ids = []

    # ---- 2. checks.md required sections ----
    checks_md_path = pack_dir / "checks.md"
    if not checks_md_path.is_file():
        errors.append("checks.md: missing")
    else:
        section_errors = _check_required_sections(checks_md_path)
        errors.extend(section_errors)

    # ---- 3. Rubric membership (optional — only when rubric is present) ----
    if rubric is None:
        # Note (not an error): emit once per pack so output is clear.
        # Callers handle the note separately; skip here.
        pass
    elif pack_json_path.is_file() and pack is not None:
        for cid in check_ids:
            if cid not in rubric:
                errors.append(
                    f"pack.json: check '{cid}' has no entry in severity-rubric.json"
                )

    return errors


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Lint /audit check packs for schema conformance and template completeness."
    )
    parser.add_argument(
        "--packs-dir",
        default=None,
        help="Path to the packs/ directory. Default: ../packs relative to this script.",
    )
    parser.add_argument(
        "--rubric",
        default=None,
        help="Path to severity-rubric.json. Default: ../schemas/severity-rubric.json. "
             "If absent, rubric membership check is skipped.",
    )
    args = parser.parse_args(argv)

    script_dir = Path(__file__).parent.resolve()
    audit_dir = script_dir.parent

    packs_dir = Path(args.packs_dir) if args.packs_dir else audit_dir / "packs"
    rubric_path = Path(args.rubric) if args.rubric else audit_dir / "schemas" / "severity-rubric.json"

    # Load registry module (for validate_pack + ValidationError).
    try:
        registry_mod = _load_registry(script_dir)
    except FileNotFoundError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1

    # Load rubric (optional).
    rubric, rubric_note = _load_rubric(rubric_path)
    if rubric_note:
        print(f"NOTE: {rubric_note}")

    # Discover pack directories (exclude _TEMPLATE).
    if not packs_dir.is_dir():
        print(f"NOTE: packs directory not found at {packs_dir}; nothing to lint.")
        return 0

    pack_dirs = sorted(
        d for d in packs_dir.iterdir()
        if d.is_dir() and d.name != "_TEMPLATE"
    )

    if not pack_dirs:
        print(f"NOTE: no packs found under {packs_dir} (excluding _TEMPLATE); nothing to lint.")
        return 0

    # Lint each pack.
    total_errors = 0
    for pack_dir in pack_dirs:
        errors = lint_pack(pack_dir, registry_mod, rubric, rubric_note)
        if errors:
            print(f"\nFAIL: {pack_dir.name}")
            for err in errors:
                print(f"  ERROR: {err}")
            total_errors += len(errors)
        else:
            print(f"PASS: {pack_dir.name}")

    # Summary.
    print(f"\n{len(pack_dirs)} pack(s) checked, {total_errors} error(s).")
    return 0 if total_errors == 0 else 1


if __name__ == "__main__":
    sys.exit(main())

skills/audit/scripts/provenance.py

#!/usr/bin/env python3
"""
CCGM /audit provenance.py (Epic 3.4) — STDLIB ONLY.

Three capabilities in one script:

1. HEADER  — emit an audit_provenance record (type: "audit_provenance") with:
     commit           base SHA the audit ran against. Prefer --commit (pinned by
                      the coordinator at spine time); falls back to live
                      git -C <repo> rev-parse HEAD only when --commit is absent.
     rubric_version   version field from severity-rubric.json
     skill_version    version from the audit module.json (or DEFAULT_SKILL_VERSION)
     tool_versions    map of installed spine tools -> version string (absent tools omitted)
     model            placeholder; fill from --model / AUDIT_MODEL env var (default "unknown")
     optional_checks_ran  list (empty b…

View raw (25129 bytes)

skills/audit/scripts/spine/run.sh

#!/usr/bin/env bash
# CCGM audit spine -- deterministic tool runner
#
# Runs each wrapped tool against the target repository, normalizes output
# to finding.schema.json JSONL, and aggregates all findings + coverage-gap
# notes to stdout.
#
# Usage:
#   run.sh [--repo <abs_path>] [--tools <comma_list>] [--output <file>]
#
#   --repo  <path>   Absolute path to the repo root (default: cwd)
#   --tools <list>   Comma-separated subset of tools to run (default: all)
#                    Valid: gitleaks,semgrep,dep-audit,knip,eslint,
#                           govulncheck,bandit,hadolint,actionlint,trivy,
#                           zizmor,pinact,squawk,sqlfluff,checkov,
#                           pip-audit,cargo-audit,bundler-audit
#   --output <file>  Write aggregated JSONL to this file instead of stdout
#
# Output: JSONL -- one JSON object per line, either a finding or a note.
# Exit code: always 0 (individual tool failures become coverage-gap notes).
#
# Safety guarantees (ss3.7):
#   - No repo-derived value is interpolated into a shell string.
#     Paths are passed as argv to wrappers; wrappers pass them as argv to tools.
#   - All wrappers are shellcheck-clean.
#   - Config isolation: each wrapper enforces tool-specific isolation flags.
#   - Secret values are redacted (first-4+length) before appearing in output.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
REPO_ROOT=""
REQUESTED_TOOLS="gitleaks,semgrep,dep-audit,knip,eslint,govulncheck,bandit,hadolint,actionlint,trivy,zizmor,pinact,squawk,sqlfluff,checkov,pip-audit,cargo-audit,bundler-audit"
OUTPUT_FILE=""

# ---------------------------------------------------------------------------
# Argument parsing (no eval, no indirect expansion of user-supplied values)
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
  case "$1" in
    --repo)
      REPO_ROOT="$2"
      shift 2
      ;;
    --tools)
      REQUESTED_TOOLS="$2"
      shift 2
      ;;
    --output)
      OUTPUT_FILE="$2"
      shift 2
      ;;
    *)
      printf 'Unknown argument: %s\n' "$1" >&2
      exit 1
      ;;
  esac
done

# Default repo root to current directory
if [[ -z "$REPO_ROOT" ]]; then
  REPO_ROOT="$(pwd)"
fi

# Validate repo root exists (safety: do not run against a path that doesn't exist)
if [[ ! -d "$REPO_ROOT" ]]; then
  printf 'ERROR: repo root does not exist: %s\n' "$REPO_ROOT" >&2
  exit 1
fi

# ---------------------------------------------------------------------------
# Tool registry -- ordered list of (tool_name, wrapper_script) pairs.
# Paths are absolute, never derived from user input.
# Bash-3.2-portable: case dispatch instead of associative array
# (declare -A requires bash 4+).
# ---------------------------------------------------------------------------

# Ordered execution list (stable, deterministic)
TOOL_ORDER=(gitleaks semgrep dep-audit knip eslint govulncheck bandit hadolint actionlint trivy zizmor pinact squawk sqlfluff checkov pip-audit cargo-audit bundler-audit)

# Resolve wrapper path for a given tool name.
# Sets WRAPPER to the script path, or empty string if unknown.
_get_wrapper() {
  local _tool="$1"
  case "$_tool" in
    gitleaks)       WRAPPER="$SCRIPT_DIR/wrap-gitleaks.sh" ;;
    semgrep)        WRAPPER="$SCRIPT_DIR/wrap-semgrep.sh" ;;
    dep-audit)      WRAPPER="$SCRIPT_DIR/wrap-dep-audit.sh" ;;
    knip)           WRAPPER="$SCRIPT_DIR/wrap-knip.sh" ;;
    eslint)         WRAPPER="$SCRIPT_DIR/wrap-eslint.sh" ;;
    govulncheck)    WRAPPER="$SCRIPT_DIR/wrap-govulncheck.sh" ;;
    bandit)         WRAPPER="$SCRIPT_DIR/wrap-bandit.sh" ;;
    hadolint)       WRAPPER="$SCRIPT_DIR/wrap-hadolint.sh" ;;
    actionlint)     WRAPPER="$SCRIPT_DIR/wrap-actionlint.sh" ;;
    trivy)          WRAPPER="$SCRIPT_DIR/wrap-trivy.sh" ;;
    zizmor)         WRAPPER="$SCRIPT_DIR/wrap-zizmor.sh" ;;
    pinact)         WRAPPER="$SCRIPT_DIR/wrap-pinact.sh" ;;
    squawk)         WRAPPER="$SCRIPT_DIR/wrap-squawk.sh" ;;
    sqlfluff)       WRAPPER="$SCRIPT_DIR/wrap-sqlfluff.sh" ;;
    checkov)        WRAPPER="$SCRIPT_DIR/wrap-checkov.sh" ;;
    pip-audit)      WRAPPER="$SCRIPT_DIR/wrap-pip-audit.sh" ;;
    cargo-audit)    WRAPPER="$SCRIPT_DIR/wrap-cargo-audit.sh" ;;
    bundler-audit)  WRAPPER="$SCRIPT_DIR/wrap-bundler-audit.sh" ;;
    *)              WRAPPER="" ;;
  esac
}

# ---------------------------------------------------------------------------
# Parse requested tools into a colon-delimited string for membership testing.
# Bash-3.2-portable: no associative arrays (declare -A requires bash 4+).
# Membership test pattern: case ":$_REQUESTED_CSV_NORM:" in *":$TOOL:"*) ...
# ---------------------------------------------------------------------------
_REQUESTED_CSV_NORM=""
IFS=',' read -ra _REQ_TOOLS <<< "$REQUESTED_TOOLS"
for _t in "${_REQ_TOOLS[@]}"; do
  _t="${_t// /}"  # strip spaces
  _REQUESTED_CSV_NORM="${_REQUESTED_CSV_NORM}:${_t}"
done
_REQUESTED_CSV_NORM="${_REQUESTED_CSV_NORM}:"  # trailing colon for uniform matching

# ---------------------------------------------------------------------------
# Aggregation: always collect to a temp file so the coordinator-side
# junk-path post-filter (exclude.py --filter) can run before output, then
# emit to --output or stdout.  Buffering the stdout path is functionally
# identical for callers that capture the output.
# ---------------------------------------------------------------------------
AGTMPFILE="$(mktemp /tmp/ccgm-spine-agg-XXXXXX.jsonl)"
FILTEREDFILE="$(mktemp /tmp/ccgm-spine-filtered-XXXXXX.jsonl)"
TOOLTMPFILE="$(mktemp /tmp/ccgm-spine-tool-XXXXXX.jsonl)"
trap 'rm -f "$AGTMPFILE" "$FILTEREDFILE" "$TOOLTMPFILE"' EXIT
SINK="$AGTMPFILE"

# ---------------------------------------------------------------------------
# Emit a provenance header record
# Use python3 json.dumps so repo paths / tool names containing ", \, or
# newlines produce valid JSONL instead of malformed output.
# ---------------------------------------------------------------------------
python3 - "$REPO_ROOT" "$REQUESTED_TOOLS" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" << 'PYEOF' >> "$SINK"
import json, sys
repo, tools, ts = sys.argv[1], sys.argv[2], sys.argv[3]
print(json.dumps({
    "type": "provenance",
    "tool": "ccgm-spine",
    "version": "1.0",
    "repo": repo,
    "tools_requested": tools,
    "timestamp": ts,
}))
PYEOF

# ---------------------------------------------------------------------------
# Run each tool wrapper in order
# ---------------------------------------------------------------------------
for TOOL in "${TOOL_ORDER[@]}"; do
  # Skip tools not in the requested set
  # Bash-3.2-portable membership test via case pattern on colon-delimited string.
  # _REQUESTED_CSV_NORM has the form ":tool1:tool2:...:toolN:" (leading+trailing colon).
  case "${_REQUESTED_CSV_NORM}" in
    *":${TOOL}:"*) ;;  # present -- fall through
    *) continue ;;     # not requested -- skip
  esac

  _get_wrapper "$TOOL"
  if [[ -z "$WRAPPER" || ! -f "$WRAPPER" ]]; then
    printf '{"type":"coverage_gap","tool":"%s","check_id":"spine/missing-wrapper","description":"wrapper script not found: %s"}\n' \
      "$TOOL" "$WRAPPER" >> "$SINK"
    continue
  fi

  # Run wrapper -- REPO_ROOT is passed as a positional argv element,
  # never interpolated into a shell string.  Capture to a per-tool temp so we
  # can report per-tool timing + finding count to stderr (#6: a 22-min stall
  # is now visible immediately instead of looking hung), then append to SINK.
  : > "$TOOLTMPFILE"
  SECONDS=0
  set +e
  bash "$WRAPPER" "$REPO_ROOT" > "$TOOLTMPFILE" 2>/dev/null
  WRAPPER_EXIT=$?
  set -e
  TOOL_ELAPSED=$SECONDS

  # Findings have no "type" field; notes (skipped/coverage_gap) do.
  TOOL_TOTAL=$(grep -cv '^[[:space:]]*$' "$TOOLTMPFILE" 2>/dev/null || true)
  TOOL_NOTES=$(grep -c '"type":' "$TOOLTMPFILE" 2>/dev/null || true)
  [[ -z "$TOOL_TOTAL" ]] && TOOL_TOTAL=0
  [[ -z "$TOOL_NOTES" ]] && TOOL_NOTES=0
  TOOL_FINDINGS=$((TOOL_TOTAL - TOOL_NOTES))
  [[ $TOOL_FINDINGS -lt 0 ]] && TOOL_FINDINGS=0

  cat "$TOOLTMPFILE" >> "$SINK"
  printf 'spine: %-13s %5d finding(s) in %3ds\n' "$TOOL" "$TOOL_FINDINGS" "$TOOL_ELAPSED" >&2

  if [[ $WRAPPER_EXIT -ne 0 ]]; then
    printf '{"type":"coverage_gap","tool":"%s","check_id":"spine/wrapper-error","description":"wrapper exited with code %d"}\n' \
      "$TOOL" "$WRAPPER_EXIT" >> "$SINK"
  fi
done

# ---------------------------------------------------------------------------
# Coordinator-side junk-path post-filter (#1, defense-in-depth).
# Drops any FINDING whose location.path contains an excluded segment
# (node_modules, stale .claude/worktrees, .audit, dist, ...), matches an
# excluded file glob (*.min.js), or -- via REPO_ROOT -- is gitignored.
# Provenance and coverage_gap notes pass through untouched.  This is the
# correctness backstop for anything a tool's own ignore logic missed; the
# per-wrapper exclusion flags are the performance half (the tools never scan
# those paths).
# ---------------------------------------------------------------------------
# The filter prints its drop/keep summary to stderr (observability, #6).
if python3 "$SCRIPT_DIR/exclude.py" --filter "$AGTMPFILE" "$FILTEREDFILE" "$REPO_ROOT"; then
  EMIT_FILE="$FILTEREDFILE"
else
  # Filter unavailable -- emit unfiltered rather than lose data.
  EMIT_FILE="$AGTMPFILE"
fi

# ---------------------------------------------------------------------------
# Emit: --output destination, or stdout.
# ---------------------------------------------------------------------------
if [[ -n "$OUTPUT_FILE" ]]; then
  cp "$EMIT_FILE" "$OUTPUT_FILE"
  printf 'Spine complete. Findings written to: %s\n' "$OUTPUT_FILE" >&2
else
  cat "$EMIT_FILE"
fi

skills/audit/scripts/spine/exclude.sh

#!/usr/bin/env bash
# CCGM audit spine -- shared path-exclusion helpers (SOURCED, not executed).
#
# Reads the canonical excluded-dir list (exclude-dirs.txt) into the array
# CCGM_EXCLUDE_DIRS and provides functions that render that list into each
# file-walking tool's own flag dialect.  This is the PERFORMANCE half of the
# #1 fix: tools never scan node_modules / stale worktrees in the first place.
# exclude.py is the correctness backstop (post-filter) for anything that slips.
#
# Bash-3.2-portable: no associative arrays, no namerefs, no mapfile -d.
# Functions that must "return" an array populate a caller-named array via the
# conventional `eval`-free pattern of printing nothing and assigning a global.
#
# Usage in a wrapper:
#   SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
#   # shellcheck source=exclude.sh
#   . "$SCRIPT_DIR/exclude.sh"
#   ccgm_eslint_ignore_args        # populates CCGM_FLAGS=(--ignore-pattern ... )
#   eslint "${CCGM_FLAGS[@]}" ...

# Resolve our own directory even when sourced.
_CCGM_EXCLUDE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_CCGM_EXCLUDE_LIST="$_CCGM_EXCLUDE_DIR/exclude-dirs.txt"
_CCGM_EXCLUDE_GLOBS="$_CCGM_EXCLUDE_DIR/exclude-file-globs.txt"

# Read a one-entry-per-line list file into the array named by $1 (comments and
# blank lines stripped). Bash-3.2-portable (no namerefs): appends via eval-free
# `read -r` loop targeting a temporary, then assigns the caller's array.
_ccgm_read_list() {
  local _file="$1"
  CCGM_LIST_RESULT=()
  [[ -f "$_file" ]] || return 0
  local _line
  while read -r _line || [[ -n "$_line" ]]; do
    _line="${_line%%#*}"                          # strip comments
    _line="${_line#"${_line%%[![:space:]]*}"}"    # ltrim
    _line="${_line%"${_line##*[![:space:]]}"}"    # rtrim
    [[ -z "$_line" ]] && continue
    CCGM_LIST_RESULT+=("$_line")
  done < "$_file"
}

# Canonical excluded directory NAMES.
# The length guard keeps the empty-array assignment nounset-safe on bash 3.2,
# where "${arr[@]}" on an empty array errors under `set -u`.
CCGM_EXCLUDE_DIRS=()
_ccgm_read_list "$_CCGM_EXCLUDE_LIST"
if [[ ${#CCGM_LIST_RESULT[@]} -gt 0 ]]; then
  CCGM_EXCLUDE_DIRS=("${CCGM_LIST_RESULT[@]}")
else
  # Fallback (mirrors exclude.py): never leave the dir list empty, so the
  # unguarded `for d in "${CCGM_EXCLUDE_DIRS[@]}"` loops below stay nounset-safe
  # on bash 3.2 and exclusion degrades gracefully if the list file is missing.
  CCGM_EXCLUDE_DIRS=(node_modules .git .claude .audit dist build)
fi

# Canonical excluded file-name GLOBS (e.g. *.min.js) -- caught by basename
# regardless of directory, so a committed client/public/js-dos/foo.min.js is
# excluded even though none of its path segments is an excluded dir name.
CCGM_EXCLUDE_FILE_GLOBS=()
_ccgm_read_list "$_CCGM_EXCLUDE_GLOBS"
if [[ ${#CCGM_LIST_RESULT[@]} -gt 0 ]]; then
  CCGM_EXCLUDE_FILE_GLOBS=("${CCGM_LIST_RESULT[@]}")
fi

# Each function below sets the global array CCGM_FLAGS to the tool-specific
# exclusion flags.  Callers read CCGM_FLAGS immediately after calling.

# eslint: --ignore-pattern globs (work with --no-config-lookup).  Match both
# top-level (<dir>/**) and nested (**/<dir>/**) occurrences, plus file globs
# (**/*.min.js) so vendored minified files outside an excluded dir are skipped.
ccgm_eslint_ignore_args() {
  CCGM_FLAGS=()
  local d g
  for d in "${CCGM_EXCLUDE_DIRS[@]}"; do
    CCGM_FLAGS+=(--ignore-pattern "$d/**" --ignore-pattern "**/$d/**")
  done
  if [[ ${#CCGM_EXCLUDE_FILE_GLOBS[@]} -gt 0 ]]; then
    for g in "${CCGM_EXCLUDE_FILE_GLOBS[@]}"; do
      CCGM_FLAGS+=(--ignore-pattern "**/$g")
    done
  fi
}

# bandit: -x / --exclude takes one comma-separated list of path globs.
# Sets CCGM_BANDIT_EXCLUDE to that comma list (empty if no dirs).
ccgm_bandit_exclude_csv() {
  CCGM_BANDIT_EXCLUDE=""
  local d
  for d in "${CCGM_EXCLUDE_DIRS[@]}"; do
    if [[ -z "$CCGM_BANDIT_EXCLUDE" ]]; then
      CCGM_BANDIT_EXCLUDE="*/$d/*"
    else
      CCGM_BANDIT_EXCLUDE="$CCGM_BANDIT_EXCLUDE,*/$d/*"
    fi
  done
}

# trivy: --skip-dirs takes a doublestar glob, repeatable; --skip-files for the
# vendored minified file globs.
ccgm_trivy_skip_args() {
  CCGM_FLAGS=()
  local d g
  for d in "${CCGM_EXCLUDE_DIRS[@]}"; do
    CCGM_FLAGS+=(--skip-dirs "**/$d/**" --skip-dirs "$d/**")
  done
  if [[ ${#CCGM_EXCLUDE_FILE_GLOBS[@]} -gt 0 ]]; then
    for g in "${CCGM_EXCLUDE_FILE_GLOBS[@]}"; do
      CCGM_FLAGS+=(--skip-files "**/$g")
    done
  fi
}

# checkov: --skip-path takes a regex, repeatable.
ccgm_checkov_skip_args() {
  CCGM_FLAGS=()
  local d esc
  for d in "${CCGM_EXCLUDE_DIRS[@]}"; do
    esc="${d//./\\.}"
    CCGM_FLAGS+=(--skip-path "(^|/)$esc(/|$)")
  done
}

# semgrep: --exclude takes a path/basename glob, repeatable (dirs + file globs).
ccgm_semgrep_exclude_args() {
  CCGM_FLAGS=()
  local d g
  for d in "${CCGM_EXCLUDE_DIRS[@]}"; do
    CCGM_FLAGS+=(--exclude "$d")
  done
  if [[ ${#CCGM_EXCLUDE_FILE_GLOBS[@]} -gt 0 ]]; then
    for g in "${CCGM_EXCLUDE_FILE_GLOBS[@]}"; do
      CCGM_FLAGS+=(--exclude "$g")
    done
  fi
}

# find(1): prune predicate elements for find-based wrappers.  Emits
#   -name <dir> -prune -o
# pairs into CCGM_FIND_PRUNE so a wrapper can splice them into its find call:
#   find "$root" \( CCGM_FIND_PRUNE -false \) -o -type f ... -print0
ccgm_find_prune_args() {
  CCGM_FIND_PRUNE=()
  local d first=1
  for d in "${CCGM_EXCLUDE_DIRS[@]}"; do
    if [[ $first -eq 1 ]]; then
      first=0
    else
      CCGM_FIND_PRUNE+=(-o)
    fi
    CCGM_FIND_PRUNE+=(-name "$d")
  done
}

skills/audit/scripts/spine/exclude.py

#!/usr/bin/env python3
"""
CCGM audit spine -- shared path-exclusion helpers (STDLIB ONLY).

Single source of truth:
  - excluded directory NAMES   -> `exclude-dirs.txt`      (same directory)
  - excluded file-name GLOBS   -> `exclude-file-globs.txt` (same directory)

This module reads those files and provides:

  Library:
    load_excluded_dirs()       -> list[str]   canonical excluded dir names
    load_excluded_file_globs() -> list[str]   canonical excluded file globs
    path_is_excluded(path)     -> bool        True if any path SEGMENT is an
                                              excluded dir OR the basename
                                              matches an excluded file glob

  CLI:
    exclude.py --gitleaks-config <out> [<repo>]
        Write a gitleaks v8 config that keeps the default ruleset
        ([extend] useDefault = true) but allowlists, by path regex:
          - every excluded dir + file glob (vendored/generated/worktree), and
          - when <repo> is a git repo, every GITIGNORED path. A "leaked
            credential" describes committed/tracked content; a gitignored,
            never-committed file like .env.local must not be reported as one.

    exclude.py --filter <in.jsonl> <out.jsonl> [<repo>]
        Coordinator-side junk-path post-filter (defense-in-depth, #1).
        Drops FINDING records whose location.path contains an excluded
        segment, matches an excluded file glob, or -- when <repo> is given --
        is gitignored. Passes through every record with a "type" field
        (provenance, coverage_gap, skipped) untouched. Prints a one-line
        summary to stderr: "filter-excluded: dropped N junk-path finding(s)".

The wrappers that walk the filesystem also apply per-tool exclusion flags
(built from the same lists by exclude.sh) so the tools never SCAN the junk
paths in the first place -- that is the performance fix. This filter is the
correctness backstop that catches anything a tool's own ignore logic misses.
"""

import fnmatch
import json
import os
import re
import subprocess
import sys

_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_EXCLUDE_LIST = os.path.join(_SCRIPT_DIR, "exclude-dirs.txt")
_EXCLUDE_GLOBS = os.path.join(_SCRIPT_DIR, "exclude-file-globs.txt")


def _read_list(path):
    """Read a list file (one entry per line; # comments and blanks ignored)."""
    out = []
    try:
        with open(path, encoding="utf-8") as fh:
            for raw in fh:
                line = raw.split("#", 1)[0].strip()
                if line:
                    out.append(line)
    except OSError:
        return None
    return out


def load_excluded_dirs():
    """Return the canonical excluded directory names from exclude-dirs.txt."""
    dirs = _read_list(_EXCLUDE_LIST)
    if dirs is None:
        # Fall back to a hard-coded minimal set so the filter never silently
        # becomes a no-op if the list file is missing.
        dirs = ["node_modules", ".git", ".claude", ".audit", "dist", "build"]
    return dirs


def load_excluded_file_globs():
    """Return the canonical excluded file globs from exclude-file-globs.txt."""
    globs = _read_list(_EXCLUDE_GLOBS)
    if globs is None:
        globs = ["*.min.js", "*.min.css", "*.bundle.js"]
    return globs


def _build_segment_matcher(dirs):
    """Compile a regex matching any path that contains an excluded segment."""
    alts = "|".join(re.escape(d) for d in dirs)
    # A segment is bounded by start/slash on the left and slash/end on the right.
    return re.compile(r"(^|/)(" + alts + r")(/|$)")


_EXCLUDED_DIRS = load_excluded_dirs()
_EXCLUDED_FILE_GLOBS = load_excluded_file_globs()
_SEGMENT_RE = _build_segment_matcher(_EXCLUDED_DIRS)


def path_is_excluded(path):
    """True if any segment of `path` is an excluded directory name, or the
    basename matches an excluded file glob."""
    if not path:
        return False
    normalized = str(path).replace("\\", "/")
    if _SEGMENT_RE.search(normalized):
        return True
    base = normalized.rsplit("/", 1)[-1]
    for glob in _EXCLUDED_FILE_GLOBS:
        if fnmatch.fnmatch(base, glob):
            return True
    return False


# ---------------------------------------------------------------------------
# Gitignore awareness
# ---------------------------------------------------------------------------

def gitignored_entries(repo):
    """Return repo-relative gitignored paths, or [] when `repo` is not a git
    repo / git is unavailable.

    Uses `--directory` so a fully-ignored directory collapses to a single
    entry (e.g. "node_modules/") instead of every file underneath it -- keeps
    the list small on real repos. Directory entries keep their trailing slash.
    """
    if not repo or not os.path.isdir(repo):
        return []
    try:
        proc = subprocess.run(
            [
                "git", "-C", repo, "ls-files", "-z",
                "--others", "--ignored", "--exclude-standard", "--directory",
                "--", ".",
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            check=False,
        )
    except (OSError, ValueError):
        return []
    if proc.returncode != 0 or not proc.stdout:
        return []
    raw = proc.stdout.decode("utf-8", "replace")
    return [e for e in raw.split("\0") if e]


# ---------------------------------------------------------------------------
# Looks-minified heuristic
# ---------------------------------------------------------------------------

# A line longer than this in the file's leading bytes marks it as machine-
# generated/minified. Real hand-written source rarely exceeds a few hundred
# chars; vendored bundles like js-dos run to 100k+ char lines.
_MINIFIED_MAX_LINE = 1000
_MINIFIED_READ_BYTES = 262144  # inspect only the first 256 KB


def looks_minified(abs_path, cache):
    """True if the file at abs_path looks machine-minified (a very long line in
    its leading bytes). Memoized via the caller-supplied `cache` dict so a file
    with hundreds of findings is read once."""
    if abs_path in cache:
        return cache[abs_path]
    result = False
    try:
        with open(abs_path, encoding="utf-8", errors="replace") as fh:
            chunk = fh.read(_MINIFIED_READ_BYTES)
        for line in chunk.split("\n"):
            if len(line) >= _MINIFIED_MAX_LINE:
                result = True
                break
    except OSError:
        result = False
    cache[abs_path] = result
    return result


def _path_is_gitignored(path, entries):
    """True if `path` equals or sits under any gitignored entry."""
    if not path:
        return False
    p = str(path).replace("\\", "/").lstrip("./")
    for e in entries:
        clean = e.rstrip("/")
        if not clean:
            continue
        if e.endswith("/"):
            if p == clean or p.startswith(clean + "/"):
                return True
        elif p == clean:
            return True
    return False


# ---------------------------------------------------------------------------
# CLI: gitleaks config
# ---------------------------------------------------------------------------

def _regex_escape_path(path):
    """Escape a repo-relative path into a literal RE2 sub-pattern (slashes kept)."""
    return re.sub(r"([.^$*+?()\[\]{}|\\])", r"\\\1", path)


def _glob_to_basename_regex(glob):
    """Convert a simple basename glob (e.g. *.min.js) into an RE2 sub-pattern.
    `*` -> any run of non-slash chars, `?` -> one non-slash char, all else
    literal. Avoids fnmatch.translate, whose (?s:...)\\Z wrapper is not a
    valid embeddable RE2 fragment."""
    out = []
    for ch in glob:
        if ch == "*":
            out.append("[^/]*")
        elif ch == "?":
            out.append("[^/]")
        else:
            out.append(re.escape(ch))
    return "".join(out)


def _gitleaks_allowlist_paths(repo):
    """Build the list of allowlist path regexes for the gitleaks config."""
    paths = []
    # Excluded directories: match the dir anywhere in the path.
    for d in load_excluded_dirs():
        paths.append("(^|/){0}(/|$)".format(_regex_escape_path(d)))
    # Excluded file globs: match the basename at the end of the path.
    for glob in load_excluded_file_globs():
        paths.append("(^|/){0}$".format(_glob_to_basename_regex(glob)))
    # Gitignored paths: a leaked credential must be committed/tracked, never a
    # gitignored local file (e.g. .env.local). Skip ones already covered above.
    for e in gitignored_entries(repo):
        clean = e.rstrip("/")
        if not clean or path_is_excluded(clean):
            continue
        esc = _regex_escape_path(clean)
        if e.endswith("/"):
            paths.append("(^|/){0}(/|$)".format(esc))
        else:
            paths.append("(^|/){0}$".format(esc))
    return paths


def _write_gitleaks_config(out_path, repo=None):
    lines = [
        "# Auto-generated by exclude.py from exclude-dirs.txt + exclude-file-globs.txt.",
        "# Do not edit by hand. Keeps gitleaks' default ruleset; allowlists",
        "# vendored/generated/worktree paths and gitignored (never-committed) files.",
        "[extend]",
        "useDefault = true",
        "",
        "[allowlist]",
        'description = "skip vendored/generated/worktree/gitignored paths (CCGM audit spine)"',
        "paths = [",
    ]
    for pat in _gitleaks_allowlist_paths(repo):
        # Triple-quoted TOML string so backslashes are literal (no escaping).
        lines.append("    '''{0}''',".format(pat))
    lines.append("]")
    with open(out_path, "w", encoding="utf-8") as fh:
        fh.write("\n".join(lines) + "\n")


# ---------------------------------------------------------------------------
# CLI: JSONL junk-path filter
# ---------------------------------------------------------------------------

def _filter_jsonl(in_path, out_path, repo=None):
    dropped = 0
    kept = 0
    entries = gitignored_entries(repo) if repo else []
    minified_cache = {}
    with open(in_path, encoding="utf-8") as fin, \
            open(out_path, "w", encoding="utf-8") as fout:
        for raw in fin:
            stripped = raw.strip()
            if not stripped:
                continue
            try:
                rec = json.loads(stripped)
            except json.JSONDecodeError:
                # Pass through malformed lines untouched -- not our job to drop.
                fout.write(stripped + "\n")
                continue
            # Records with a "type" field (provenance, coverage_gap, skipped)
            # always pass through; they have no location to judge.
            if isinstance(rec, dict) and "type" not in rec:
                path = (rec.get("location") or {}).get("path", "")
                drop = path_is_excluded(path) or _path_is_gitignored(path, entries)
                # Looks-minified backstop: drop findings on vendored/minified files
                # that no name/dir rule caught (e.g. client/public/js-dos/js-dos.js
                # -- minified but not *.min.js, not in an excluded dir). Minified
                # bundles are generated/vendored, not authored source, so lint and
                # code-pattern (SAST) findings there are noise. SECRETS are kept: a
                # committed credential is actionable regardless of minification.
                # Needs repo to resolve the relative path to file content.
                if not drop and repo and path \
                        and not str(rec.get("check_id", "")).startswith("secrets/"):
                    abs_path = os.path.join(repo, path)
                    if looks_minified(abs_path, minified_cache):
                        drop = True
                if drop:
                    dropped += 1
                    continue
                kept += 1
            fout.write(stripped + "\n")
    print(
        "filter-excluded: dropped {0} junk-path finding(s), kept {1}".format(
            dropped, kept
        ),
        file=sys.stderr,
    )
    return dropped


def main(argv):
    if len(argv) >= 3 and argv[1] == "--gitleaks-config":
        repo = argv[3] if len(argv) >= 4 else None
        _write_gitleaks_config(argv[2], repo)
        return 0
    if len(argv) >= 4 and argv[1] == "--filter":
        repo = argv[4] if len(argv) >= 5 else None
        _filter_jsonl(argv[2], argv[3], repo)
        return 0
    sys.stderr.write(
        "Usage:\n"
        "  exclude.py --gitleaks-config <out.toml> [<repo>]\n"
        "  exclude.py --filter <in.jsonl> <out.jsonl> [<repo>]\n"
    )
    return 2


if __name__ == "__main__":
    sys.exit(main(sys.argv))

skills/audit/scripts/spine/normalize.py

#!/usr/bin/env python3
"""
CCGM audit spine — shared normalizer helpers + CLI entry point.

Dual-use:
  1. Library: other Python normalizers import from this module.
  2. CLI: called by bash wrappers for simple skip/gap emission.

CLI modes (argv[1]):
  --emit-skip  <tool>  <"check_id:description" ...>
      Emits a skipped note + one coverage-gap per pair, then exits 0.

No third-party deps -- stdlib only.
"""

import hashlib
import json
import re
import sys


# ---------------------------------------------------------------------------
# Fingerprint
# ---------------------------------------------------------------------------

def compute_fingerprint(file_lines, line_no, gen=1):
    """
    Compute a stable fingerprint for a finding.

    fingerprint = sha256( lower( strip_all_whitespace( primary line +/- 2 lines ) ) )[:16] + ":<gen>"

    file_lines: list of strings (file content, 0-indexed)
    line_no:    1-based line number of the primary finding line
    gen:        fingerprint generation (default 1)
    """
    idx = line_no - 1  # convert to 0-based
    start = max(0, idx - 2)
    end = min(len(file_lines), idx + 3)
    context = "".join(file_lines[start:end])
    normalized = re.sub(r"\s+", "", context).lower()
    digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
    return "{0}:{1}".format(digest[:16], gen)


_FINGERPRINT_RE = re.compile(r"^[A-Za-z0-9_.:+/=-]{8,128}$")


def validate_tool_fingerprint(tool_fp):
    """
    Return the stripped tool-supplied fingerprint if it matches the finding
    schema fingerprint pattern ^[A-Za-z0-9_.:+/=-]{8,128}$, otherwise None.

    Semgrep emits 'requires login' (contains a space) when not authenticated
    to Semgrep Cloud; that placeholder fails the schema and must be discarded
    so callers fall back to compute_fingerprint().
    """
    stripped = tool_fp.strip() if tool_fp else ""
    if stripped and _FINGERPRINT_RE.match(stripped):
        return stripped
    return None


def fingerprint_from_tool(tool_fp):
    """
    Return a validated tool-supplied fingerprint (stripped), or None if the
    value fails the fingerprint schema pattern ^[A-Za-z0-9_.:+/=-]{8,128}$.

    Per plan ss3.7: where a spine tool emits its own partialFingerprints,
    use the tool's fingerprint -- but only if it is schema-valid.  Invalid
    values (e.g. semgrep's 'requires login' placeholder when not logged into
    Semgrep Cloud) are discarded so callers fall back to compute_fingerprint().
    """
    return validate_tool_fingerprint(tool_fp)


def make_content_fingerprint(content, gen=1):
    """
    Compute a fingerprint directly from a content string (for findings
    without a file context, e.g. dependency findings).
    """
    normalized = re.sub(r"\s+", "", content).lower()
    digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
    return "{0}:{1}".format(digest[:16], gen)


# ---------------------------------------------------------------------------
# Secret redaction
# ---------------------------------------------------------------------------

# Patterns for credential-like values.
_SECRET_PATTERN = re.compile(
    r"""(?xi)
    (?:
        # keyword = value  (group 1 = value)
        (?:api[_\-]?key|access[_\-]?token|secret[_\-]?key|auth[_\-]?token|
           bearer|password|passwd|credential|private[_\-]?key|api[_\-]?secret)
        \s*[=:]\s*
        ['""]?([A-Za-z0-9+/._\-]{8,})['""]?
    )
    |
    (?:
        # Bare token prefixes (group 2)
        (ghp_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9\-]{20,}|
         xox[bpoas]-[A-Za-z0-9\-]+|
         AKIA[A-Z0-9]{16}|
         ya29\.[A-Za-z0-9\-_]+|
         AIza[A-Za-z0-9\-_]{35,})
    )
    """,
    re.IGNORECASE,
)


def redact_secret(value):
    """
    Redact a secret value: keep first 4 chars, show total length.
    e.g. "ghp_AbcXyz123..." -> "ghp_[redacted:len=40]"
    """
    if len(value) <= 4:
        return "[redacted:len={0}]".format(len(value))
    return "{0}[redacted:len={1}]".format(value[:4], len(value))


def redact_message(message):
    """
    Scan a finding message for credential-like values and redact them.
    Only the captured value group (not the keyword prefix) is redacted.
    """
    def _replace(m):
        full = m.group(0)
        secret_val = m.group(1) or m.group(2)
        if secret_val and len(secret_val) >= 8:
            return full.replace(secret_val, redact_secret(secret_val), 1)
        return full

    return _SECRET_PATTERN.sub(_replace, message)


# ---------------------------------------------------------------------------
# Finding construction
# ---------------------------------------------------------------------------

def make_finding(
    check_id,
    rule_id,
    severity,
    confidence,
    path,
    line,
    message,
    fingerprint,
    end_line=None,
    fix_confidence=None,
    properties=None,
    detection="tool",
):
    """
    Build a finding dict conforming to finding.schema.json.
    Automatically redacts the message field.

    detection: "tool" (default) for deterministic, lockfile/manifest-grade
    findings that must NOT be dismissible (dep-audit, govulncheck, ...).
    Heuristic, FP-prone scanners (gitleaks, semgrep, bandit) pass "hybrid" so
    worker triage can dismiss false positives on test fixtures and the like
    (field report #4); a hybrid finding is dropped only if EVERY worker that
    named it voted "dismissed".  source stays "tool" -- the spine produced it.
    """
    if detection not in ("tool", "hybrid"):
        detection = "tool"
    finding = {
        "check_id": check_id,
        "rule_id": rule_id,
        "severity": severity,
        "confidence": confidence,
        "location": {
            "path": path,
            "line": line,
        },
        "message": redact_message(message),
        "fingerprint": fingerprint,
        "detection": detection,
        "source": "tool",
    }
    if end_line is not None and end_line >= line:
        finding["location"]["end_line"] = end_line
    if fix_confidence is not None:
        finding["fix_confidence"] = fix_confidence
    if properties:
        finding["properties"] = properties
    return finding


# ---------------------------------------------------------------------------
# Coverage gap / skip note
# ---------------------------------------------------------------------------

def make_skipped_note(tool, reason="not installed"):
    """Build a skipped note dict."""
    return {
        "type": "skipped",
        "tool": tool,
        "reason": reason,
    }


def make_coverage_gap(tool, check_id, description):
    """
    Build a coverage-gap entry for a tool-backed check that could not run.
    Per plan ss3.6: coverage_gaps[] is first-class output.
    """
    return {
        "type": "coverage_gap",
        "tool": tool,
        "check_id": check_id,
        "description": description,
    }


# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------

def emit_finding(finding):
    """Print a finding as a JSONL line to stdout."""
    sys.stdout.write(json.dumps(finding, separators=(",", ":")) + "\n")
    sys.stdout.flush()


def emit_note(note):
    """Print a note/coverage-gap as a JSONL line to stdout."""
    sys.stdout.write(json.dumps(note, separators=(",", ":")) + "\n")
    sys.stdout.flush()


def emit_skip_and_exit(tool, gaps):
    """
    Emit a skipped note + one coverage-gap entry per (check_id, description)
    tuple in gaps.  Exits 0 -- absent tools are not errors.

    gaps: list of (check_id, description) tuples
    """
    emit_note(make_skipped_note(tool))
    for check_id, description in gaps:
        emit_note(make_coverage_gap(tool, check_id, description))
    sys.exit(0)


# ---------------------------------------------------------------------------
# CLI entry point (used by bash wrappers)
# ---------------------------------------------------------------------------

def _cli(argv):
    """
    CLI: normalize.py --emit-skip <tool> <"check_id:description" ...>
    """
    if len(argv) < 2:
        sys.stderr.write(
            "Usage: normalize.py --emit-skip <tool> <check_id:description ...>\n"
        )
        sys.exit(1)

    mode = argv[1]

    if mode == "--emit-skip":
        if len(argv) < 3:
            sys.stderr.write("--emit-skip requires <tool> argument\n")
            sys.exit(1)
        tool = argv[2]
        gaps = []
        for item in argv[3:]:
            if ":" in item:
                check_id, _, description = item.partition(":")
                gaps.append((check_id.strip(), description.strip()))
        emit_skip_and_exit(tool, gaps)
    else:
        sys.stderr.write("Unknown mode: {0}\n".format(mode))
        sys.exit(1)


if __name__ == "__main__":
    _cli(sys.argv)

skills/audit/scripts/spine/wrap-gitleaks.sh

#!/usr/bin/env bash
# CCGM audit spine -- gitleaks wrapper
# Detects hard-coded secrets in the working tree (default) or full git history.
#
# Usage: wrap-gitleaks.sh <repo_root>
#   repo_root: absolute path to the repository root
#
# Scan modes:
#   Working-tree (default):
#     Uses `gitleaks detect --no-git` -- scans files present in the working
#     directory. Works in worktrees and detached-HEAD states.
#
#   History (opt-in):
#     Set CCGM_GITLEAKS_HISTORY=1 before invoking to use `gitleaks git`
#     instead. Walks every commit in the full git history -- finds secrets
#     that were committed and later removed. Requires a real git repo with
#     at least one commit. No network calls are made.
#
# --verify-secrets (opt-in, NOT wired by default):
#   Live verification of detected secrets by calling credential-issuer APIs
#   is intentionally out of scope for the default path; it makes network calls
#   to external services and triggers security-review gate C1. To add opt-in
#   verification, set CCGM_GITLEAKS_VERIFY=1 only after obtaining approval.
#   The trufflehog wrapper (currently not installed) provides this capability
#   as an independent optional step; do not rely on it being present.
#
# Output (stdout): JSONL -- one JSON object per line.
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-gitleaks.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip gitleaks \
    "secrets/leaked-credential:no repo_root argument supplied"
  exit 0
fi

if ! command -v gitleaks > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip gitleaks \
    "secrets/leaked-credential:gitleaks not installed -- secret scanning skipped" \
    "secrets/high-entropy-string:gitleaks not installed -- entropy scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-gitleaks-XXXXXX.json)"
CONFIGFILE="$(mktemp /tmp/ccgm-gitleaks-cfg-XXXXXX.toml)"
trap 'rm -f "$TMPFILE" "$CONFIGFILE"' EXIT

# Path exclusion (field report #1): `--no-git` walks the whole filesystem with
# NO path exclusions, so it scans a 566 MB node_modules and every stale
# worktree copy -- 22+ minutes on this tool alone.  gitleaks excludes nothing
# without a config.  We generate a config that keeps the default ruleset
# ([extend] useDefault = true) and allowlists every canonical excluded dir;
# this dropped gitleaks from 22 min -> 34 s.
#
# Passing REPO_ROOT also allowlists GITIGNORED paths: a working-tree scan would
# otherwise report a gitignored, never-committed .env.local as a CRITICAL
# leaked-credential. A leaked credential describes committed/tracked content,
# so gitignored local files must not be flagged (field report #726).
python3 "$SCRIPT_DIR/exclude.py" --gitleaks-config "$CONFIGFILE" "$REPO_ROOT" 2>/dev/null || true

# Determine scan mode: history or working-tree
HISTORY_MODE="${CCGM_GITLEAKS_HISTORY:-0}"

set +e
if [[ "$HISTORY_MODE" == "1" ]]; then
  # Full-history scan: walks every commit in the repository.
  # Uses `gitleaks git` which requires a real git repo.
  # --no-banner: suppress the gitleaks ASCII banner (keeps output clean)
  gitleaks git \
    --config "$CONFIGFILE" \
    --report-format json \
    --report-path "$TMPFILE" \
    --exit-code 0 \
    --no-banner \
    "$REPO_ROOT" \
    > /dev/null 2>&1
else
  # Working-tree scan (default): scans files present in the working directory.
  # --no-git: works in worktrees / detached heads
  gitleaks detect \
    --config "$CONFIGFILE" \
    --source "$REPO_ROOT" \
    --report-format json \
    --report-path "$TMPFILE" \
    --no-git \
    --exit-code 0 \
    > /dev/null 2>&1
fi
GL_EXIT=$?
set -e

if [[ $GL_EXIT -ne 0 && ! -s "$TMPFILE" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip gitleaks \
    "secrets/leaked-credential:gitleaks exited non-zero with no output"
  exit 0
fi

# Normalize output -> finding.schema.json
python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-gitleaks.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse gitleaks JSON output -> finding.schema.json JSONL.

Usage: parse-gitleaks.py <gitleaks_json_file> <repo_root>

Gitleaks JSON shape (one finding per array element):
  {
    "Description": "Generic API Key",
    "StartLine": 10,
    "EndLine": 10,
    "File": "config/secrets.env",
    "Secret": "AKIAIOSFODNN7EXAMPLE",
    "RuleID": "generic-api-key",
    "Fingerprint": "abc123..."  (may be present)
    ...
  }
"""

import json
import os
import sys

# Allow importing normalize from same directory
sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    # gitleaks rule IDs -> severity (conservative: all secrets are high)
    "default": "high",
}

_RULE_SEVERITY = {
    "generic-api-key": "high",
    "aws-access-token": "critical",
    "github-token": "critical",
    "github-fine-grained-pat": "critical",
    "github-pat": "critical",
    "google-api-key": "high",
    "slack-webhook-url": "high",
    "stripe-access-token": "critical",
    "private-key": "critical",
}


def severity_for_rule(rule_id):
    return _RULE_SEVERITY.get(rule_id, _SEVERITY_MAP["default"])


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-gitleaks.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            raw = fh.read().strip()
    except OSError as exc:
        sys.stderr.write("Cannot read {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not raw or raw in ("null", "[]"):
        # No findings
        return

    try:
        findings_raw = json.loads(raw)
    except json.JSONDecodeError as exc:
        sys.stderr.write("Invalid JSON from gitleaks: {0}\n".format(exc))
        sys.exit(0)

    if not isinstance(findings_raw, list):
        sys.stderr.write("Expected JSON array from gitleaks\n")
        sys.exit(0)

    for item in findings_raw:
        if not isinstance(item, dict):
            continue

        rule_id = item.get("RuleID", "unknown")
        description = item.get("Description", "Secret detected")
        file_path = item.get("File", "")
        start_line = item.get("StartLine", 1)
        end_line = item.get("EndLine")
        secret_val = item.get("Secret", "")
        tool_fp = item.get("Fingerprint", "")

        # Make path repo-relative
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        # Redact the secret value before it enters the message
        redacted = normalize.redact_secret(secret_val) if secret_val else "[redacted]"
        message = "{0} [{1}] matched value: {2}".format(description, rule_id, redacted)

        # Fingerprint: gitleaks emits "filepath:rule:line" as the fingerprint,
        # which can be an absolute path and exceed the 128-char limit in
        # finding.schema.json. We always recompute using make_content_fingerprint
        # so the fingerprint is stable and schema-conformant.
        # (The tool fingerprint is still useful for dedup upstream -- we store
        # the raw tool_fp in properties for that purpose.)
        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}:{3}".format(file_path, start_line, rule_id, secret_val[:8] if secret_val else "")
        )

        # Validate line number
        if not isinstance(start_line, int) or start_line < 1:
            start_line = 1

        end = end_line if (isinstance(end_line, int) and end_line >= start_line) else None

        props = {"tool": "gitleaks"}
        if tool_fp:
            props["tool_fingerprint"] = tool_fp
        finding = normalize.make_finding(
            check_id="secrets/leaked-credential",
            rule_id=rule_id,
            severity=severity_for_rule(rule_id),
            confidence="high",
            path=file_path,
            line=start_line,
            message=message,
            fingerprint=fp,
            end_line=end,
            properties=props,
            # Heuristic secret match -- FP-prone on test fixtures and fabricated
            # keys, so worker triage must be able to dismiss it (#4).
            detection="hybrid",
        )
        normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-semgrep.sh

#!/usr/bin/env bash
# CCGM audit spine -- semgrep wrapper
# Runs semgrep with explicit --config (never --config auto) for config isolation.
#
# Usage: wrap-semgrep.sh <repo_root> [semgrep_config]
#   repo_root:      absolute path to the repository root
#   semgrep_config: semgrep ruleset (default: p/default)
#
# Config isolation: --config is always explicit. Never passes --config auto
# against the audited repo (which would execute repo-local semgrep rules).
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SEMGREP_CONFIG="${2:-p/default}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-semgrep.py"

# shellcheck source=exclude.sh
. "$SCRIPT_DIR/exclude.sh"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip semgrep \
    "sast/code-injection:no repo_root argument supplied"
  exit 0
fi

if ! command -v semgrep > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip semgrep \
    "sast/code-injection:semgrep not installed -- SAST scan skipped" \
    "sast/insecure-deserialization:semgrep not installed -- SAST scan skipped" \
    "sast/sql-injection:semgrep not installed -- SAST scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-semgrep-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Config isolation: explicit --config, never --config auto
# --no-autofix: read-only scan
# --metrics off: no telemetry
# --quiet: suppress progress output
# --exclude: vendored/generated dirs and stale worktrees (#1).  semgrep already
#   honors .gitignore + a default .semgrepignore, but an un-gitignored stale
#   .claude/worktrees tree would still be scanned; this makes exclusion explicit.
# repo_root passed as positional arg
ccgm_semgrep_exclude_args
set +e
semgrep scan \
  --config "$SEMGREP_CONFIG" \
  --json \
  --output "$TMPFILE" \
  --no-autofix \
  --metrics off \
  --quiet \
  "${CCGM_FLAGS[@]}" \
  "$REPO_ROOT" \
  > /dev/null 2>&1
SEMGREP_EXIT=$?
set -e

if [[ $SEMGREP_EXIT -ne 0 && ! -s "$TMPFILE" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip semgrep \
    "sast/code-injection:semgrep exited non-zero with no output"
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-semgrep.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse semgrep JSON output -> finding.schema.json JSONL.

Usage: parse-semgrep.py <semgrep_json_file> <repo_root>

Semgrep JSON shape (results array):
  {
    "check_id": "python.lang.security.audit.exec-detected.exec-detected",
    "path": "src/app.py",
    "start": {"line": 10, "col": 1},
    "end": {"line": 10, "col": 50},
    "extra": {
      "message": "Use of exec",
      "severity": "WARNING",
      "metadata": { "confidence": "HIGH" },
      "fingerprint": "abc...",
      "lines": "exec(user_input)"
    }
  }
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    "ERROR": "high",
    "WARNING": "medium",
    "INFO": "info",
    "LOW": "low",
}

_CONFIDENCE_MAP = {
    "HIGH": "high",
    "MEDIUM": "medium",
    "LOW": "low",
}


def map_severity(semgrep_sev):
    return _SEVERITY_MAP.get(semgrep_sev.upper(), "medium")


def map_confidence(meta):
    raw = meta.get("confidence", "MEDIUM")
    if isinstance(raw, str):
        return _CONFIDENCE_MAP.get(raw.upper(), "medium")
    return "medium"


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-semgrep.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    results = data.get("results", [])
    if not results:
        return

    for item in results:
        if not isinstance(item, dict):
            continue

        rule_id = item.get("check_id", "unknown")
        path = item.get("path", "")
        extra = item.get("extra", {})
        message = extra.get("message", "Semgrep finding")
        sev_raw = extra.get("severity", "WARNING")
        meta = extra.get("metadata", {})
        start = item.get("start", {})
        end_info = item.get("end", {})
        tool_fp = extra.get("fingerprint", "")
        lines_ctx = extra.get("lines", "")

        # Make path repo-relative
        if path.startswith(repo_root + "/"):
            path = path[len(repo_root) + 1:]

        start_line = max(1, int(start.get("line", 1)))
        end_line_raw = int(end_info.get("line", start_line))
        end_line = end_line_raw if end_line_raw >= start_line else None

        severity = map_severity(sev_raw)
        confidence = map_confidence(meta)

        # Fingerprint: use the tool's fingerprint only when it is schema-valid
        # (fingerprint_from_tool returns None for invalid values such as
        # semgrep's 'requires login' placeholder emitted without Semgrep Cloud
        # authentication). Fall back to content-based fingerprint on None.
        fp = None
        if tool_fp:
            fp = normalize.fingerprint_from_tool(tool_fp)
        if fp is None:
            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}:{3}".format(path, start_line, rule_id, lines_ctx)
            )

        # Derive a short check_id from the rule
        # e.g. "python.lang.security.audit.exec-detected" -> "sast/exec-detected"
        parts = rule_id.split(".")
        short = parts[-1] if parts else rule_id
        check_id = "sast/{0}".format(short)

        finding = normalize.make_finding(
            check_id=check_id,
            rule_id=rule_id,
            severity=severity,
            confidence=confidence,
            path=path,
            line=start_line,
            message=message,
            fingerprint=fp,
            end_line=end_line,
            properties={"tool": "semgrep"},
            # Heuristic SAST -- FP-prone (e.g. unsafe-formatstring on console
            # template literals), so worker triage must be able to dismiss (#4).
            detection="hybrid",
        )
        normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-dep-audit.sh

#!/usr/bin/env bash
# CCGM audit spine -- dependency audit wrapper (npm/pnpm/yarn/bun)
# Detects vulnerable dependencies.
#
# Usage: wrap-dep-audit.sh <repo_root>
#
# Detects which package manager is in use from lockfile presence:
#   pnpm-lock.yaml -> pnpm
#   yarn.lock      -> yarn
#   bun.lockb      -> bun
#   package-lock.json -> npm
#   package.json (no lockfile) -> npm (best effort)
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-dep-audit.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip dep-audit \
    "deps/vulnerable-dependency:no repo_root argument supplied"
  exit 0
fi

# Detect package manager from lockfile
PM=""
if [[ -f "$REPO_ROOT/pnpm-lock.yaml" ]]; then
  PM="pnpm"
elif [[ -f "$REPO_ROOT/yarn.lock" ]]; then
  PM="yarn"
elif [[ -f "$REPO_ROOT/bun.lockb" ]]; then
  PM="bun"
elif [[ -f "$REPO_ROOT/package-lock.json" || -f "$REPO_ROOT/package.json" ]]; then
  PM="npm"
fi

if [[ -z "$PM" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip dep-audit \
    "deps/vulnerable-dependency:no package.json found -- dependency audit skipped"
  exit 0
fi

if ! command -v "$PM" > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip dep-audit \
    "deps/vulnerable-dependency:${PM} not installed -- dependency audit skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-dep-audit-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Run audit -- all args are static; repo_root is passed as the working directory
# via a subshell so we never interpolate it into the audit command string itself.
set +e
(
  cd "$REPO_ROOT"
  case "$PM" in
    npm)
      npm audit --json 2>/dev/null > "$TMPFILE"
      ;;
    pnpm)
      pnpm audit --json 2>/dev/null > "$TMPFILE"
      ;;
    yarn)
      # yarn audit exits non-zero when vulns found; that is expected
      yarn audit --json 2>/dev/null > "$TMPFILE" || true
      ;;
    bun)
      bun audit 2>/dev/null > "$TMPFILE" || true
      ;;
  esac
)
AUDIT_EXIT=$?
set -e

if [[ $AUDIT_EXIT -ne 0 && ! -s "$TMPFILE" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip dep-audit \
    "deps/vulnerable-dependency:${PM} audit exited non-zero with no output"
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$PM"

skills/audit/scripts/spine/parse-dep-audit.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse npm/pnpm/yarn/bun audit JSON -> finding.schema.json JSONL.

Usage: parse-dep-audit.py <audit_json_file> <package_manager>

Handles npm v7+ (advisories object), pnpm (similar to npm), yarn v1 (advisory lines),
and falls back gracefully on unknown shapes.
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    "critical": "critical",
    "high": "high",
    "moderate": "medium",
    "medium": "medium",
    "low": "low",
    "info": "info",
}


def map_severity(raw):
    if isinstance(raw, str):
        return _SEVERITY_MAP.get(raw.lower(), "medium")
    return "medium"


def process_npm_pnpm(data, pm):
    """npm v7+ / pnpm audit --json format."""
    vulns = data.get("vulnerabilities", {})
    if not vulns and "advisories" in data:
        # npm v6 format
        vulns = data["advisories"]

    for pkg_name, vuln in vulns.items():
        if not isinstance(vuln, dict):
            continue

        # npm v7: severity on the vuln object
        severity_raw = vuln.get("severity", "medium")
        severity = map_severity(severity_raw)

        via = vuln.get("via", [])
        # Extract advisory detail if via contains objects
        advisory = None
        for v in via:
            if isinstance(v, dict):
                advisory = v
                break

        if advisory:
            rule_id = "GHSA-{0}".format(advisory.get("source", pkg_name)) if advisory.get("source") else pkg_name
            cve = advisory.get("cve", "")
            title = advisory.get("title", "Vulnerable dependency")
            url = advisory.get("url", "")
            severity = map_severity(advisory.get("severity", severity_raw))
        else:
            rule_id = pkg_name
            cve = ""
            title = "Vulnerable dependency: {0}".format(pkg_name)
            url = ""

        range_affected = vuln.get("range", "")
        message = "{0} in {1} {2}".format(title, pkg_name, range_affected).strip()
        if cve:
            message = "{0} [{1}]".format(message, cve)
        if url:
            message = "{0} -- {1}".format(message, url)

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(pkg_name, rule_id, range_affected)
        )

        finding = normalize.make_finding(
            check_id="deps/vulnerable-dependency",
            rule_id=rule_id,
            severity=severity,
            confidence="high",
            path="package.json",
            line=1,
            message=message,
            fingerprint=fp,
            properties={
                "tool": "dep-audit",
                "package_manager": pm,
                "package": pkg_name,
            },
        )
        normalize.emit_finding(finding)


def process_yarn(data):
    """yarn audit --json emits NDJSON (one object per line, not an array)."""
    # The input file may already be parsed if it was valid JSON array,
    # or may be a single advisory dict.
    advisories = []
    if isinstance(data, dict) and "data" in data:
        # yarn v1 wraps in {type: "auditAdvisory", data: {...}}
        inner = data.get("data", {})
        adv = inner.get("advisory", {})
        if adv:
            advisories.append(adv)
    elif isinstance(data, list):
        for item in data:
            if isinstance(item, dict):
                inner = item.get("data", {})
                adv = inner.get("advisory", {})
                if adv:
                    advisories.append(adv)

    for advisory in advisories:
        pkg = advisory.get("module_name", "unknown")
        severity = map_severity(advisory.get("severity", "medium"))
        title = advisory.get("title", "Vulnerable dependency")
        cves = advisory.get("cves", [])
        cve = cves[0] if cves else ""
        url = advisory.get("url", "")
        findings = advisory.get("findings", [{}])
        version = findings[0].get("version", "") if findings else ""

        message = "{0} in {1} {2}".format(title, pkg, version).strip()
        if cve:
            message = "{0} [{1}]".format(message, cve)
        if url:
            message = "{0} -- {1}".format(message, url)

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(pkg, title, version)
        )

        finding = normalize.make_finding(
            check_id="deps/vulnerable-dependency",
            rule_id=advisory.get("id", pkg),
            severity=severity,
            confidence="high",
            path="package.json",
            line=1,
            message=message,
            fingerprint=fp,
            properties={
                "tool": "dep-audit",
                "package_manager": "yarn",
                "package": pkg,
            },
        )
        normalize.emit_finding(finding)


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-dep-audit.py <json_file> <pm>\n")
        sys.exit(1)

    json_file = argv[1]
    pm = argv[2]

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            raw = fh.read().strip()
    except OSError as exc:
        sys.stderr.write("Cannot read {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not raw:
        return

    # yarn emits NDJSON -- try to parse as array of lines
    if pm == "yarn":
        lines = [l.strip() for l in raw.splitlines() if l.strip()]
        parsed_lines = []
        for line in lines:
            try:
                parsed_lines.append(json.loads(line))
            except json.JSONDecodeError:
                pass
        if parsed_lines:
            process_yarn(parsed_lines)
            return
        # Fall through to try standard JSON

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        sys.stderr.write("Invalid JSON from {0} audit: {1}\n".format(pm, exc))
        sys.exit(0)

    if pm in ("npm", "pnpm"):
        process_npm_pnpm(data, pm)
    elif pm == "yarn":
        process_yarn(data)
    elif pm == "bun":
        # bun audit output is not standardized yet; parse best-effort as npm shape
        process_npm_pnpm(data, pm)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-knip.sh

#!/usr/bin/env bash
# CCGM audit spine -- knip wrapper
# Finds unused exports, files, and dependencies.
#
# Config isolation: knip evaluates knip.config.ts/js from the project -- we
# guard this by checking for a config that would error before running, and we
# run with --no-progress (non-interactive) but CANNOT fully isolate from the
# repo config without breaking knip's ability to traverse the project.
# Therefore we document this as a known limitation and skip if a guard env
# var CCGM_KNIP_SKIP is set (e.g. when the config isolation fixture is active).
#
# Usage: wrap-knip.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-knip.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip knip \
    "dead-code/unused-export:no repo_root argument supplied"
  exit 0
fi

if ! command -v knip > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip knip \
    "dead-code/unused-export:knip not installed -- dead-code scan skipped" \
    "dead-code/unused-file:knip not installed -- dead-code scan skipped"
  exit 0
fi

# Config isolation guard: skip if CCGM_KNIP_SKIP is set
# (used by tests to prove the wrapper respects the config-isolation fixture)
if [[ -n "${CCGM_KNIP_SKIP:-}" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip knip \
    "dead-code/unused-export:knip skipped (CCGM_KNIP_SKIP set -- config isolation)"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-knip-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

set +e
(
  cd "$REPO_ROOT"
  knip --reporter json --no-progress 2>/dev/null > "$TMPFILE" || true
)
set -e

if [[ ! -s "$TMPFILE" ]]; then
  # No output = no issues (or no package.json)
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-knip.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse knip JSON output -> finding.schema.json JSONL.

Usage: parse-knip.py <knip_json_file> <repo_root>

Knip JSON shape:
  {
    "files": ["src/old.ts"],
    "issues": [
      {
        "file": "src/utils.ts",
        "owners": [],
        "dependencies": [],
        "devDependencies": [],
        "optionalPeerDependencies": [],
        "unlisted": [],
        "unresolved": [],
        "exports": [{"name": "foo", "line": 10, "col": 1, "pos": 100}],
        "types": [],
        "nsExports": [],
        "nsTypes": [],
        "enumMembers": {},
        "classMembers": {},
        "duplicates": []
      }
    ]
  }
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-knip.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    # Unused files
    for file_path in data.get("files", []):
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        fp = normalize.make_content_fingerprint(
            "unused-file:{0}".format(file_path)
        )
        finding = normalize.make_finding(
            check_id="dead-code/unused-file",
            rule_id="knip/unused-file",
            severity="low",
            confidence="medium",
            path=file_path,
            line=1,
            message="Unused file: {0}".format(file_path),
            fingerprint=fp,
            properties={"tool": "knip"},
        )
        normalize.emit_finding(finding)

    # Per-file issues
    for issue in data.get("issues", []):
        if not isinstance(issue, dict):
            continue

        file_path = issue.get("file", "")
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        # Unused exports
        for exp in issue.get("exports", []):
            name = exp.get("name", "?")
            line = max(1, int(exp.get("line", 1)))
            fp = normalize.make_content_fingerprint(
                "unused-export:{0}:{1}:{2}".format(file_path, line, name)
            )
            finding = normalize.make_finding(
                check_id="dead-code/unused-export",
                rule_id="knip/unused-export",
                severity="low",
                confidence="medium",
                path=file_path,
                line=line,
                message="Unused export: {0}".format(name),
                fingerprint=fp,
                properties={"tool": "knip"},
            )
            normalize.emit_finding(finding)

        # Unlisted dependencies
        for dep in issue.get("unlisted", []):
            name = dep.get("name", "?") if isinstance(dep, dict) else str(dep)
            fp = normalize.make_content_fingerprint(
                "unlisted-dep:{0}:{1}".format(file_path, name)
            )
            finding = normalize.make_finding(
                check_id="dead-code/unlisted-dependency",
                rule_id="knip/unlisted",
                severity="medium",
                confidence="medium",
                path=file_path,
                line=1,
                message="Unlisted dependency: {0}".format(name),
                fingerprint=fp,
                properties={"tool": "knip", "package": name},
            )
            normalize.emit_finding(finding)

        # Unused dependencies listed in package.json
        for dep in issue.get("dependencies", []):
            name = dep.get("name", "?") if isinstance(dep, dict) else str(dep)
            fp = normalize.make_content_fingerprint(
                "unused-dep:{0}:{1}".format(file_path, name)
            )
            finding = normalize.make_finding(
                check_id="dead-code/unused-dependency",
                rule_id="knip/unused-dependency",
                severity="low",
                confidence="medium",
                path="package.json",
                line=1,
                message="Unused dependency in package.json: {0}".format(name),
                fingerprint=fp,
                properties={"tool": "knip", "package": name},
            )
            normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-eslint.sh

#!/usr/bin/env bash
# CCGM audit spine -- eslint wrapper
# Config isolation: --no-config-lookup prevents loading repo .eslintrc/.eslintrc.js
# and uses only the flags we pass explicitly. This is the safety-critical flag.
#
# Usage: wrap-eslint.sh <repo_root> [glob_pattern]
#   glob_pattern: optional, defaults to "**/*.{js,jsx,ts,tsx,mjs,cjs}"
#
# Output (stdout): JSONL
# Exit code: always 0
#
# Active rules (all core ESLint, all config-free, all require no type information):
#   Security surface (Epic 2.1):
#     no-eval, no-implied-eval, no-new-func
#   Correctness/Logic surface (Epic 2.4):
#     eqeqeq, use-isnan, valid-typeof, no-unreachable,
#     no-constant-condition, no-fallthrough, default-case

set -euo pipefail

REPO_ROOT="${1:-}"
GLOB_PATTERN="${2:-**/*.{js,jsx,ts,tsx,mjs,cjs}}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-eslint.py"

# shellcheck source=exclude.sh
. "$SCRIPT_DIR/exclude.sh"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip eslint \
    "lint/eslint-error:no repo_root argument supplied"
  exit 0
fi

if ! command -v eslint > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip eslint \
    "lint/eslint-error:eslint not installed -- lint scan skipped" \
    "lint/eslint-warning:eslint not installed -- lint scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-eslint-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Config isolation: --no-config-lookup is the critical flag.
# We pass a fixed rule set (10 rules: 3 security + 7 correctness); all are
# core ESLint rules that run correctly with only --no-config-lookup and no
# type information.  The repo's own lint config is never loaded.
# repo_root is passed via cd in subshell, glob is a static pattern.
#
# Path exclusion: --no-config-lookup ALSO discards .eslintignore / flat-config
# `ignores`, so without this eslint lints node_modules/, dist/, and every
# stale .claude/worktrees/* copy (28k+ findings, 40+ min in the field report).
# --ignore-pattern works with --no-config-lookup; CCGM_FLAGS is built from the
# canonical exclude list.
ccgm_eslint_ignore_args
set +e
(
  cd "$REPO_ROOT"
  eslint \
    --no-config-lookup \
    "${CCGM_FLAGS[@]}" \
    --rule '{"no-eval":["error"],"no-implied-eval":["error"],"no-new-func":["error"],"eqeqeq":["error","always"],"use-isnan":["error"],"valid-typeof":["error"],"no-unreachable":["error"],"no-constant-condition":["error"],"no-fallthrough":["error"],"default-case":["error"]}' \
    --format json \
    --output-file "$TMPFILE" \
    "$GLOB_PATTERN" \
    > /dev/null 2>&1
)
ESLINT_EXIT=$?
set -e

# eslint exits 1 when errors/warnings found (expected), 2 on config error
if [[ $ESLINT_EXIT -eq 2 || (! -s "$TMPFILE") ]]; then
  python3 "$NORMALIZE_PY" --emit-skip eslint \
    "lint/eslint-error:eslint configuration error or no output"
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-eslint.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse eslint JSON output -> finding.schema.json JSONL.

Usage: parse-eslint.py <eslint_json_file> <repo_root>

ESLint JSON shape (array of file results):
  [
    {
      "filePath": "/abs/path/to/file.ts",
      "messages": [
        {
          "ruleId": "no-eval",
          "severity": 2,
          "message": "eval can be harmful.",
          "line": 10,
          "endLine": 10,
          "column": 1,
          "endColumn": 20
        }
      ]
    }
  ]
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


def map_severity(eslint_sev):
    # ESLint severity: 0=off, 1=warn, 2=error
    if eslint_sev == 2:
        return "medium"
    if eslint_sev == 1:
        return "low"
    return "info"


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-eslint.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not isinstance(data, list):
        sys.stderr.write("Expected JSON array from eslint\n")
        sys.exit(0)

    for file_result in data:
        if not isinstance(file_result, dict):
            continue

        file_path = file_result.get("filePath", "")
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        messages = file_result.get("messages", [])
        for msg in messages:
            if not isinstance(msg, dict):
                continue

            rule_id = msg.get("ruleId") or "unknown"
            sev_raw = msg.get("severity", 2)
            message = msg.get("message", "ESLint finding")
            line = max(1, int(msg.get("line", 1)))
            end_line_raw = msg.get("endLine")
            end_line = int(end_line_raw) if end_line_raw and int(end_line_raw) >= line else None

            severity = map_severity(sev_raw)

            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}:{3}".format(file_path, line, rule_id, message[:64])
            )

            check_id = "lint/{0}".format(rule_id.replace("/", "-").replace("@", ""))

            finding = normalize.make_finding(
                check_id=check_id,
                rule_id=rule_id,
                severity=severity,
                confidence="high",
                path=file_path,
                line=line,
                message=message,
                fingerprint=fp,
                end_line=end_line,
                properties={"tool": "eslint"},
            )
            normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-govulncheck.sh

#!/usr/bin/env bash
# CCGM audit spine -- govulncheck wrapper
# Scans Go modules for known vulnerabilities.
#
# Usage: wrap-govulncheck.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-govulncheck.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip govulncheck \
    "deps/go-vulnerability:no repo_root argument supplied"
  exit 0
fi

# Only run if this looks like a Go project
if [[ ! -f "$REPO_ROOT/go.mod" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip govulncheck \
    "deps/go-vulnerability:no go.mod found -- govulncheck skipped"
  exit 0
fi

if ! command -v govulncheck > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip govulncheck \
    "deps/go-vulnerability:govulncheck not installed -- Go vulnerability scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-govulncheck-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

set +e
(
  cd "$REPO_ROOT"
  govulncheck -json ./... 2>/dev/null > "$TMPFILE" || true
)
set -e

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE"

skills/audit/scripts/spine/parse-govulncheck.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse govulncheck JSON output -> finding.schema.json JSONL.

Usage: parse-govulncheck.py <govulncheck_json_file>

govulncheck -json emits NDJSON (one object per line).
Objects have type "osv", "finding", "progress", "message".
We care about "finding" objects.

Finding shape:
  {
    "finding": {
      "osv": "GO-2023-1234",
      "fixed_version": "v1.2.3",
      "trace": [
        {
          "module": "golang.org/x/net",
          "version": "v0.1.0",
          "package": "golang.org/x/net/http2",
          "function": "...",
          "position": {"filename": "src/main.go", "line": 10, "column": 1}
        }
      ]
    }
  }
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


def main(argv):
    if len(argv) < 2:
        sys.stderr.write("Usage: parse-govulncheck.py <json_file>\n")
        sys.exit(1)

    json_file = argv[1]

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            content = fh.read()
    except OSError as exc:
        sys.stderr.write("Cannot read {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    for line in content.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
        except json.JSONDecodeError:
            continue

        if not isinstance(obj, dict):
            continue

        finding_data = obj.get("finding")
        if not finding_data:
            continue

        osv_id = finding_data.get("osv", "unknown")
        trace = finding_data.get("trace", [])

        # Use the first trace entry for location
        module = ""
        version = ""
        file_path = "go.mod"
        line_no = 1

        if trace:
            first = trace[0]
            module = first.get("module", "")
            version = first.get("version", "")
            pos = first.get("position")
            if pos and isinstance(pos, dict):
                file_path = pos.get("filename", "go.mod") or "go.mod"
                line_no = max(1, int(pos.get("line", 1)))

        message = "Go vulnerability {0} in {1} {2}".format(osv_id, module, version).strip()
        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(osv_id, module, version)
        )

        finding = normalize.make_finding(
            check_id="deps/go-vulnerability",
            rule_id=osv_id,
            severity="high",
            confidence="high",
            path=file_path,
            line=line_no,
            message=message,
            fingerprint=fp,
            properties={
                "tool": "govulncheck",
                "package": module,
            },
        )
        normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-bandit.sh

#!/usr/bin/env bash
# CCGM audit spine -- bandit wrapper
# Scans Python source for common security issues.
#
# Usage: wrap-bandit.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-bandit.py"

# shellcheck source=exclude.sh
. "$SCRIPT_DIR/exclude.sh"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip bandit \
    "sast/python-security:no repo_root argument supplied"
  exit 0
fi

# Only run if this looks like a Python project
if [[ ! -f "$REPO_ROOT/requirements.txt" && ! -f "$REPO_ROOT/pyproject.toml" && ! -f "$REPO_ROOT/setup.py" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip bandit \
    "sast/python-security:no Python project files found -- bandit skipped"
  exit 0
fi

if ! command -v bandit > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip bandit \
    "sast/python-security:bandit not installed -- Python SAST scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-bandit-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# -r: recursive  -f json: JSON output
# -q: quiet (suppress progress)
# -x: exclude vendored/generated dirs so a recursive scan does not descend into
#     node_modules, .venv, stale worktrees, etc. (field report #1).
# repo_root passed as positional arg
ccgm_bandit_exclude_csv
set +e
bandit -r "$REPO_ROOT" -x "$CCGM_BANDIT_EXCLUDE" -f json -q -o "$TMPFILE" > /dev/null 2>&1 || true
set -e

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-bandit.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse bandit JSON output -> finding.schema.json JSONL.

Usage: parse-bandit.py <bandit_json_file> <repo_root>

Bandit JSON shape:
  {
    "results": [
      {
        "test_id": "B102",
        "test_name": "exec_used",
        "issue_severity": "MEDIUM",
        "issue_confidence": "HIGH",
        "issue_text": "Use of exec detected.",
        "filename": "/abs/path/to/file.py",
        "line_number": 10,
        "line_range": [10, 11],
        "code": "exec(user_input)"
      }
    ]
  }
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    "HIGH": "high",
    "MEDIUM": "medium",
    "LOW": "low",
    "UNDEFINED": "info",
}

_CONFIDENCE_MAP = {
    "HIGH": "high",
    "MEDIUM": "medium",
    "LOW": "low",
    "UNDEFINED": "low",
}


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-bandit.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    results = data.get("results", [])

    for item in results:
        if not isinstance(item, dict):
            continue

        test_id = item.get("test_id", "B000")
        test_name = item.get("test_name", "unknown")
        sev_raw = item.get("issue_severity", "MEDIUM")
        conf_raw = item.get("issue_confidence", "MEDIUM")
        message = item.get("issue_text", "Bandit finding")
        file_path = item.get("filename", "")
        line_no = max(1, int(item.get("line_number", 1)))
        line_range = item.get("line_range", [])
        code_snippet = item.get("code", "")

        # Make path repo-relative
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        end_line = None
        if line_range and len(line_range) >= 2:
            end_candidate = int(line_range[-1])
            if end_candidate >= line_no:
                end_line = end_candidate

        severity = _SEVERITY_MAP.get(sev_raw.upper(), "medium")
        confidence = _CONFIDENCE_MAP.get(conf_raw.upper(), "medium")

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}:{3}".format(file_path, line_no, test_id, code_snippet[:64])
        )

        finding = normalize.make_finding(
            check_id="sast/{0}".format(test_name.lower().replace("_", "-")),
            rule_id=test_id,
            severity=severity,
            confidence=confidence,
            path=file_path,
            line=line_no,
            message=message,
            fingerprint=fp,
            end_line=end_line,
            properties={"tool": "bandit"},
            # Heuristic SAST -- FP-prone on test fixtures, so worker triage must
            # be able to dismiss it (#4).
            detection="hybrid",
        )
        normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-hadolint.sh

#!/usr/bin/env bash
# CCGM audit spine -- hadolint wrapper
# Lints Dockerfiles for best practices and security issues.
#
# Usage: wrap-hadolint.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-hadolint.py"

# shellcheck source=exclude.sh
. "$SCRIPT_DIR/exclude.sh"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip hadolint \
    "iac/dockerfile-issue:no repo_root argument supplied"
  exit 0
fi

if ! command -v hadolint > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip hadolint \
    "iac/dockerfile-issue:hadolint not installed -- Dockerfile lint skipped"
  exit 0
fi

# Find all Dockerfiles -- use find with -print0 for injection safety
TMPFILES_LIST="$(mktemp /tmp/ccgm-hadolint-files-XXXXXX.txt)"
TMPFILE="$(mktemp /tmp/ccgm-hadolint-XXXXXX.json)"
trap 'rm -f "$TMPFILES_LIST" "$TMPFILE"' EXIT

# Collect Dockerfiles via find -- NUL-delimited read loop.
# Bash-3.2-portable: mapfile -d '' requires bash 4+; use while-read instead.
# Prune vendored/generated dirs and stale worktrees so a Dockerfile inside
# node_modules or a duplicate worktree copy is not linted (field report #1).
ccgm_find_prune_args
DOCKERFILES=()
while IFS= read -r -d '' f; do
  DOCKERFILES+=("$f")
done < <(
  find "$REPO_ROOT" \
    \( "${CCGM_FIND_PRUNE[@]}" \) -prune -o \
    -type f \
    \( -name "Dockerfile" -o -name "Dockerfile.*" \) \
    -print0
)

if [[ ${#DOCKERFILES[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip hadolint \
    "iac/dockerfile-issue:no Dockerfiles found -- hadolint skipped"
  exit 0
fi

# Run hadolint on each Dockerfile -- each path is a separate array element
# (never interpolated into a shell string)
set +e
hadolint --format json "${DOCKERFILES[@]}" > "$TMPFILE" 2>/dev/null || true
set -e

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-hadolint.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse hadolint JSON output -> finding.schema.json JSONL.

Usage: parse-hadolint.py <hadolint_json_file> <repo_root>

hadolint --format json emits a JSON array:
  [
    {
      "file": "/abs/path/Dockerfile",
      "line": 3,
      "column": 1,
      "level": "warning",
      "code": "DL3008",
      "message": "Pin versions in apt get install."
    }
  ]
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    "error": "high",
    "warning": "medium",
    "info": "low",
    "style": "info",
}


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-hadolint.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not isinstance(data, list):
        sys.stderr.write("Expected JSON array from hadolint\n")
        sys.exit(0)

    for item in data:
        if not isinstance(item, dict):
            continue

        file_path = item.get("file", "Dockerfile")
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        line_no = max(1, int(item.get("line", 1)))
        level = item.get("level", "warning")
        code = item.get("code", "DL0000")
        message = item.get("message", "hadolint finding")

        severity = _SEVERITY_MAP.get(level.lower(), "medium")

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(file_path, line_no, code)
        )

        finding = normalize.make_finding(
            check_id="iac/dockerfile-issue",
            rule_id=code,
            severity=severity,
            confidence="high",
            path=file_path,
            line=line_no,
            message=message,
            fingerprint=fp,
            properties={"tool": "hadolint"},
        )
        normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-actionlint.sh

#!/usr/bin/env bash
# CCGM audit spine -- actionlint + zizmor wrapper
# Lints GitHub Actions workflow files.
# Runs actionlint first; runs zizmor if present for deeper security analysis.
#
# Usage: wrap-actionlint.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-actionlint.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip actionlint \
    "ci/workflow-issue:no repo_root argument supplied"
  exit 0
fi

# Only run if workflow files exist
WORKFLOWS_DIR="$REPO_ROOT/.github/workflows"
if [[ ! -d "$WORKFLOWS_DIR" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip actionlint \
    "ci/workflow-issue:no .github/workflows directory -- actionlint skipped"
  exit 0
fi

# Collect workflow files safely using a NUL-delimited read loop.
# Bash-3.2-portable: mapfile -d '' requires bash 4+; use while-read instead.
WORKFLOW_FILES=()
while IFS= read -r -d '' f; do
  WORKFLOW_FILES+=("$f")
done < <(
  find "$WORKFLOWS_DIR" \
    -maxdepth 1 \
    -type f \
    \( -name "*.yml" -o -name "*.yaml" \) \
    -print0
)

if [[ ${#WORKFLOW_FILES[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip actionlint \
    "ci/workflow-issue:no workflow YAML files found -- actionlint skipped"
  exit 0
fi

if ! command -v actionlint > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip actionlint \
    "ci/workflow-issue:actionlint not installed -- GitHub Actions lint skipped" \
    "ci/workflow-injection:actionlint not installed -- expression injection check skipped"
  exit 0
fi

TMPFILE_AL="$(mktemp /tmp/ccgm-actionlint-XXXXXX.json)"
TMPFILE_ZI="$(mktemp /tmp/ccgm-zizmor-XXXXXX.json)"
trap 'rm -f "$TMPFILE_AL" "$TMPFILE_ZI"' EXIT

# actionlint: -format json, workflow files as argv array
set +e
actionlint -format '{{json .}}' "${WORKFLOW_FILES[@]}" > "$TMPFILE_AL" 2>/dev/null || true
set -e

# zizmor (optional): deeper security analysis
if command -v zizmor > /dev/null 2>&1; then
  set +e
  zizmor --format json "${WORKFLOW_FILES[@]}" > "$TMPFILE_ZI" 2>/dev/null || true
  set -e
fi

python3 "$PARSE_PY" "$TMPFILE_AL" "$TMPFILE_ZI" "$REPO_ROOT"

skills/audit/scripts/spine/parse-actionlint.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse actionlint + zizmor JSON -> finding.schema.json JSONL.

Usage: parse-actionlint.py <actionlint_json> <zizmor_json> <repo_root>

actionlint -format '{{json .}}' emits a JSON array:
  [
    {
      "message": "...",
      "filepath": ".github/workflows/ci.yml",
      "line": 10,
      "column": 1,
      "kind": "error",
      "snippet": "...",
      "end_column": 20
    }
  ]

zizmor --format json emits a SARIF-like structure (best-effort parse).
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


def process_actionlint(data, repo_root):
    if not isinstance(data, list):
        return

    for item in data:
        if not isinstance(item, dict):
            continue

        file_path = item.get("filepath", ".github/workflows/unknown.yml")
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        line_no = max(1, int(item.get("line", 1)))
        message = item.get("message", "actionlint finding")
        kind = item.get("kind", "error")

        severity = "medium" if kind == "error" else "low"

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(file_path, line_no, message[:64])
        )

        finding = normalize.make_finding(
            check_id="ci/workflow-issue",
            rule_id="actionlint/{0}".format(kind),
            severity=severity,
            confidence="high",
            path=file_path,
            line=line_no,
            message=message,
            fingerprint=fp,
            properties={"tool": "actionlint"},
        )
        normalize.emit_finding(finding)


def process_zizmor(data, repo_root):
    """Parse zizmor output -- handles both SARIF and simple array shapes."""
    if not data:
        return

    # Try SARIF shape first
    runs = data.get("runs", []) if isinstance(data, dict) else []
    for run in runs:
        for result in run.get("results", []):
            if not isinstance(result, dict):
                continue

            rule_id = result.get("ruleId", "zizmor/unknown")
            message_obj = result.get("message", {})
            message = message_obj.get("text", "zizmor finding") if isinstance(message_obj, dict) else str(message_obj)
            sev_raw = result.get("level", "warning")
            severity = "high" if sev_raw == "error" else "medium" if sev_raw == "warning" else "low"

            locations = result.get("locations", [{}])
            loc = locations[0] if locations else {}
            pl = loc.get("physicalLocation", {})
            art = pl.get("artifactLocation", {})
            region = pl.get("region", {})

            file_path = art.get("uri", ".github/workflows/unknown.yml")
            if file_path.startswith(repo_root + "/"):
                file_path = file_path[len(repo_root) + 1:]

            line_no = max(1, int(region.get("startLine", 1)))

            # Use tool fingerprint when schema-valid;
            # fingerprint_from_tool returns None for invalid values.
            fps = result.get("partialFingerprints", {})
            tool_fp = fps.get("primaryLocationLineHash", "")
            fp = None
            if tool_fp:
                fp = normalize.fingerprint_from_tool(tool_fp)
            if fp is None:
                fp = normalize.make_content_fingerprint(
                    "{0}:{1}:{2}".format(file_path, line_no, rule_id)
                )

            finding = normalize.make_finding(
                check_id="ci/workflow-injection",
                rule_id=rule_id,
                severity=severity,
                confidence="high",
                path=file_path,
                line=line_no,
                message=message,
                fingerprint=fp,
                properties={"tool": "zizmor"},
            )
            normalize.emit_finding(finding)


def main(argv):
    if len(argv) < 4:
        sys.stderr.write("Usage: parse-actionlint.py <al_json> <zi_json> <repo_root>\n")
        sys.exit(1)

    al_file = argv[1]
    zi_file = argv[2]
    repo_root = argv[3].rstrip("/")

    # actionlint
    if os.path.isfile(al_file) and os.path.getsize(al_file) > 0:
        try:
            with open(al_file, "r", encoding="utf-8") as fh:
                al_data = json.load(fh)
            process_actionlint(al_data, repo_root)
        except (OSError, json.JSONDecodeError) as exc:
            sys.stderr.write("Cannot parse actionlint output: {0}\n".format(exc))

    # zizmor (optional)
    if os.path.isfile(zi_file) and os.path.getsize(zi_file) > 0:
        try:
            with open(zi_file, "r", encoding="utf-8") as fh:
                zi_data = json.load(fh)
            process_zizmor(zi_data, repo_root)
        except (OSError, json.JSONDecodeError) as exc:
            sys.stderr.write("Cannot parse zizmor output: {0}\n".format(exc))


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-trivy.sh

#!/usr/bin/env bash
# CCGM audit spine -- trivy wrapper
# Scans filesystem for vulnerabilities (OS packages, language deps, IaC misconfig).
#
# Usage: wrap-trivy.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-trivy.py"

# shellcheck source=exclude.sh
. "$SCRIPT_DIR/exclude.sh"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip trivy \
    "deps/container-vulnerability:no repo_root argument supplied"
  exit 0
fi

if ! command -v trivy > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip trivy \
    "deps/container-vulnerability:trivy not installed -- container/IaC scan skipped" \
    "iac/misconfig:trivy not installed -- IaC misconfiguration scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-trivy-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# trivy fs: scan the filesystem
# --format json: machine-readable output
# --exit-code 0: always exit 0 (we handle findings ourselves)
# --quiet: suppress progress bars
# --skip-dirs: exclude vendored/generated dirs and stale worktrees so trivy
#   does not scan a 566 MB node_modules or duplicate worktree trees (#1).
# repo_root passed as positional arg (never interpolated into string)
ccgm_trivy_skip_args
set +e
trivy fs \
  --format json \
  --output "$TMPFILE" \
  --exit-code 0 \
  --quiet \
  --scanners vuln,misconfig,secret \
  "${CCGM_FLAGS[@]}" \
  "$REPO_ROOT" \
  > /dev/null 2>&1
TRIVY_EXIT=$?
set -e

if [[ $TRIVY_EXIT -ne 0 && ! -s "$TMPFILE" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip trivy \
    "deps/container-vulnerability:trivy exited non-zero with no output"
  exit 0
fi

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-trivy.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse trivy JSON output -> finding.schema.json JSONL.

Usage: parse-trivy.py <trivy_json_file> <repo_root>

Trivy JSON shape:
  {
    "Results": [
      {
        "Target": "package-lock.json",
        "Type": "npm",
        "Vulnerabilities": [
          {
            "VulnerabilityID": "CVE-2023-1234",
            "PkgName": "lodash",
            "InstalledVersion": "4.17.19",
            "Severity": "HIGH",
            "Title": "Prototype Pollution",
            "Description": "...",
            "PrimaryURL": "https://..."
          }
        ],
        "Misconfigurations": [
          {
            "ID": "DS002",
            "Title": "Image user should not be 'root'",
            "Severity": "HIGH",
            "Message": "...",
            "CauseMetadata": {"StartLine": 5, "EndLine": 7}
          }
        ]
      }
    ]
  }
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    "CRITICAL": "critical",
    "HIGH": "high",
    "MEDIUM": "medium",
    "LOW": "low",
    "UNKNOWN": "info",
    "INFORMATIONAL": "info",
}


def map_severity(raw):
    if isinstance(raw, str):
        return _SEVERITY_MAP.get(raw.upper(), "medium")
    return "medium"


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-trivy.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    results = data.get("Results", [])

    for result in results:
        if not isinstance(result, dict):
            continue

        target = result.get("Target", "unknown")
        # Make path repo-relative
        if target.startswith(repo_root + "/"):
            target = target[len(repo_root) + 1:]

        # Vulnerabilities
        for vuln in result.get("Vulnerabilities", []) or []:
            if not isinstance(vuln, dict):
                continue

            vuln_id = vuln.get("VulnerabilityID", "CVE-unknown")
            pkg = vuln.get("PkgName", "unknown")
            version = vuln.get("InstalledVersion", "")
            severity = map_severity(vuln.get("Severity", "MEDIUM"))
            title = vuln.get("Title", vuln_id)
            url = vuln.get("PrimaryURL", "")

            message = "{0}: {1} in {2} {3}".format(vuln_id, title, pkg, version).strip()
            if url:
                message = "{0} -- {1}".format(message, url)

            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}:{3}".format(target, vuln_id, pkg, version)
            )

            finding = normalize.make_finding(
                check_id="deps/container-vulnerability",
                rule_id=vuln_id,
                severity=severity,
                confidence="high",
                path=target,
                line=1,
                message=message,
                fingerprint=fp,
                properties={
                    "tool": "trivy",
                    "package": pkg,
                },
            )
            normalize.emit_finding(finding)

        # Misconfigurations
        for misconfig in result.get("Misconfigurations", []) or []:
            if not isinstance(misconfig, dict):
                continue

            mc_id = misconfig.get("ID", "MC0000")
            title = misconfig.get("Title", "Misconfiguration")
            severity = map_severity(misconfig.get("Severity", "MEDIUM"))
            message = misconfig.get("Message", title)
            cause = misconfig.get("CauseMetadata", {})
            line_no = max(1, int(cause.get("StartLine", 1))) if cause else 1
            end_line_raw = cause.get("EndLine") if cause else None
            end_line = int(end_line_raw) if end_line_raw and int(end_line_raw) >= line_no else None

            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}".format(target, mc_id, line_no)
            )

            finding = normalize.make_finding(
                check_id="iac/misconfig",
                rule_id=mc_id,
                severity=severity,
                confidence="high",
                path=target,
                line=line_no,
                message="{0}: {1}".format(mc_id, message),
                fingerprint=fp,
                end_line=end_line,
                properties={"tool": "trivy"},
            )
            normalize.emit_finding(finding)

        # Secrets
        for secret in result.get("Secrets", []) or []:
            if not isinstance(secret, dict):
                continue

            rule_id = secret.get("RuleID", "secret-unknown")
            title = secret.get("Title", "Secret detected")
            severity = "high"
            start_line = max(1, int(secret.get("StartLine", 1)))
            end_line_raw = secret.get("EndLine")
            end_line = int(end_line_raw) if end_line_raw and int(end_line_raw) >= start_line else None
            match_val = secret.get("Match", "")

            # Redact the matched secret value
            redacted = normalize.redact_secret(match_val) if match_val else "[redacted]"
            message = "{0} [{1}] matched: {2}".format(title, rule_id, redacted)

            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}:{3}".format(target, start_line, rule_id, match_val[:8] if match_val else "")
            )

            finding = normalize.make_finding(
                check_id="secrets/leaked-credential",
                rule_id="trivy/{0}".format(rule_id),
                severity=severity,
                confidence="high",
                path=target,
                line=start_line,
                message=message,
                fingerprint=fp,
                end_line=end_line,
                properties={"tool": "trivy"},
            )
            normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-zizmor.sh

#!/usr/bin/env bash
# CCGM audit spine -- zizmor wrapper
# Audits GitHub Actions workflow files for security issues:
#   - pull_request_target misuse (dangerous triggers)
#   - excessive GITHUB_TOKEN permissions
#   - expression injection via ${{ github.event.* }}
#
# Usage: wrap-zizmor.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-zizmor.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip zizmor \
    "cicd/dangerous-trigger:no repo_root argument supplied" \
    "cicd/excessive-permissions:no repo_root argument supplied" \
    "cicd/script-injection:no repo_root argument supplied"
  exit 0
fi

# Only run if workflow files exist
WORKFLOWS_DIR="$REPO_ROOT/.github/workflows"
if [[ ! -d "$WORKFLOWS_DIR" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip zizmor \
    "cicd/dangerous-trigger:no .github/workflows directory -- zizmor skipped" \
    "cicd/excessive-permissions:no .github/workflows directory -- zizmor skipped" \
    "cicd/script-injection:no .github/workflows directory -- zizmor skipped"
  exit 0
fi

# Collect workflow files safely using a NUL-delimited read loop.
# Bash-3.2-portable: mapfile -d '' requires bash 4+; use while-read instead.
WORKFLOW_FILES=()
while IFS= read -r -d '' f; do
  WORKFLOW_FILES+=("$f")
done < <(
  find "$WORKFLOWS_DIR" \
    -maxdepth 1 \
    -type f \
    \( -name "*.yml" -o -name "*.yaml" \) \
    -print0
)

if [[ ${#WORKFLOW_FILES[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip zizmor \
    "cicd/dangerous-trigger:no workflow YAML files found -- zizmor skipped" \
    "cicd/excessive-permissions:no workflow YAML files found -- zizmor skipped" \
    "cicd/script-injection:no workflow YAML files found -- zizmor skipped"
  exit 0
fi

if ! command -v zizmor > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip zizmor \
    "cicd/dangerous-trigger:zizmor not installed -- dangerous trigger check skipped" \
    "cicd/excessive-permissions:zizmor not installed -- permissions check skipped" \
    "cicd/script-injection:zizmor not installed -- script injection check skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-zizmor-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# zizmor --format sarif: emits SARIF JSON
# Pass workflow files as argv (never interpolated into a shell string).
# zizmor exits non-zero when findings exist; always exit 0 in the wrapper.
set +e
zizmor --format sarif "${WORKFLOW_FILES[@]}" > "$TMPFILE" 2>/dev/null
set -e

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-zizmor.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse zizmor SARIF JSON -> finding.schema.json JSONL.

Usage: parse-zizmor.py <zizmor_sarif_json> <repo_root>

# -------------------------------------------------------------------------
# Assumed zizmor --format sarif output shape (documented; not installed locally)
#
# zizmor emits a SARIF 2.1.0 document.  Representative structure:
#
#   {
#     "$schema": "https://...",
#     "version": "2.1.0",
#     "runs": [
#       {
#         "tool": {
#           "driver": {
#             "name": "zizmor",
#             "rules": [
#               {
#                 "id": "excessive-permissions",
#                 "name": "excessive-permissions",
#                 "shortDescription": { "text": "Overbroad GITHUB_TOKEN permissions" },
#                 "defaultConfiguration": { "level": "warning" }
#               }
#             ]
#           }
#         },
#         "results": [
#           {
#             "ruleId": "excessive-permissions",
#             "level": "warning",
#             "message": { "text": "Job 'build' has excessive permissions: write-all" },
#             "locations": [
#               {
#                 "physicalLocation": {
#                   "artifactLocation": { "uri": ".github/workflows/ci.yml", "uriBaseId": "%SRCROOT%" },
#                   "region": { "startLine": 12, "startColumn": 1 }
#                 }
#               }
#             ],
#             "partialFingerprints": {
#               "primaryLocationLineHash": "abc123def456"
#             }
#           }
#         ]
#       }
#     ]
#   }
#
# Known zizmor rule IDs (from source / docs as of 2024-2025):
#   - dangerous-triggers          -> cicd/dangerous-trigger (critical/high)
#   - excessive-permissions       -> cicd/excessive-permissions (medium/high)
#   - template-injection          -> cicd/script-injection (high)
#   - expression-injection        -> cicd/script-injection (high)
#   - artipacked                  -> cicd/excessive-permissions (medium)
#   - pull-request-target         -> cicd/dangerous-trigger (critical)
#   - unpinned-uses               -> cicd/unpinned-action (high)
#   - (any other)                 -> cicd/workflow-security-issue (medium, fallback)
#
# Level mapping:  error -> high, warning -> medium, note/none -> low
# -------------------------------------------------------------------------
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


# Mapping from zizmor rule-id (lower-case) to (check_id, severity).
# Matched by prefix/substring so "template-injection" and "expression-injection"
# both land on cicd/script-injection.
_RULE_MAP = {
    "dangerous-triggers":   ("cicd/dangerous-trigger",    "critical"),
    "pull-request-target":  ("cicd/dangerous-trigger",    "critical"),
    "template-injection":   ("cicd/script-injection",     "high"),
    "expression-injection": ("cicd/script-injection",     "high"),
    "excessive-permissions":("cicd/excessive-permissions", "medium"),
    "artipacked":           ("cicd/excessive-permissions", "medium"),
    "unpinned-uses":        ("cicd/unpinned-action",       "high"),
}

_LEVEL_TO_SEVERITY = {
    "error":   "high",
    "warning": "medium",
    "note":    "low",
    "none":    "low",
}


def _map_rule(rule_id_raw):
    """Return (check_id, severity) for a raw zizmor rule ID."""
    rule_lower = rule_id_raw.lower()
    for key, val in _RULE_MAP.items():
        if key in rule_lower:
            return val
    return ("cicd/workflow-security-issue", "medium")


def process_zizmor_sarif(data, repo_root):
    """
    Parse a SARIF document emitted by 'zizmor --format sarif' and emit
    normalized findings to stdout.
    """
    if not isinstance(data, dict):
        return

    for run in data.get("runs", []):
        if not isinstance(run, dict):
            continue

        for result in run.get("results", []):
            if not isinstance(result, dict):
                continue

            rule_id_raw = result.get("ruleId", "unknown")
            check_id, default_severity = _map_rule(rule_id_raw)

            # Level may override the default severity mapping
            level = result.get("level", "warning").lower()
            severity = _LEVEL_TO_SEVERITY.get(level, "medium")
            # critical is not a SARIF level; only override when not already critical
            if default_severity == "critical":
                severity = "critical"
            elif default_severity == "high" and severity == "medium":
                severity = "high"

            message_obj = result.get("message", {})
            if isinstance(message_obj, dict):
                message = message_obj.get("text", "zizmor finding")
            else:
                message = str(message_obj) if message_obj else "zizmor finding"

            locations = result.get("locations", [{}])
            loc = locations[0] if locations else {}
            pl = loc.get("physicalLocation", {})
            art = pl.get("artifactLocation", {})
            region = pl.get("region", {})

            file_path = art.get("uri", ".github/workflows/unknown.yml")
            # Strip uriBaseId markers like "%SRCROOT%/" that SARIF tools emit
            if file_path.startswith("%SRCROOT%/"):
                file_path = file_path[len("%SRCROOT%/"):]
            # Strip absolute repo prefix if present
            if file_path.startswith(repo_root + "/"):
                file_path = file_path[len(repo_root) + 1:]

            line_no = max(1, int(region.get("startLine", 1)))

            # Prefer tool-supplied fingerprint when schema-valid;
            # fingerprint_from_tool returns None for invalid values.
            fps = result.get("partialFingerprints", {})
            tool_fp = fps.get("primaryLocationLineHash", "")
            fp = None
            if tool_fp:
                fp = normalize.fingerprint_from_tool(tool_fp)
            if fp is None:
                fp = normalize.make_content_fingerprint(
                    "{0}:{1}:{2}".format(file_path, line_no, rule_id_raw)
                )

            finding = normalize.make_finding(
                check_id=check_id,
                rule_id="zizmor/{0}".format(rule_id_raw),
                severity=severity,
                confidence="high",
                path=file_path,
                line=line_no,
                message=message,
                fingerprint=fp,
                properties={"tool": "zizmor"},
            )
            normalize.emit_finding(finding)


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-zizmor.py <zizmor_sarif_json> <repo_root>\n")
        sys.exit(1)

    sarif_file = argv[1]
    repo_root = argv[2].rstrip("/")

    if not os.path.isfile(sarif_file) or os.path.getsize(sarif_file) == 0:
        # No output from zizmor (no findings) -- emit nothing
        return

    try:
        with open(sarif_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
        process_zizmor_sarif(data, repo_root)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse zizmor output: {0}\n".format(exc))


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-pinact.sh

#!/usr/bin/env bash
# CCGM audit spine -- pinact wrapper
# Checks GitHub Actions workflows for unpinned third-party actions
# (actions referenced by mutable tag or branch instead of a full commit SHA).
#
# Usage: wrap-pinact.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-pinact.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip pinact \
    "cicd/unpinned-action:no repo_root argument supplied"
  exit 0
fi

# Only run if workflow files exist
WORKFLOWS_DIR="$REPO_ROOT/.github/workflows"
if [[ ! -d "$WORKFLOWS_DIR" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip pinact \
    "cicd/unpinned-action:no .github/workflows directory -- pinact skipped"
  exit 0
fi

# Collect workflow files safely using a NUL-delimited read loop.
# Bash-3.2-portable: mapfile -d '' requires bash 4+; use while-read instead.
WORKFLOW_FILES=()
while IFS= read -r -d '' f; do
  WORKFLOW_FILES+=("$f")
done < <(
  find "$WORKFLOWS_DIR" \
    -maxdepth 1 \
    -type f \
    \( -name "*.yml" -o -name "*.yaml" \) \
    -print0
)

if [[ ${#WORKFLOW_FILES[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip pinact \
    "cicd/unpinned-action:no workflow YAML files found -- pinact skipped"
  exit 0
fi

if ! command -v pinact > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip pinact \
    "cicd/unpinned-action:pinact not installed -- action-pinning check skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-pinact-XXXXXX.txt)"
trap 'rm -f "$TMPFILE"' EXIT

# pinact run --check: exits non-zero if unpinned actions are found.
# Output is a diff-like text showing which actions need pinning.
# Pass workflow files as argv (never interpolated into a shell string).
set +e
pinact run --check "${WORKFLOW_FILES[@]}" > "$TMPFILE" 2>&1
set -e

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-pinact.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse pinact output -> finding.schema.json JSONL.

Usage: parse-pinact.py <pinact_output_file> <repo_root>

# -------------------------------------------------------------------------
# Assumed pinact output shape (documented; not installed locally)
#
# pinact run --check emits a diff-like text to stdout.
# Each unpinned action produces a block like:
#
#   .github/workflows/ci.yml
#     uses: actions/checkout@v4
#   ->
#     uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
#
# Alternatively, pinact may emit one line per finding in a more structured form:
#
#   .github/workflows/ci.yml:12: actions/checkout@v4 -> actions/checkout@<sha> # v4
#
# We handle BOTH formats:
#   1. "file:line: action@ref" lines (structured single-line format).
#   2. Multi-line diff blocks starting with a bare filename followed by "uses:" lines.
#
# For format 1, a line matches the pattern:
#   ^<filepath>:[0-9]+: <owner>/<repo>@<ref> ->
#
# For format 2, a diff block looks like:
#   ^<filepath>$              (bare filename, no colon-number)
#   ^  uses: <owner>/<repo>@<ref>$   (the unpinned reference)
#   ^->$                              (arrow separator)
#   ^  uses: <owner>/<repo>@<sha>    (the pinned suggestion)
#
# In both cases we extract: filepath, line number (if present, else 1),
# action reference (e.g. "actions/checkout@v4"), and emit one
# cicd/unpinned-action finding per action.
#
# A third fallback: any "uses:" line containing an action ref that does NOT
# look like a 40-char hex SHA is treated as a finding. This handles output
# formats we haven't seen but that still contain "uses:" lines.
# -------------------------------------------------------------------------
"""

import json
import os
import re
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


# Regex: structured single-line format
# e.g. ".github/workflows/ci.yml:12: actions/checkout@v4 ->"
_STRUCTURED_RE = re.compile(
    r"^(?P<path>[^\s:][^\s]*\.ya?ml):(?P<line>\d+):\s+"
    r"(?P<action>[^\s@]+@[^\s]+)"
    r"\s+->"
)

# Regex: "uses:" line inside a diff block
# e.g. "  uses: actions/checkout@v4" or "- uses: actions/checkout@v4"
_USES_RE = re.compile(
    r"^\s*(?:[-+])?\s*uses:\s+(?P<action>[^\s@]+@(?P<ref>[^\s#]+))"
)

# A fully-pinned ref is a 40-char hex SHA (optionally followed by comment)
_SHA_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)


def _is_sha(ref):
    return bool(_SHA_RE.match(ref.split()[0].strip()))


def process_pinact(text, repo_root):
    """Parse pinact output text and emit normalized findings."""
    lines = text.splitlines()
    emitted = set()  # deduplicate by (path, action)

    # Pass 1: structured single-line format
    for raw in lines:
        m = _STRUCTURED_RE.match(raw)
        if m:
            file_path = m.group("path")
            if file_path.startswith(repo_root + "/"):
                file_path = file_path[len(repo_root) + 1:]
            line_no = max(1, int(m.group("line")))
            action = m.group("action")
            key = (file_path, action)
            if key in emitted:
                continue
            emitted.add(key)
            _emit_unpinned(file_path, line_no, action, repo_root)
        # Track the current file from bare filename lines for pass 2
    # Pass 2: diff-block / "uses:" line format
    # Walk lines; when we see a "uses:" line that is unpinned, emit a finding.
    # We track the current file from preceding bare filename lines.
    current_file = None
    current_line = 0

    for raw in lines:
        stripped = raw.strip()

        # Bare filename line (no line number suffix, ends with .yml/.yaml)
        if re.match(r"^[^\s:]+\.ya?ml$", stripped) and not stripped.startswith("-"):
            candidate = stripped
            if candidate.startswith(repo_root + "/"):
                candidate = candidate[len(repo_root) + 1:]
            current_file = candidate
            current_line = 0
            continue

        # "uses:" line
        m = _USES_RE.match(raw)
        if m:
            action = m.group("action")
            ref = m.group("ref").rstrip()
            if not _is_sha(ref):
                file_path = current_file or ".github/workflows/unknown.yml"
                line_no = max(1, current_line)
                key = (file_path, action)
                if key not in emitted:
                    emitted.add(key)
                    _emit_unpinned(file_path, line_no, action, repo_root)
            continue

        # Line-number tracking: look for ":<number>:" patterns embedded in lines
        lno_m = re.search(r":(\d+):", raw)
        if lno_m and current_file:
            current_line = int(lno_m.group(1))


def _emit_unpinned(file_path, line_no, action, _repo_root):
    """Emit one cicd/unpinned-action finding."""
    # action is e.g. "actions/checkout@v4"
    message = "Unpinned action: {0} (use a full commit SHA instead of a mutable ref)".format(action)
    fp = normalize.make_content_fingerprint(
        "{0}:{1}:{2}".format(file_path, line_no, action)
    )
    finding = normalize.make_finding(
        check_id="cicd/unpinned-action",
        rule_id="pinact/unpinned-uses",
        severity="high",
        confidence="high",
        path=file_path,
        line=line_no,
        message=message,
        fingerprint=fp,
        properties={"tool": "pinact", "action_ref": action},
    )
    normalize.emit_finding(finding)


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-pinact.py <pinact_output_file> <repo_root>\n")
        sys.exit(1)

    output_file = argv[1]
    repo_root = argv[2].rstrip("/")

    if not os.path.isfile(output_file) or os.path.getsize(output_file) == 0:
        # No output from pinact (no unpinned actions found) -- emit nothing
        return

    try:
        with open(output_file, "r", encoding="utf-8", errors="replace") as fh:
            text = fh.read()
        process_pinact(text, repo_root)
    except OSError as exc:
        sys.stderr.write("Cannot read pinact output: {0}\n".format(exc))


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-squawk.sh

#!/usr/bin/env bash
# CCGM audit spine -- squawk wrapper
# Lints PostgreSQL migration files for dangerous patterns (missing CONCURRENTLY,
# unquoted reserved keywords, etc.).
#
# Usage: wrap-squawk.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0
#
# squawk JSON output shape (squawk --reporter json <files>):
#   [
#     {
#       "file": "db/migrations/0001_create_users.sql",
#       "violations": [
#         {
#           "rule": "require-concurrent-index-creation",
#           "level": "Warning",
#           "messages": [
#             {
#               "Note": "..."
#             }
#           ],
#           "position": {
#             "start": { "line": 5, "col": 1 },
#             "end":   { "line": 5, "col": 40 }
#           }
#         }
#       ]
#     }
#   ]
#
# squawk rule -> check_id mapping (in parse-squawk.py):
#   require-concurrent-index-creation -> dm/index-without-concurrently
#   (all others)                      -> dm/squawk-violation (generic)

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-squawk.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip squawk \
    "dm/index-without-concurrently:squawk not installed -- index-without-CONCURRENTLY check skipped" \
    "dm/unquoted-reserved-keyword:squawk not installed -- reserved-keyword check skipped (grep/llm fallback available)"
  exit 0
fi

# Locate migration directories (mirrors detect-ecosystems.sh has_migrations logic)
MIGRATION_DIRS=()
for candidate in \
    "supabase/migrations" \
    "prisma/migrations" \
    "db/migrate" \
    "db/migrations" \
    "database/migrations"; do
  if [[ -d "$REPO_ROOT/$candidate" ]]; then
    MIGRATION_DIRS+=("$REPO_ROOT/$candidate")
  fi
done

if [[ ${#MIGRATION_DIRS[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip squawk \
    "dm/index-without-concurrently:no migration directories found -- squawk skipped" \
    "dm/unquoted-reserved-keyword:no migration directories found -- squawk skipped"
  exit 0
fi

if ! command -v squawk > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip squawk \
    "dm/index-without-concurrently:squawk not installed -- index-without-CONCURRENTLY check skipped" \
    "dm/unquoted-reserved-keyword:squawk not installed -- reserved-keyword check skipped (grep/llm fallback available)"
  exit 0
fi

# Collect .sql files from migration directories using a NUL-delimited read loop.
# Bash-3.2-portable: mapfile -d '' requires bash 4+; use while-read instead.
SQL_FILES=()
while IFS= read -r -d '' f; do
  SQL_FILES+=("$f")
done < <(
  for dir in "${MIGRATION_DIRS[@]}"; do
    find "$dir" \
      -type f \
      -name "*.sql" \
      -not -path "*/.git/*" \
      -print0
  done
)

if [[ ${#SQL_FILES[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip squawk \
    "dm/index-without-concurrently:no .sql files found in migration dirs -- squawk skipped" \
    "dm/unquoted-reserved-keyword:no .sql files found in migration dirs -- squawk skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-squawk-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Run squawk: each SQL file is passed as a separate argv element (injection-safe)
set +e
squawk --reporter json "${SQL_FILES[@]}" > "$TMPFILE" 2>/dev/null || true
set -e

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-squawk.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse squawk JSON output -> finding.schema.json JSONL.

Usage: parse-squawk.py <squawk_json_file> <repo_root>

Assumed squawk --reporter json output shape (documented in wrap-squawk.sh):

  [
    {
      "file": "db/migrations/0001_create_users.sql",
      "violations": [
        {
          "rule": "require-concurrent-index-creation",
          "level": "Warning",
          "messages": [
            {
              "Note": "Use CONCURRENTLY when creating indexes to avoid locking the table."
            }
          ],
          "position": {
            "start": { "line": 5, "col": 1 },
            "end":   { "line": 5, "col": 40 }
          }
        }
      ]
    }
  ]

The top-level array has one entry per file. Each entry has a "violations" array.
Each violation has:
  - rule:     string — squawk rule name, e.g. "require-concurrent-index-creation"
  - level:    string — "Warning" | "Error" | "Note"
  - messages: list of single-key objects, e.g. [{"Note": "..."}, {"Help": "..."}]
  - position: object with start/end, each containing line (1-based) and col

squawk rule -> dm/* check_id mapping:
  require-concurrent-index-creation  -> dm/index-without-concurrently
  ban-drop-database                  -> dm/squawk-violation
  prefer-robust-stmts                -> dm/squawk-violation
  add-field-with-default             -> dm/squawk-violation
  (all others)                       -> dm/squawk-violation
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


# Severity mapping from squawk level strings.
_SEVERITY_MAP = {
    "error": "high",
    "warning": "medium",
    "note": "low",
}

# Rule-to-check_id mapping; unrecognised rules fall back to dm/squawk-violation.
_RULE_CHECK_ID = {
    "require-concurrent-index-creation": "dm/index-without-concurrently",
}
_DEFAULT_CHECK_ID = "dm/squawk-violation"


def _extract_message(messages):
    """
    Extract a human-readable message string from squawk's messages list.
    Each element is a single-key dict: {"Note": "..."} or {"Help": "..."}.
    Returns the first Note or Help value found, or "squawk violation" as default.
    """
    if not isinstance(messages, list):
        return "squawk violation"
    for msg_obj in messages:
        if not isinstance(msg_obj, dict):
            continue
        for key in ("Note", "Help", "Error"):
            val = msg_obj.get(key)
            if val:
                return str(val)
    return "squawk violation"


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-squawk.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not isinstance(data, list):
        sys.stderr.write("Expected JSON array from squawk --reporter json\n")
        sys.exit(0)

    for file_entry in data:
        if not isinstance(file_entry, dict):
            continue

        file_path = file_entry.get("file", "unknown.sql")
        # Make path repo-relative
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        violations = file_entry.get("violations", [])
        if not isinstance(violations, list):
            continue

        for violation in violations:
            if not isinstance(violation, dict):
                continue

            rule = violation.get("rule", "unknown")
            level = violation.get("level", "Warning").lower()
            messages = violation.get("messages", [])
            position = violation.get("position", {})
            start = position.get("start", {})
            line_no = max(1, int(start.get("line", 1)))

            check_id = _RULE_CHECK_ID.get(rule, _DEFAULT_CHECK_ID)
            severity = _SEVERITY_MAP.get(level, "medium")
            message = _extract_message(messages)
            if message == "squawk violation":
                message = "squawk rule {0}".format(rule)

            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}".format(file_path, line_no, rule)
            )

            finding = normalize.make_finding(
                check_id=check_id,
                rule_id="squawk/{0}".format(rule),
                severity=severity,
                confidence="high",
                path=file_path,
                line=line_no,
                message=message,
                fingerprint=fp,
                properties={"tool": "squawk"},
            )
            normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-sqlfluff.sh

#!/usr/bin/env bash
# CCGM audit spine -- sqlfluff wrapper
# Lints SQL migration files using sqlfluff for style, formatting, and
# dangerous pattern detection (e.g. SECURITY DEFINER).
#
# Usage: wrap-sqlfluff.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0
#
# sqlfluff lint --format json output shape:
#   [
#     {
#       "filepath": "db/migrations/0001_create_users.sql",
#       "violations": [
#         {
#           "start_line_no": 5,
#           "start_line_pos": 1,
#           "end_line_no": 5,
#           "end_line_pos": 40,
#           "description": "Found SECURITY DEFINER in function definition.",
#           "name": "ST07",
#           "warning": false,
#           "fixable": false
#         }
#       ]
#     }
#   ]
#
# The top-level array has one entry per file. Each entry has a "violations" array.
# Each violation has:
#   - start_line_no:  integer (1-based)
#   - start_line_pos: integer (1-based)
#   - description:    string
#   - name:           string rule code, e.g. "LT01", "ST07"
#   - warning:        bool
#   - fixable:        bool
#
# sqlfluff rule -> check_id mapping (in parse-sqlfluff.py):
#   (all violations)  -> dm/security-definer-function when description matches SECURITY DEFINER
#   (all others)      -> dm/sqlfluff-violation (generic)

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-sqlfluff.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip sqlfluff \
    "dm/security-definer-function:sqlfluff not installed -- SECURITY DEFINER check skipped (llm fallback available)"
  exit 0
fi

# Locate migration directories (mirrors detect-ecosystems.sh has_migrations logic)
MIGRATION_DIRS=()
for candidate in \
    "supabase/migrations" \
    "prisma/migrations" \
    "db/migrate" \
    "db/migrations" \
    "database/migrations"; do
  if [[ -d "$REPO_ROOT/$candidate" ]]; then
    MIGRATION_DIRS+=("$REPO_ROOT/$candidate")
  fi
done

if [[ ${#MIGRATION_DIRS[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip sqlfluff \
    "dm/security-definer-function:no migration directories found -- sqlfluff skipped"
  exit 0
fi

if ! command -v sqlfluff > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip sqlfluff \
    "dm/security-definer-function:sqlfluff not installed -- SECURITY DEFINER check skipped (llm fallback available)"
  exit 0
fi

# Collect .sql files from migration directories using a NUL-delimited read loop.
# Bash-3.2-portable: mapfile -d '' requires bash 4+; use while-read instead.
SQL_FILES=()
while IFS= read -r -d '' f; do
  SQL_FILES+=("$f")
done < <(
  for dir in "${MIGRATION_DIRS[@]}"; do
    find "$dir" \
      -type f \
      -name "*.sql" \
      -not -path "*/.git/*" \
      -print0
  done
)

if [[ ${#SQL_FILES[@]} -eq 0 ]]; then
  python3 "$NORMALIZE_PY" --emit-skip sqlfluff \
    "dm/security-definer-function:no .sql files found in migration dirs -- sqlfluff skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-sqlfluff-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Run sqlfluff lint with JSON format and no project config (--config /dev/null
# is not supported; use --nocolor and rely on --format json for isolation).
# Paths are passed as separate argv elements (injection-safe).
# sqlfluff exits non-zero when violations are found -- ignore exit code.
set +e
sqlfluff lint --format json --nocolor "${SQL_FILES[@]}" > "$TMPFILE" 2>/dev/null || true
set -e

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-sqlfluff.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse sqlfluff JSON output -> finding.schema.json JSONL.

Usage: parse-sqlfluff.py <sqlfluff_json_file> <repo_root>

Assumed sqlfluff lint --format json output shape (documented in wrap-sqlfluff.sh):

  [
    {
      "filepath": "db/migrations/0001_create_users.sql",
      "violations": [
        {
          "start_line_no": 5,
          "start_line_pos": 1,
          "end_line_no": 5,
          "end_line_pos": 40,
          "description": "Found SECURITY DEFINER in function definition.",
          "name": "ST07",
          "warning": false,
          "fixable": false
        }
      ]
    }
  ]

The top-level is a JSON array of file objects. Each file object has a "violations"
array. Each violation contains start_line_no (1-based integer), description (string),
and name (rule code string). The "warning" bool controls severity: false -> medium,
true -> low.

check_id assignment:
  - description contains "SECURITY DEFINER" (case-insensitive) -> dm/security-definer-function
  - all others -> dm/sqlfluff-violation

properties.tool is always set to "sqlfluff".
"""

import json
import os
import re
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


# Pattern to detect SECURITY DEFINER descriptions
_SECURITY_DEFINER_RE = re.compile(r"security[\s_-]*definer", re.IGNORECASE)


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-sqlfluff.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not isinstance(data, list):
        sys.stderr.write("Expected JSON array from sqlfluff lint --format json\n")
        sys.exit(0)

    for file_entry in data:
        if not isinstance(file_entry, dict):
            continue

        file_path = file_entry.get("filepath", "unknown.sql")
        # Make path repo-relative
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]

        violations = file_entry.get("violations", [])
        if not isinstance(violations, list):
            continue

        for violation in violations:
            if not isinstance(violation, dict):
                continue

            rule_name = violation.get("name", "unknown")
            description = violation.get("description", "sqlfluff violation")
            is_warning = bool(violation.get("warning", False))
            line_no = max(1, int(violation.get("start_line_no", 1)))

            # Determine check_id
            if _SECURITY_DEFINER_RE.search(description):
                check_id = "dm/security-definer-function"
                severity = "medium"
                confidence = "high"
            else:
                check_id = "dm/sqlfluff-violation"
                severity = "low" if is_warning else "medium"
                confidence = "high"

            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}".format(file_path, line_no, rule_name)
            )

            finding = normalize.make_finding(
                check_id=check_id,
                rule_id="sqlfluff/{0}".format(rule_name),
                severity=severity,
                confidence=confidence,
                path=file_path,
                line=line_no,
                message=description,
                fingerprint=fp,
                properties={"tool": "sqlfluff"},
            )
            normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-pip-audit.sh

#!/usr/bin/env bash
# CCGM audit spine -- pip-audit wrapper
# Scans Python projects for known dependency vulnerabilities.
#
# Usage: wrap-pip-audit.sh <repo_root>
#
# Detects Python projects by presence of requirements.txt or pyproject.toml.
# Runs: pip-audit --format json
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-pip-audit.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip pip-audit \
    "deps/vulnerable-dependency:no repo_root argument supplied"
  exit 0
fi

# Only run if this looks like a Python project
if [[ ! -f "$REPO_ROOT/requirements.txt" && \
      ! -f "$REPO_ROOT/pyproject.toml" && \
      ! -f "$REPO_ROOT/setup.py" && \
      ! -f "$REPO_ROOT/Pipfile" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip pip-audit \
    "deps/vulnerable-dependency:no Python manifest found -- pip-audit skipped"
  exit 0
fi

if ! command -v pip-audit > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip pip-audit \
    "deps/vulnerable-dependency:pip-audit not installed -- Python vulnerability scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-pip-audit-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Run pip-audit -- repo path is passed as argv via cd into subshell;
# never interpolated into the audit command string itself.
set +e
(
  cd "$REPO_ROOT"
  pip-audit --format json 2>/dev/null > "$TMPFILE" || true
)
set -e

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE"

skills/audit/scripts/spine/parse-pip-audit.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse pip-audit JSON output -> finding.schema.json JSONL.

Usage: parse-pip-audit.py <pip_audit_json_file>

pip-audit --format json emits a single JSON object.

Assumed output shape (pip-audit >= 2.0):
  {
    "dependencies": [
      {
        "name": "cryptography",
        "version": "38.0.0",
        "vulns": [
          {
            "id": "PYSEC-2023-123",
            "fix_versions": ["41.0.0"],
            "aliases": ["CVE-2023-12345"],
            "description": "A buffer overflow vulnerability..."
          }
        ]
      }
    ]
  }

Packages with an empty "vulns" list are not vulnerable and are skipped.
The "aliases" field may contain CVE IDs; the first CVE alias (if any) is
appended to the message for human reference.
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


def main(argv):
    if len(argv) < 2:
        sys.stderr.write("Usage: parse-pip-audit.py <json_file>\n")
        sys.exit(1)

    json_file = argv[1]

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            raw = fh.read().strip()
    except OSError as exc:
        sys.stderr.write("Cannot read {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not raw:
        return

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        sys.stderr.write("Invalid JSON from pip-audit: {0}\n".format(exc))
        sys.exit(0)

    if not isinstance(data, dict):
        sys.stderr.write("Unexpected pip-audit output shape (not a dict)\n")
        sys.exit(0)

    dependencies = data.get("dependencies", [])
    if not isinstance(dependencies, list):
        return

    for dep in dependencies:
        if not isinstance(dep, dict):
            continue
        vulns = dep.get("vulns", [])
        if not vulns:
            continue

        pkg_name = dep.get("name", "unknown")
        pkg_version = dep.get("version", "")

        for vuln in vulns:
            if not isinstance(vuln, dict):
                continue

            vuln_id = vuln.get("id", "unknown")
            description = vuln.get("description", "Vulnerable dependency")
            fix_versions = vuln.get("fix_versions", [])
            aliases = vuln.get("aliases", [])

            # Find first CVE alias for human reference
            cve = ""
            for alias in aliases:
                if isinstance(alias, str) and alias.upper().startswith("CVE-"):
                    cve = alias
                    break

            fix_note = ""
            if fix_versions:
                fix_note = " (fix: {0})".format(", ".join(str(v) for v in fix_versions))

            message = "{0} in {1} {2}{3}".format(
                description, pkg_name, pkg_version, fix_note
            ).strip()
            if cve:
                message = "{0} [{1}]".format(message, cve)

            fp = normalize.make_content_fingerprint(
                "{0}:{1}:{2}".format(vuln_id, pkg_name, pkg_version)
            )

            finding = normalize.make_finding(
                check_id="deps/vulnerable-dependency",
                rule_id=vuln_id,
                severity="high",
                confidence="high",
                path="requirements.txt",
                line=1,
                message=message,
                fingerprint=fp,
                properties={
                    "tool": "pip-audit",
                    "package": pkg_name,
                    "ecosystem": "python",
                },
            )
            normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-cargo-audit.sh

#!/usr/bin/env bash
# CCGM audit spine -- cargo-audit wrapper
# Scans Rust projects for known dependency vulnerabilities.
#
# Usage: wrap-cargo-audit.sh <repo_root>
#
# Detects Rust projects by presence of Cargo.toml.
# Runs: cargo audit --json
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-cargo-audit.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip cargo-audit \
    "deps/vulnerable-dependency:no repo_root argument supplied"
  exit 0
fi

# Only run if this looks like a Rust project
if [[ ! -f "$REPO_ROOT/Cargo.toml" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip cargo-audit \
    "deps/vulnerable-dependency:no Cargo.toml found -- cargo-audit skipped"
  exit 0
fi

if ! command -v cargo-audit > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip cargo-audit \
    "deps/vulnerable-dependency:cargo-audit not installed -- Rust vulnerability scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-cargo-audit-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Run cargo audit -- repo path is passed via cd into subshell;
# never interpolated into the audit command string itself.
# cargo audit exits non-zero when vulnerabilities are found; that is expected.
set +e
(
  cd "$REPO_ROOT"
  cargo audit --json 2>/dev/null > "$TMPFILE" || true
)
set -e

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE"

skills/audit/scripts/spine/parse-cargo-audit.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse cargo-audit JSON output -> finding.schema.json JSONL.

Usage: parse-cargo-audit.py <cargo_audit_json_file>

cargo audit --json emits a single JSON object.

Assumed output shape (cargo-audit >= 0.17, rustsec advisory DB):
  {
    "database": { "advisory-count": 123, ... },
    "lockfile": { "dependency-count": 45 },
    "vulnerabilities": {
      "found": true,
      "count": 2,
      "list": [
        {
          "advisory": {
            "id": "RUSTSEC-2023-0001",
            "package": "openssl",
            "title": "Use after free in EVP_KEY_CTX",
            "description": "...",
            "date": "2023-01-01",
            "url": "https://rustsec.org/advisories/RUSTSEC-2023-0001.html",
            "aliases": ["CVE-2023-0001"],
            "severity": "high"
          },
          "versions": {
            "patched": [">=1.0.2u"],
            "unaffected": []
          },
          "affected": {
            "package": {
              "name": "openssl",
              "version": "1.0.2t",
              "source": "registry+..."
            },
            "cvss": "CVSS:3.1/..."
          }
        }
      ]
    },
    "warnings": {
      "list": [...]
    }
  }

The "vulnerabilities.list" array is the primary signal.
The "warnings" section (unmaintained, unsound, notice) is NOT parsed; only CVE vulnerabilities
are emitted.
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    "critical": "critical",
    "high": "high",
    "medium": "medium",
    "moderate": "medium",
    "low": "low",
    "info": "info",
}


def map_severity(raw):
    if isinstance(raw, str):
        return _SEVERITY_MAP.get(raw.lower(), "high")
    return "high"


def main(argv):
    if len(argv) < 2:
        sys.stderr.write("Usage: parse-cargo-audit.py <json_file>\n")
        sys.exit(1)

    json_file = argv[1]

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            raw = fh.read().strip()
    except OSError as exc:
        sys.stderr.write("Cannot read {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    if not raw:
        return

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        sys.stderr.write("Invalid JSON from cargo-audit: {0}\n".format(exc))
        sys.exit(0)

    if not isinstance(data, dict):
        sys.stderr.write("Unexpected cargo-audit output shape (not a dict)\n")
        sys.exit(0)

    # --- vulnerabilities section ---
    vulns_section = data.get("vulnerabilities", {})
    vuln_list = vulns_section.get("list", []) if isinstance(vulns_section, dict) else []

    for item in vuln_list:
        if not isinstance(item, dict):
            continue

        advisory = item.get("advisory", {})
        if not isinstance(advisory, dict):
            continue

        affected = item.get("affected", {})
        pkg_obj = {}
        if isinstance(affected, dict):
            pkg_obj = affected.get("package", {}) or {}

        vuln_id = advisory.get("id", "unknown")
        title = advisory.get("title", "Vulnerable dependency")
        description = advisory.get("description", "")
        pkg_name = advisory.get("package", pkg_obj.get("name", "unknown"))
        pkg_version = pkg_obj.get("version", "")
        severity_raw = advisory.get("severity", "high")
        severity = map_severity(severity_raw)
        url = advisory.get("url", "")
        aliases = advisory.get("aliases", [])

        cve = ""
        for alias in aliases:
            if isinstance(alias, str) and alias.upper().startswith("CVE-"):
                cve = alias
                break

        message = "{0} in {1} {2}".format(title, pkg_name, pkg_version).strip()
        if description:
            # Truncate long descriptions to keep messages readable
            short_desc = description[:120].rstrip()
            if len(description) > 120:
                short_desc += "..."
            message = "{0}: {1}".format(message, short_desc)
        if cve:
            message = "{0} [{1}]".format(message, cve)
        if url:
            message = "{0} -- {1}".format(message, url)

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(vuln_id, pkg_name, pkg_version)
        )

        finding = normalize.make_finding(
            check_id="deps/vulnerable-dependency",
            rule_id=vuln_id,
            severity=severity,
            confidence="high",
            path="Cargo.toml",
            line=1,
            message=message,
            fingerprint=fp,
            properties={
                "tool": "cargo-audit",
                "package": pkg_name,
                "ecosystem": "rust",
            },
        )
        normalize.emit_finding(finding)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-bundler-audit.sh

#!/usr/bin/env bash
# CCGM audit spine -- bundler-audit wrapper
# Scans Ruby projects for known dependency vulnerabilities.
#
# Usage: wrap-bundler-audit.sh <repo_root>
#
# Detects Ruby projects by presence of Gemfile.lock.
# Runs: bundle-audit check --format json (falls back to text on older versions)
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-bundler-audit.py"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip bundler-audit \
    "deps/vulnerable-dependency:no repo_root argument supplied"
  exit 0
fi

# Only run if this looks like a Ruby project with a lockfile
if [[ ! -f "$REPO_ROOT/Gemfile.lock" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip bundler-audit \
    "deps/vulnerable-dependency:no Gemfile.lock found -- bundler-audit skipped"
  exit 0
fi

if ! command -v bundle-audit > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip bundler-audit \
    "deps/vulnerable-dependency:bundle-audit not installed -- Ruby vulnerability scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-bundler-audit-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Try JSON format first (bundler-audit >= 0.9 supports --format json).
# bundle-audit exits non-zero when vulnerabilities are found; that is expected.
set +e
(
  cd "$REPO_ROOT"
  bundle-audit check --format json 2>/dev/null > "$TMPFILE" || true
)
set -e

# If JSON output is empty or not valid JSON, fall back to text format
if [[ ! -s "$TMPFILE" ]] || ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$TMPFILE" 2>/dev/null; then
  rm -f "$TMPFILE"
  TMPFILE="$(mktemp /tmp/ccgm-bundler-audit-text-XXXXXX.txt)"
  trap 'rm -f "$TMPFILE"' EXIT
  set +e
  (
    cd "$REPO_ROOT"
    bundle-audit check 2>/dev/null > "$TMPFILE" || true
  )
  set -e
fi

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE"

skills/audit/scripts/spine/parse-bundler-audit.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse bundler-audit output -> finding.schema.json JSONL.

Usage: parse-bundler-audit.py <bundler_audit_output_file>

bundler-audit supports two output formats:

1. JSON format (bundler-audit >= 0.9, --format json):
   {
     "version": "0.9.2",
     "created_at": "2024-01-01T00:00:00Z",
     "results": [
       {
         "type": "InsecureSource",
         "source": { "uri": "http://insecure.example" }
       },
       {
         "type": "UnpatchedGem",
         "gem": {
           "name": "activesupport",
           "version": "5.2.0"
         },
         "advisory": {
           "id": "CVE-2023-12345",
           "ghsa": "GHSA-xxxx-xxxx-xxxx",
           "title": "Possible ReDoS vulnerability...",
           "date": "2023-01-15",
           "url": "https://github.com/advisories/GHSA-xxxx-xxxx-xxxx",
           "description": "...",
           "cvss_v2": 5.0,
           "cvss_v3": 7.5,
           "cve": "2023-12345",
           "osvdb": null,
           "criticality": "high",
           "patched_versions": [">= 6.1.7.3", ">= 7.0.4.3"],
           "unaffected_versions": []
         }
       }
     ],
     "ignored": [],
     "totals": {
       "unpatched": 1,
       "ignored": 0
     }
   }

2. Text format (bundler-audit < 0.9, default output):
   Name: activesupport
   Version: 5.2.0
   Advisory: CVE-2023-12345
   Criticality: High
   URL: https://github.com/advisories/GHSA-xxxx-xxxx-xxxx
   Title: Possible ReDoS vulnerability in GlobalID
   Solution: upgrade to >= 6.1.7.3, >= 7.0.4.3

   Vulnerabilities found!

The parser tries JSON first; on failure, falls back to line-based text parsing.
"""

import json
import os
import re
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


_SEVERITY_MAP = {
    "critical": "critical",
    "high": "high",
    "medium": "medium",
    "moderate": "medium",
    "low": "low",
    "none": "low",
    "unknown": "medium",
}


def map_severity(raw):
    if isinstance(raw, str):
        return _SEVERITY_MAP.get(raw.lower(), "high")
    return "high"


def map_severity_from_cvss(cvss_v3, cvss_v2):
    """Derive severity from CVSS score when criticality field is absent."""
    score = cvss_v3 if cvss_v3 is not None else cvss_v2
    if score is None:
        return "high"
    if score >= 9.0:
        return "critical"
    if score >= 7.0:
        return "high"
    if score >= 4.0:
        return "medium"
    return "low"


def process_json(data):
    """Parse bundler-audit JSON format."""
    results = data.get("results", [])
    if not isinstance(results, list):
        return

    for item in results:
        if not isinstance(item, dict):
            continue
        if item.get("type") != "UnpatchedGem":
            continue

        gem = item.get("gem", {}) or {}
        advisory = item.get("advisory", {}) or {}

        pkg_name = gem.get("name", "unknown")
        pkg_version = gem.get("version", "")
        vuln_id = advisory.get("id") or advisory.get("cve", "unknown")
        title = advisory.get("title", "Vulnerable gem")
        url = advisory.get("url", "")
        criticality = advisory.get("criticality", "")
        cvss_v3 = advisory.get("cvss_v3")
        cvss_v2 = advisory.get("cvss_v2")

        if criticality:
            severity = map_severity(criticality)
        else:
            severity = map_severity_from_cvss(cvss_v3, cvss_v2)

        message = "{0} in {1} {2}".format(title, pkg_name, pkg_version).strip()
        if url:
            message = "{0} -- {1}".format(message, url)

        # Prefix vuln_id with CVE- if it looks like a bare CVE number
        if re.fullmatch(r"\d{4}-\d+", vuln_id):
            vuln_id = "CVE-{0}".format(vuln_id)

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(vuln_id, pkg_name, pkg_version)
        )

        finding = normalize.make_finding(
            check_id="deps/vulnerable-dependency",
            rule_id=vuln_id,
            severity=severity,
            confidence="high",
            path="Gemfile.lock",
            line=1,
            message=message,
            fingerprint=fp,
            properties={
                "tool": "bundler-audit",
                "package": pkg_name,
                "ecosystem": "ruby",
            },
        )
        normalize.emit_finding(finding)


def process_text(text):
    """Parse bundler-audit text format (line-based key: value blocks)."""
    current = {}
    for line in text.splitlines():
        line = line.rstrip()
        if not line:
            if current:
                emit_text_finding(current)
                current = {}
            continue
        if ":" in line:
            key, _, value = line.partition(":")
            current[key.strip().lower()] = value.strip()

    if current:
        emit_text_finding(current)


def emit_text_finding(rec):
    """Emit a single finding from a key:value block parsed from text output."""
    name = rec.get("name", "")
    if not name:
        return  # Not a vulnerability block

    version = rec.get("version", "")
    advisory_id = rec.get("advisory", rec.get("cve", "unknown"))
    criticality = rec.get("criticality", "")
    title = rec.get("title", "Vulnerable gem")
    url = rec.get("url", "")

    severity = map_severity(criticality) if criticality else "high"

    message = "{0} in {1} {2}".format(title, name, version).strip()
    if url:
        message = "{0} -- {1}".format(message, url)

    if re.fullmatch(r"\d{4}-\d+", advisory_id):
        advisory_id = "CVE-{0}".format(advisory_id)

    fp = normalize.make_content_fingerprint(
        "{0}:{1}:{2}".format(advisory_id, name, version)
    )

    finding = normalize.make_finding(
        check_id="deps/vulnerable-dependency",
        rule_id=advisory_id,
        severity=severity,
        confidence="high",
        path="Gemfile.lock",
        line=1,
        message=message,
        fingerprint=fp,
        properties={
            "tool": "bundler-audit",
            "package": name,
            "ecosystem": "ruby",
        },
    )
    normalize.emit_finding(finding)


def main(argv):
    if len(argv) < 2:
        sys.stderr.write("Usage: parse-bundler-audit.py <output_file>\n")
        sys.exit(1)

    output_file = argv[1]

    try:
        with open(output_file, "r", encoding="utf-8") as fh:
            raw = fh.read().strip()
    except OSError as exc:
        sys.stderr.write("Cannot read {0}: {1}\n".format(output_file, exc))
        sys.exit(0)

    if not raw:
        return

    # Try JSON first
    try:
        data = json.loads(raw)
        if isinstance(data, dict) and "results" in data:
            process_json(data)
            return
    except json.JSONDecodeError:
        pass

    # Fall back to text format
    process_text(raw)


if __name__ == "__main__":
    main(sys.argv)

skills/audit/scripts/spine/wrap-checkov.sh

#!/usr/bin/env bash
# CCGM audit spine -- checkov wrapper
# Scans IaC files (Terraform, Dockerfiles, CloudFormation, k8s manifests)
# for security and compliance misconfigurations.
#
# Usage: wrap-checkov.sh <repo_root>
#
# Output (stdout): JSONL
# Exit code: always 0

set -euo pipefail

REPO_ROOT="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NORMALIZE_PY="$SCRIPT_DIR/normalize.py"
PARSE_PY="$SCRIPT_DIR/parse-checkov.py"

# shellcheck source=exclude.sh
. "$SCRIPT_DIR/exclude.sh"

if [[ -z "$REPO_ROOT" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip checkov \
    "iac/public-ingress:no repo_root argument supplied" \
    "iac/missing-encryption:no repo_root argument supplied" \
    "iac/hardcoded-secret-in-iac:no repo_root argument supplied"
  exit 0
fi

if ! command -v checkov > /dev/null 2>&1; then
  python3 "$NORMALIZE_PY" --emit-skip checkov \
    "iac/public-ingress:checkov not installed -- IaC security scan skipped" \
    "iac/missing-encryption:checkov not installed -- IaC security scan skipped" \
    "iac/hardcoded-secret-in-iac:checkov not installed -- IaC security scan skipped"
  exit 0
fi

TMPFILE="$(mktemp /tmp/ccgm-checkov-XXXXXX.json)"
trap 'rm -f "$TMPFILE"' EXIT

# Run checkov on the repo directory.
# --directory: target path (passed as argv, not interpolated)
# --output json: machine-readable JSON output
# --quiet: suppress progress bars and logging
# --compact: omit passed checks from JSON output (findings only)
# --soft-fail: exit 0 regardless of findings (we handle them ourselves)
#
# Config isolation caveat: checkov auto-discovers .checkov.yaml/.checkov.yml
# from the scanned --directory, then the process cwd, then ~/.checkov.yaml
# (via configargparse default_config_files).  There is no CLI flag that
# suppresses this discovery — --config-file adds to the list rather than
# replacing it.  A repo-local .checkov.yaml can therefore silently skip or
# alter checks.  Accepted limitation: the pack rubric and normalizer
# (parse-checkov.py) own severity/confidence regardless of what the repo
# config does to the check list.
# --skip-path: regex of vendored/generated dirs and stale worktrees so checkov
#   does not walk node_modules or duplicate worktree trees (field report #1).
ccgm_checkov_skip_args
set +e
checkov \
  --directory "$REPO_ROOT" \
  --output json \
  --quiet \
  --compact \
  --soft-fail \
  "${CCGM_FLAGS[@]}" \
  > "$TMPFILE" 2>/dev/null
CHECKOV_EXIT=$?
set -e

if [[ $CHECKOV_EXIT -ne 0 && ! -s "$TMPFILE" ]]; then
  python3 "$NORMALIZE_PY" --emit-skip checkov \
    "iac/public-ingress:checkov exited non-zero with no output"
  exit 0
fi

if [[ ! -s "$TMPFILE" ]]; then
  exit 0
fi

python3 "$PARSE_PY" "$TMPFILE" "$REPO_ROOT"

skills/audit/scripts/spine/parse-checkov.py

#!/usr/bin/env python3
"""
CCGM audit spine -- parse checkov JSON output -> finding.schema.json JSONL.

Usage: parse-checkov.py <checkov_json_file> <repo_root>

checkov --output json emits one of two shapes:

  Single-framework output (most common):
    {
      "check_type": "terraform",
      "results": {
        "failed_checks": [
          {
            "check_id": "CKV_AWS_20",
            "check_name": "Ensure the S3 bucket has access control list (ACL) is private",
            "file_path": "/main.tf",
            "file_line_range": [1, 10],
            "resource": "aws_s3_bucket.example",
            "check_class": "...",
            ...
          },
          ...
        ],
        "passed_checks": [...],
        "skipped_checks": [...]
      }
    }

  Multi-framework output (when multiple IaC types are present):
    [
      { "check_type": "terraform", "results": { "failed_checks": [...] } },
      { "check_type": "dockerfile", "results": { "failed_checks": [...] } },
      ...
    ]

  NOTE: We only process failed_checks. Passed and skipped checks are ignored.
  The check_id field (e.g. "CKV_AWS_20") is used as the rule_id. All findings
  are emitted under the "iac/" check_id namespace.
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))
import normalize  # noqa: E402


# ---------------------------------------------------------------------------
# Severity mapping: checkov does not emit severity in standard JSON output,
# so we assign "medium" by default.  Known high-severity check IDs override.
# ---------------------------------------------------------------------------

_HIGH_SEVERITY_PREFIXES = {
    "CKV_AWS_20",   # S3 bucket public
    "CKV_AWS_21",   # S3 versioning
    "CKV_AWS_54",   # S3 public access block
    "CKV_AWS_55",   # S3 public access block (account level)
    "CKV_AWS_18",   # CloudTrail logging
    "CKV_AWS_19",   # CloudTrail encryption
    "CKV_AWS_7",    # KMS key rotation
    "CKV_SECRET",   # Secrets in IaC
    "CKV2_SECRET",  # Secrets in IaC (v2)
}


def _severity_for(check_id):
    """Assign severity based on check_id prefix heuristic.

    The heuristic only keys on the check_id string; it does NOT map every
    possible CKV_* to a precise severity.  That is intentional: pack-level
    rubric entries (packs/infra-iac/checks.md) are the authoritative severity
    owners for named check categories (iac/public-ingress, iac/missing-
    encryption, etc.).  The catch-all "return medium" here is the correct
    default for the generic iac/checkov-violation check_id — the rubric entry
    for that check_id specifies medium confidence/severity by design.
    """
    if check_id in _HIGH_SEVERITY_PREFIXES:
        return "high"
    check_upper = check_id.upper()
    if "SECRET" in check_upper or "CREDENTIAL" in check_upper or "KEY" in check_upper:
        return "high"
    # Public ingress / open to 0.0.0.0
    if "PUBLIC" in check_upper or "OPEN" in check_upper or "INGRESS" in check_upper:
        return "high"
    # Encryption misses
    if "ENCRYPT" in check_upper:
        return "medium"
    return "medium"


# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------

def _parse_failed_checks(failed_checks, repo_root):
    """Yield normalized findings from a failed_checks list."""
    if not isinstance(failed_checks, list):
        return

    for item in failed_checks:
        if not isinstance(item, dict):
            continue

        check_id = item.get("check_id", "CKV_UNKNOWN")
        check_name = item.get("check_name", "checkov finding")
        file_path = item.get("file_path", "")
        resource = item.get("resource", "")

        # Normalize file path: strip leading / and repo_root prefix
        if file_path.startswith(repo_root + "/"):
            file_path = file_path[len(repo_root) + 1:]
        elif file_path.startswith("/"):
            file_path = file_path.lstrip("/")

        if not file_path:
            file_path = "."

        # Extract line number from file_line_range: [start, end]
        line_range = item.get("file_line_range", [1, 1])
        if isinstance(line_range, list) and len(line_range) >= 1:
            try:
                line_no = max(1, int(line_range[0]))
                end_line = max(line_no, int(line_range[1])) if len(line_range) >= 2 else None
            except (TypeError, ValueError):
                line_no = 1
                end_line = None
        else:
            line_no = 1
            end_line = None

        severity = _severity_for(check_id)

        message = check_name
        if resource:
            message = "{0} [{1}]".format(check_name, resource)

        fp = normalize.make_content_fingerprint(
            "{0}:{1}:{2}".format(file_path, line_no, check_id)
        )

        finding = normalize.make_finding(
            check_id="iac/checkov-violation",
            rule_id=check_id,
            severity=severity,
            confidence="medium",
            path=file_path,
            line=line_no,
            message=message,
            fingerprint=fp,
            end_line=end_line,
            properties={"tool": "checkov"},
        )
        normalize.emit_finding(finding)


def main(argv):
    if len(argv) < 3:
        sys.stderr.write("Usage: parse-checkov.py <json_file> <repo_root>\n")
        sys.exit(1)

    json_file = argv[1]
    repo_root = argv[2].rstrip("/")

    try:
        with open(json_file, "r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.stderr.write("Cannot parse {0}: {1}\n".format(json_file, exc))
        sys.exit(0)

    # Normalize to a list of framework result objects
    if isinstance(data, dict):
        # Single-framework output
        framework_results = [data]
    elif isinstance(data, list):
        # Multi-framework output
        framework_results = data
    else:
        sys.stderr.write("Unexpected checkov output shape\n")
        sys.exit(0)

    for fw_result in framework_results:
        if not isinstance(fw_result, dict):
            continue
        results = fw_result.get("results", {})
        if not isinstance(results, dict):
            continue
        failed_checks = results.get("failed_checks", [])
        _parse_failed_checks(failed_checks, repo_root)


if __name__ == "__main__":
    main(sys.argv)
doc (55)

skills/audit/reference/security-patterns.md

# Security Audit Patterns

Reference patterns for the security audit agent. Based on OWASP Top 10, gitleaks patterns, and common vulnerability categories.

## 1. Hardcoded Secrets

### Patterns to Search For

```regex
# API Keys
(api[_-]?key|apikey)['":\s]*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]
(sk-[a-zA-Z0-9]{48})  # OpenAI keys
(ghp_[a-zA-Z0-9]{36})  # GitHub personal tokens
(gho_[a-zA-Z0-9]{36})  # GitHub OAuth tokens
(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})  # GitHub fine-grained tokens

# AWS
(AKIA[0-9A-Z]{16})  # AWS Access Key ID
aws[_-]?secret[_-]?access[_-]?key

# Database URLs
(postgres|mysql|mongodb)://[^:]+:[^@]+@

# Generic secrets
(password|passwd|pwd|secret|token|auth)['":\s]*[=:]\s*['"][^'"]{8,}['"]
(private[_-]?key|privatekey)
-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY----…

View raw (5108 bytes)

skills/audit/reference/code-quality.md

# Code Quality Audit Patterns

Reference patterns for the code quality audit agent. Based on SonarQube rules, Martin Fowler's refactoring patterns, and common code smells.

## 1. Bloaters

### Long Methods
- **Threshold**: >50 lines
- **Why it matters**: Hard to understand, test, and maintain
- **Look for**: Methods that do multiple things, deeply nested logic

### Large Classes/Files
- **Threshold**: >500 lines for files, >300 lines for classes
- **Why it matters**: Violates Single Responsibility Principle
- **Look for**: Files with many unrelated functions, classes with many methods

### Long Parameter Lists
- **Threshold**: >4 parameters
- **Why it matters**: Hard to use correctly, often indicates missing abstraction
- **Pattern**:
```javascript
// Bad
function createUser(name, email, a…

View raw (6041 bytes)

skills/audit/reference/architecture.md

# Architecture Audit Patterns

Reference patterns for the architecture audit agent. Based on architecture-antipatterns.tech, Clean Architecture principles, and common structural issues.

## 1. Structural Antipatterns

### Big Ball of Mud
A system lacking discernible architecture - everything depends on everything.

**Indicators**:
- No clear module boundaries
- Any file can import from any other file
- Circular dependencies everywhere
- Changes cascade unpredictably

**Detection**:
- Count import depth (imports importing imports)
- Map all imports and look for cycles
- Check if there's any layering at all

### God Object / Blob
A single class/module that does everything.

**Indicators**:
- File >1000 lines with many unrelated functions
- Class with >20 methods
- Module imported by >50% of …

View raw (7325 bytes)

skills/audit/reference/fix-patterns.md

# Auto-Fix Patterns Reference

Guide for determining which findings can be auto-fixed and how.

## Fix Confidence Levels

### HIGH Confidence - Auto-Fix Without Hesitation

These fixes are safe, well-tested, and unlikely to cause issues.

#### ESLint Auto-Fixes
```bash
# Fix all auto-fixable ESLint issues
npx eslint --fix {file}

# Fix specific rules
npx eslint --fix --rule 'no-unused-vars: error' {file}
```

**Auto-fixable ESLint rules:**
- `no-unused-vars` (remove unused imports)
- `no-extra-semi` (remove extra semicolons)
- `semi` (add/remove semicolons)
- `quotes` (fix quote style)
- `indent` (fix indentation)
- `comma-dangle` (fix trailing commas)
- `object-curly-spacing` (fix spacing)
- `array-bracket-spacing` (fix spacing)
- `eol-last` (fix end of file newline)
- `no-multiple-empty-…

View raw (8920 bytes)

skills/audit/reference/multi-agent-config.md

# Multi-Agent Audit Configuration

Reference spec for distributed audit using git worktrees for isolation.

---

## Agent Assignments

9 audit categories are distributed across 4 agents. Agent 0 carries 3 categories because the
ToS & Compliance audit is often the most project-specific and benefits from sharing context
with the Security and Dependencies audits.

| Agent | Worktree Dir | Categories | Merge Priority |
|-------|-------------|------------|----------------|
| 0 | `.audit/worktrees/agent-0` | Security, Dependencies, ToS & Compliance | 1 (highest - wins conflicts) |
| 1 | `.audit/worktrees/agent-1` | Code Quality, TypeScript/React | 2 |
| 2 | `.audit/worktrees/agent-2` | Architecture, Performance | 3 |
| 3 | `.audit/worktrees/agent-3` | Testing, Documentation | 4 (lowest) |

**9 c…

View raw (14126 bytes)

skills/audit/reference/output-template.md

# Audit Output Template

Template for GitHub issues created by the audit skill.

## Issue Title Format

```
Audit: [Category] - [YYYY-MM-DD]
```

Examples:
- `Audit: Security - 2026-02-05`
- `Audit: Code Quality - 2026-02-05`

## Issue Labels

Always apply:
- `audit`

Category-specific:
- `security` - Security findings
- `dependencies` - Dependency findings
- `tos-compliance` - Terms of Service & Policy Compliance findings
- `code-quality` - Code quality findings
- `architecture` - Architecture findings
- `typescript` - TypeScript/React findings
- `testing` - Testing findings
- `documentation` - Documentation findings
- `performance` - Performance findings

Severity:
- `critical` - Critical severity
- `high` - High severity (omit for medium/low)

## Issue Body Template

```markdown
## Summ…

View raw (10179 bytes)

skills/audit/schemas/pack.schema.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "ccgm/audit/pack.schema.json",
  "title": "CCGM Audit Pack Manifest",
  "description": "Schema for a CCGM /audit check pack manifest. `applies_when` is the sole gating mechanism — conditions are project-shape flags, language predicates, or the literal `always`.",
  "type": "object",
  "required": ["id", "name", "version", "applies_when", "checks"],
  "additionalProperties": false,
  "properties": {
    "id": {
      "type": "string",
      "description": "Stable, namespaced pack identifier, e.g. `ccgm/data-migrations`.",
      "pattern": "^[a-z0-9_-]+/[a-z0-9_-]+$"
    },
    "name": {
      "type": "string",
      "description": "Human-readable pack name."
    },
    "version": {
      "type": "string",
      "descrip…

View raw (4467 bytes)

skills/audit/schemas/finding.schema.json

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "ccgm/audit/finding.schema.json",
  "title": "CCGM Audit Finding",
  "description": "Schema for a single finding — one JSON object / one JSONL line in `.audit/current/findings.jsonl`. NOT SARIF; forward-compatible with a v2 SARIF emitter. Gate decision #30.",
  "type": "object",
  "required": [
    "check_id",
    "rule_id",
    "severity",
    "confidence",
    "location",
    "message",
    "fingerprint",
    "detection",
    "source"
  ],
  "additionalProperties": false,
  "properties": {
    "check_id": {
      "type": "string",
      "description": "The check that produced this finding, e.g. `dm/unquoted-reserved-keyword`. Namespaced `<pack>/<check>`.",
      "pattern": "^[a-z0-9_-]+/[a-z0-9_.-]+$"
    },
    "rul…

View raw (4096 bytes)

skills/audit/schemas/severity-rubric.json

{
  "version": "1.0.0",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "ccgm/audit/severity-rubric.json",
  "title": "CCGM Audit Severity Rubric",
  "description": "Rule-level, deterministic severity/confidence table. Agents source severity from here; they do NOT invent it. Keyed by check_id (pattern: <pack>/<check>, matching finding.schema.json). Pack epics add entries under their own namespace; use the [RUBRIC-serial] gate to avoid concurrent edits.",
  "_format": {
    "severity": "critical|high|medium|low|info",
    "confidence": "high|medium|low \u2014 signal precision (how often this check fires correctly)",
    "fix_confidence": "high|medium|low \u2014 safety confidence for auto-fix when applicable"
  },
  "checks": {
    "secrets/leaked-credential": {
      "…

View raw (17685 bytes)

skills/audit/packs/_TEMPLATE/checks.md

# checks.md Template

Copy this file to `packs/{your-pack-name}/checks.md` and fill in each section.
Remove all `<!-- ... -->` comments before shipping the pack.

---

## Scope

<!-- One paragraph: what this pack audits and what it does NOT cover.
     Be specific. "This pack audits SQL migration files for PostgreSQL reserved-keyword
     quoting. It does not audit Go source files or application-level queries." -->

**Pack ID:** `<!-- e.g. ccgm/data-migrations -->`
**Applies when:** `<!-- mirror the applies_when[] array from pack.json -->`

---

## applies_when Rationale

<!-- Explain why each gating condition in pack.json's applies_when[] is necessary
     and sufficient. One sentence per condition.

     Example:
       - `has_migrations`: Pack is only useful when migration files exist; …

View raw (4887 bytes)

skills/audit/packs/security/pack.json

{
  "id": "ccgm/security",
  "name": "Security Audit",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["security", "vulnerabilities", "owasp"],
  "severity_floor": "high",
  "tools": ["gitleaks", "semgrep"],
  "checks": [
    {
      "id": "security/hardcoded-secret",
      "severity": "critical",
      "confidence": "medium",
      "detection": "hybrid",
      "tool": "gitleaks",
      "fallback": "llm",
      "auto_fixable": false
    },
    {
      "id": "security/sensitive-console-log",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "security/sql-injection",
      "severity": "critical",
      "confidence": "medium",
      "detection": "hybrid",
      "tool": "semgrep",
      "fallback…

View raw (1414 bytes)

skills/audit/packs/security/checks.md

# Security Audit Pack

## Scope

This pack audits source code for security vulnerabilities including hardcoded credentials, injection risks, cross-site scripting vectors, missing security headers, and edge function authentication bypasses. It applies OWASP Top 10 guidance and uses tool-assisted detection (gitleaks for secrets, semgrep for injection/XSS patterns) backed by LLM confirmation. It does NOT audit dependency CVEs (covered by the dependencies pack), infrastructure misconfigurations, or runtime security controls.

**Defense-in-depth cross-reference:** The `ccgm/secrets` pack provides deeper coverage of
committed secrets: full git history scanning (`secrets/leaked-credential`), tracked `.env`
files (`secrets/tracked-env-file`), tracked private key material (`secrets/tracked-key-mate…

View raw (23028 bytes)

skills/audit/packs/dependencies/pack.json

{
  "id": "ccgm/dependencies",
  "name": "Dependencies Audit",
  "version": "1.1.0",
  "applies_when": ["language:javascript"],
  "tags": ["dependencies", "npm", "pip", "cargo", "bundler", "vulnerabilities", "supply-chain"],
  "severity_floor": "low",
  "tools": ["dep-audit", "knip", "pip-audit", "cargo-audit", "bundler-audit"],
  "checks": [
    {
      "id": "dependencies/npm-audit-vulnerability",
      "severity": "high",
      "confidence": "high",
      "detection": "tool",
      "tool": "dep-audit",
      "auto_fixable": true
    },
    {
      "id": "dependencies/outdated-minor",
      "severity": "low",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "dependencies/outdated-major",
      "severity": "medium",
      "confidenc…

View raw (1939 bytes)

skills/audit/packs/dependencies/checks.md

# Dependencies Audit Pack

## Scope

This pack audits dependency health across multiple ecosystems: npm (JavaScript/TypeScript), pip (Python), Cargo (Rust), and Bundler (Ruby). It covers known CVE vulnerabilities via tool-backed spine wrappers (dep-audit, pip-audit, cargo-audit, bundler-audit), plus supply-chain checks for postinstall lifecycle scripts, typosquatting, lockfile integrity, and unpinned version ranges. It also covers npm-specific outdatedness and dead-code cleanup (knip). It does NOT audit license compliance (covered by the tos-compliance pack), Go dependency trees (covered by govulncheck in the security pack), peer-dependency conflicts beyond what npm audit reports, or Python/Ruby/Rust outdatedness (those ecosystems lack a tool-backed auditor in this wave).

**Pack ID:** `cc…

View raw (30639 bytes)

skills/audit/packs/tos-compliance/pack.json

{
  "id": "ccgm/tos-compliance",
  "name": "Terms of Service & Policy Compliance Audit",
  "version": "1.0.0",
  "applies_when": [
    "always"
  ],
  "tags": [
    "compliance",
    "legal",
    "license",
    "tos",
    "privacy",
    "store-policy",
    "ai"
  ],
  "severity_floor": "medium",
  "tools": [],
  "checks": [
    {
      "id": "tos-compliance/copyleft-in-proprietary",
      "severity": "critical",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "tos-compliance/non-commercial-in-commercial",
      "severity": "critical",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "tos-compliance/missing-attribution",
      "severity": "high",
      "confidence": "medium",…

View raw (3860 bytes)

skills/audit/packs/tos-compliance/checks.md

# Terms of Service & Policy Compliance Audit Pack

## Scope

This pack audits for terms-of-service, license, and platform-policy violations across five compliance surfaces: (1) OSS/dependency license compliance, (2) third-party API and service ToS, (3) app/extension store and platform policy, (4) AI/LLM provider ToS, and (5) any other relevant ToS surface (email/SMS consent, payment processors, OAuth scope, CDN licensing). This is a COMPLIANCE audit: findings flag legal/policy risk for human review. Most findings are NOT auto-fixable — they require human or legal judgment. The pack self-detects which policy regimes apply based on the project's manifest files and dependencies; it does NOT audit runtime security controls (covered by the security pack) or dependency CVEs (covered by the depen…

View raw (72662 bytes)

skills/audit/reference/pack-quality-bar.md

# Pack Quality Bar

Every `/audit` check pack must clear this bar before being shipped. The goal is uniform signal quality across packs: an agent running any pack should get deterministic, well-scoped prompts — not guesswork.

A pack meets the quality bar when its `checks.md` contains a filled-in Scope section (one focused paragraph stating what is and is not covered), an `applies_when` rationale table with one row per gating condition, and a per-check block for every check declared in `pack.json`. Each per-check block must name the detection mode (`tool`, `llm`, or `hybrid`), include a full LLM instruction for any LLM or hybrid check (specific enough that two different agents would reach the same finding set), cite a real spine tool for any tool or hybrid check, provide severity and confi…

View raw (1348 bytes)

skills/audit/packs/testing/pack.json

{
  "id": "ccgm/testing",
  "name": "Testing Audit",
  "version": "1.1.0",
  "applies_when": [
    "always"
  ],
  "tags": [
    "testing",
    "coverage",
    "quality"
  ],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "testing/missing-test-file",
      "severity": "medium",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "testing/no-assertions",
      "severity": "high",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "testing/missing-edge-cases",
      "severity": "low",
      "confidence": "low",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "testing/sleep-based-flake",
      "severity": "medium",
      "conf…

View raw (1385 bytes)

skills/audit/packs/testing/checks.md

# Testing Audit Pack

**Pack ID:** `ccgm/testing`
**Applies when:** `always`

---

## Scope

This pack audits test coverage and test quality across the codebase. It checks for source files
that lack corresponding test files, test files that contain no assertions (and therefore prove
nothing), test files that omit edge-case scenarios (empty inputs, boundary values, error paths),
arbitrary-sleep-based synchronization in tests that causes flakiness, committed `.only`/`.skip`
modifiers that silently narrow or disable the suite, tests that assert on mock call-logs rather
than real system behavior, and production code that exposes test-only setter or reset methods.

It does NOT audit test runtime performance, test framework configuration, or whether coverage
percentage meets a threshold. Worker …

View raw (28272 bytes)

skills/audit/packs/documentation/pack.json

{
  "id": "ccgm/documentation",
  "name": "Documentation Audit",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["documentation", "jsdoc", "readme", "quality"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "documentation/missing-jsdoc",
      "severity": "low",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "documentation/stale-comment",
      "severity": "low",
      "confidence": "low",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "documentation/incomplete-readme",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/documentation/checks.md

# Documentation Audit Pack

**Pack ID:** `ccgm/documentation`
**Applies when:** `always`

---

## Scope

This pack audits the quality and completeness of inline code documentation and project-level
documentation. It checks for exported symbols missing JSDoc, code comments that no longer
accurately describe the code they annotate (stale comments), and README files that are
incomplete or missing key sections. It does NOT audit auto-generated API docs, test
documentation, or changelog formatting. All three checks are NOT auto-fixable: generated
documentation stubs do not substitute for meaningful human-authored documentation.

---

## applies_when Rationale

| Condition | Reason |
|-----------|--------|
| `always` | Documentation gaps are relevant in every project regardless of ecosystem; lan…

View raw (10939 bytes)

skills/audit/packs/performance/pack.json

{
  "id": "ccgm/performance",
  "name": "Performance Audit",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["performance", "react", "bundle", "database", "quality"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "performance/n-plus-one-query",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "performance/missing-react-memo",
      "severity": "low",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "performance/large-bundle-import",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/performance/checks.md

# Performance Audit Pack

**Pack ID:** `ccgm/performance`
**Applies when:** `always`

---

## Scope

This pack audits common performance anti-patterns in application code. It checks for N+1
query patterns (a data-fetching loop that issues one query per item rather than batching),
React components that re-render unnecessarily due to missing `React.memo`, and large
library imports that pull in entire packages when only a small subset is needed. It does NOT
audit server infrastructure, network latency, database index design, or build pipeline
performance. React-specific checks (missing-react-memo) produce no findings on non-React
repositories.

---

## applies_when Rationale

| Condition | Reason |
|-----------|--------|
| `always` | N+1 query and large-bundle-import checks apply to any stack…

View raw (11545 bytes)

skills/audit/scripts/spine/exclude-dirs.txt

# CCGM audit spine -- canonical path-exclusion list (single source of truth).
#
# These directory NAMES are excluded from every file-walking spine tool and
# from the coordinator-side junk-path post-filter (filter-excluded.py).  They
# are vendored, generated, or coordination directories that never contain
# source the audit should report on.  A populated node_modules or a tree of
# stale agent worktrees would otherwise explode the spine to tens of thousands
# of findings and 40+ minute runs (see audit-skill-feedback-2026-06-14 #1).
#
# Consumed by:
#   - exclude.sh           (sourced by the bash wrappers; builds per-tool flags)
#   - filter-excluded.py   (the always-on coordinator post-filter in run.sh)
#
# Format: one directory name per line.  Blank lines and #-comment lines ignored.
# Names are matched as path SEGMENTS, not substrings (e.g. "build" matches
# "build/" or "x/build/y" but never "rebuild/").
node_modules
.git
.claude
.audit
dist
build
.next
out
coverage
.turbo
vendor
DerivedData
.build
target
.venv
venv
__pycache__
.mypy_cache
.pytest_cache
.gradle
.idea

skills/audit/scripts/spine/exclude-file-globs.txt

# CCGM audit spine -- canonical excluded FILE-GLOB list (single source of truth).
#
# Companion to exclude-dirs.txt. These globs match vendored or generated files
# by NAME, regardless of which directory they live in -- the directory denylist
# cannot catch a committed `client/public/js-dos/foo.min.js` because none of its
# path segments is an excluded dir name. Minified / bundled assets are machine-
# generated; linting them produces thousands of junk findings (the lem-work run
# reported 4,828 eqeqeq from minified vendor JS).
#
# Consumed by:
#   - exclude.sh   (sourced by the bash wrappers; builds per-tool exclude flags)
#   - exclude.py   (path_is_excluded basename match + the run.sh post-filter)
#
# Format: one glob per line, matched against the file BASENAME (fnmatch). Blank
# lines and #-comment lines are ignored.
*.min.js
*.min.css
*.min.mjs
*.bundle.js
*.bundle.css
*.map

skills/audit/packs/ci-cd/pack.json

{
  "id": "ccgm/ci-cd",
  "name": "CI/CD Hardening",
  "version": "1.0.0",
  "applies_when": ["has_workflows"],
  "tags": ["ci", "github-actions", "supply-chain", "security"],
  "severity_floor": "medium",
  "tools": ["actionlint", "zizmor", "pinact"],
  "checks": [
    {
      "id": "cicd/unpinned-action",
      "severity": "high",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "pinact",
      "fallback": "llm",
      "auto_fixable": false
    },
    {
      "id": "cicd/dangerous-trigger",
      "severity": "critical",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "zizmor",
      "fallback": "llm",
      "auto_fixable": false
    },
    {
      "id": "cicd/excessive-permissions",
      "severity": "medium",
      "confidence": "high",
     …

View raw (1258 bytes)

skills/audit/packs/ci-cd/checks.md

# CI/CD Hardening Pack

## Scope

This pack audits GitHub Actions workflow files for supply-chain and security issues:
unpinned third-party actions, dangerous trigger configurations, overbroad GITHUB_TOKEN
permissions, expression-injection vulnerabilities, and actionlint syntax/usage errors.
It does NOT audit application source code, Docker images, or non-GitHub CI systems
(e.g. CircleCI, Jenkins). It operates exclusively on files under `.github/workflows/`.

**Pack ID:** `ccgm/ci-cd`
**Applies when:** `has_workflows`

---

## applies_when Rationale

| Condition | Reason |
|-----------|--------|
| `has_workflows` | The pack audits `.github/workflows/` files exclusively; a repo without that directory has zero workflow files to scan, making all five checks vacuous. |

---

## Checks

---

##…

View raw (11908 bytes)

skills/audit/packs/code-quality/pack.json

{
  "id": "ccgm/code-quality",
  "name": "Code Quality",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["code-quality", "prettier", "style"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "code-quality/eslint-violation",
      "severity": "medium",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "code-quality/prettier-violation",
      "severity": "low",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "code-quality/unused-import",
      "severity": "low",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "code-quality/long-method",
      "severity": "medium",
      "confidence"…

View raw (1207 bytes)

skills/audit/packs/code-quality/checks.md

# checks.md — Code Quality Pack

---

## Scope

This pack audits source code for common quality issues: linting violations, formatting drift, unused imports, oversized methods and files, and unhandled error paths (empty catch blocks). It covers all languages for the language-agnostic checks (long-method, large-file, empty-catch-block) and JavaScript/TypeScript projects for the ESLint-based checks. The ESLint-based checks (eslint-violation, unused-import) use LLM detection; the worker agent may run `npx eslint` for advisory results, but does not rely on the spine's eslint wrapper (which is config-isolated to a fixed eval-rule surface under the `lint/*` namespace). This pack does NOT cover security vulnerabilities, dependency health, architectural patterns, or TypeScript/React-specific type …

View raw (19361 bytes)

skills/audit/packs/typescript-react/pack.json

{
  "id": "ccgm/typescript-react",
  "name": "TypeScript / React",
  "version": "1.0.0",
  "applies_when": ["language:javascript"],
  "tags": ["typescript", "react", "hooks", "fast-refresh"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "typescript/excessive-any",
      "severity": "medium",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "typescript/missing-return-type",
      "severity": "low",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "typescript/react-hooks-violation",
      "severity": "high",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "typescript/fast-refresh-violation",
      "severity": "medium",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "typescript/missing-key-prop",
      "severity": "medium",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/typescript-react/checks.md

# checks.md — TypeScript / React Pack

---

## Scope

This pack audits TypeScript and React-specific patterns: overuse of the `any` escape hatch, missing function return type annotations, React Hooks Rules violations, React Fast Refresh violations (mixed component/non-component exports), and missing `key` props in list renders. It applies to any repository that uses JavaScript or TypeScript (detected by the presence of a `package.json`), which includes plain-JS React projects and TypeScript projects alike. This pack does NOT cover general code quality smells, architecture patterns, security issues, or dependency health — those belong in their respective packs.

**Pack ID:** `ccgm/typescript-react`
**Applies when:** `language:javascript`

---

## applies_when Rationale

| Condition | Reason…

View raw (17283 bytes)

skills/audit/packs/architecture/pack.json

{
  "id": "ccgm/architecture",
  "name": "Architecture",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["architecture", "structure", "dependencies", "layering"],
  "severity_floor": "medium",
  "tools": [],
  "checks": [
    {
      "id": "architecture/circular-dependency",
      "severity": "high",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "architecture/god-object",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "architecture/improper-layering",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "architecture/wrong-layer-import",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": true
    }
  ]
}

skills/audit/packs/architecture/checks.md

# checks.md — Architecture Pack

---

## Scope

This pack audits structural and architectural patterns: circular module dependencies, god objects (modules or classes with excessive responsibilities), improper layering (concerns mixed across architectural layers), and wrong-layer imports (a layer importing from a layer it should not depend on). All checks apply to any codebase regardless of language, since these structural antipatterns are language-agnostic. This pack does NOT cover code quality smells (line lengths, linting), TypeScript-specific type patterns, security vulnerabilities, or dependency health — those belong in their respective packs.

**Pack ID:** `ccgm/architecture`
**Applies when:** `always`

---

## applies_when Rationale

| Condition | Reason |
|-----------|--------|
| `a…

View raw (14816 bytes)

skills/audit/packs/accessibility/pack.json

{
  "id": "ccgm/accessibility",
  "name": "Accessibility",
  "version": "1.0.0",
  "applies_when": ["language:javascript"],
  "tags": ["accessibility", "a11y", "jsx", "wcag"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "a11y/img-missing-alt",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "a11y/click-without-keyboard",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "a11y/anchor-missing-rel",
      "severity": "medium",
      "confidence": "high",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "a11y/missing-prefers-reduced-motion",
      "severity": "low",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "a11y/tailwind-cursor-pointer",
      "severity": "low",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": true
    }
  ]
}

skills/audit/packs/accessibility/checks.md

# checks.md — Accessibility Pack

---

## Scope

This pack audits JSX and TSX source files for common accessibility (a11y) defects: missing
`alt` attributes on images, click handlers on non-interactive elements without keyboard
support, `target="_blank"` anchors lacking `rel="noopener noreferrer"`, animations and
transitions without a `prefers-reduced-motion` guard, and interactive elements in Tailwind
v4 projects that rely on default cursor styles (Tailwind v4 no longer sets `cursor:pointer`
on buttons globally). Checks self-scope to `.jsx` and `.tsx` files; a JavaScript
back-end project with no JSX will match the `language:javascript` gate but produce zero
findings, which is the expected graceful behaviour. This pack does NOT audit semantic HTML
correctness (heading order, landmark regio…

View raw (20555 bytes)

skills/audit/packs/reliability/pack.json

{
  "id": "ccgm/reliability",
  "name": "Reliability & Error Handling",
  "version": "1.0.0",
  "applies_when": ["language:javascript"],
  "tags": ["reliability", "promises", "async", "error-handling"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "reliability/floating-promise",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "reliability/misused-promise",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "reliability/unhandled-promise",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "reliability/await-in-…

View raw (1451 bytes)

skills/audit/packs/reliability/checks.md

# checks.md — Reliability & Error Handling Pack

---

## Scope

This pack audits JavaScript and TypeScript source files for async reliability defects: floating promises, misused promise return types, unhandled promise chains, sequential `await` inside loops, HTTP calls without a timeout or abort signal, retry loops without backoff or jitter, and incorrect use of `Promise.all` where partial failures should use `Promise.allSettled`. All checks use LLM detection; the worker agent may run the repo's own `npx eslint` advisory (read-only) but the spine's eslint wrapper (`scripts/spine/wrap-eslint.sh`) runs only `no-eval`/`no-implied-eval`/`no-new-func` via `--no-config-lookup` and cannot cover these rule classes. This pack does NOT cover general code quality (empty catch blocks, style, formattin…

View raw (25016 bytes)

skills/audit/packs/correctness/pack.json

{
  "id": "ccgm/correctness",
  "name": "Correctness / Logic",
  "version": "1.0.0",
  "applies_when": ["language:javascript"],
  "tags": ["correctness", "logic", "eslint"],
  "severity_floor": "medium",
  "tools": ["eslint"],
  "checks": [
    {
      "id": "correctness/off-by-one",
      "severity": "high",
      "confidence": "low",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "correctness/float-equality",
      "severity": "medium",
      "confidence": "low",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "correctness/wrong-branch-logic",
      "severity": "high",
      "confidence": "low",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/correctness/checks.md

# Correctness / Logic Pack

## Scope

This pack audits JavaScript source files for correctness and logic errors. It covers two layers of detection. First, the deterministic layer: the spine's eslint wrapper (`wrap-eslint.sh`) runs seven core ESLint rules with `--no-config-lookup` and no type information; these rules emit findings under the `lint/*` namespace (e.g. `lint/eqeqeq`, `lint/use-isnan`) and are severitied via the rubric at HIGH confidence. Second, the LLM best-effort layer: three checks (`correctness/off-by-one`, `correctness/float-equality`, `correctness/wrong-branch-logic`) are LLM-only, LOW confidence, with no deterministic backing. This pack does NOT audit security vulnerabilities, dependency health, architectural patterns, performance, TypeScript type safety, or Python/Go/ot…

View raw (14042 bytes)

skills/audit/packs/secrets/pack.json

{
  "id": "ccgm/secrets",
  "name": "Secrets & Credentials",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["secrets", "credentials", "security"],
  "severity_floor": "high",
  "tools": ["gitleaks"],
  "checks": [
    {
      "id": "secrets/leaked-credential",
      "severity": "critical",
      "confidence": "high",
      "detection": "tool",
      "tool": "gitleaks",
      "auto_fixable": false
    },
    {
      "id": "secrets/tracked-env-file",
      "severity": "high",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "grep",
      "fallback": "llm",
      "auto_fixable": false
    },
    {
      "id": "secrets/tracked-key-material",
      "severity": "critical",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "grep",
      "fallback": "llm",
      "auto_fixable": false
    },
    {
      "id": "secrets/history-only-credential",
      "severity": "high",
      "confidence": "high",
      "detection": "tool",
      "tool": "gitleaks",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/secrets/checks.md

# Secrets & Credentials Pack

## Scope

This pack audits git repositories for exposed secrets and credentials: values committed to version
control that should never be tracked. It covers credentials found in the full git history (including
commits that were later reverted or squashed), `.env` files tracked by git, and private key material
checked into the repo. It does NOT audit runtime secret injection, environment variable
configuration, or secrets stored in external vaults. Dependency CVE scanning is covered by the
dependencies pack; runtime misconfigurations are covered by the security pack.

**Pack ID:** `ccgm/secrets`
**Applies when:** `always`

---

## applies_when Rationale

| Condition | Reason |
|-----------|--------|
| `always` | Secrets can be committed to any repository regard…

View raw (13272 bytes)

skills/audit/packs/data-migrations/pack.json

{
  "id": "ccgm/data-migrations",
  "name": "Data & Migrations Audit",
  "version": "1.0.0",
  "applies_when": ["has_migrations"],
  "tags": ["database", "migrations", "postgresql", "sql"],
  "severity_floor": "medium",
  "tools": ["squawk", "sqlfluff"],
  "checks": [
    {
      "id": "dm/unquoted-reserved-keyword",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": true
    },
    {
      "id": "dm/index-without-concurrently",
      "severity": "high",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "squawk",
      "fallback": "llm",
      "auto_fixable": true
    },
    {
      "id": "dm/missing-rls",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
  …

View raw (1201 bytes)

skills/audit/packs/data-migrations/checks.md

# Data & Migrations Audit

## Scope

This pack audits SQL migration files for PostgreSQL-specific dangerous patterns. It
targets files under known migration directories (`supabase/migrations`, `prisma/migrations`,
`db/migrate`, `db/migrations`, `database/migrations`). Checks cover reserved-keyword
quoting, locking risks from non-concurrent index creation, missing row-level security on
new tables, invalid ON CONFLICT usage, and SECURITY DEFINER functions that require
reviewer attention. This pack does NOT audit application-level query code, ORM model
definitions, or non-SQL config files.

**Pack ID:** `ccgm/data-migrations`
**Applies when:** `["has_migrations"]`

---

## applies_when Rationale

| Condition | Reason |
|-----------|--------|
| `has_migrations` | Pack is only useful when a rec…

View raw (17756 bytes)

skills/audit/packs/api-contract/pack.json

{
  "id": "ccgm/api-contract",
  "name": "API & Contract Audit",
  "version": "1.0.0",
  "applies_when": ["language:javascript"],
  "tags": ["api", "security", "validation", "contract"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "api/missing-input-validation",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "api/mass-assignment",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "api/unbounded-list",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "api/missing-versioning",
      "severity": "low",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/api-contract/checks.md

# API & Contract Audit Pack

## Scope

This pack audits JavaScript and TypeScript HTTP route handlers and API endpoints for
contract-level defects: missing input validation, mass-assignment vulnerabilities,
unbounded list endpoints, and absent API versioning. It targets the surface area where
untrusted caller data enters a backend handler and where the API contract lacks
structural controls. Checks are LLM-based (no spine tool) because the defects require
understanding handler intent, not just syntactic patterns. This pack does NOT cover SQL
injection or XSS (owned by `ccgm/security`), authentication bypass (also
`ccgm/security`), or dependency vulnerabilities (`ccgm/dependencies`).

**Pack ID:** `ccgm/api-contract`
**Applies when:** `["language:javascript"]`

---

## applies_when Rational…

View raw (16249 bytes)

skills/audit/packs/privacy/pack.json

{
  "id": "ccgm/privacy",
  "name": "Privacy & PII Handling",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["privacy", "pii", "gdpr", "consent", "data-handling"],
  "severity_floor": "medium",
  "tools": [],
  "checks": [
    {
      "id": "privacy/pii-without-consent",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "privacy/pii-no-retention",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "privacy/pii-in-url",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/privacy/checks.md

# checks.md — Privacy & PII Handling Pack

---

## Scope

This pack audits source code for data-handling patterns that put Personally Identifiable Information (PII) at risk: analytics or tracking SDKs wired without a user consent gate, PII stored or persisted with no documented retention or deletion path, and PII passed in URL query strings or GET parameters where it leaks into server logs, browser history, and referrer headers. All three checks target code behavior — what the application does with personal data — not legal documents or license terms. This pack does NOT audit license compliance, terms-of-service adherence, or the presence of a privacy policy document; those are covered by the `ccgm/tos-compliance` pack. Checks produce nothing when no data-handling code is present (`applies…

View raw (14016 bytes)

skills/audit/packs/observability/pack.json

{
  "id": "ccgm/observability",
  "name": "Observability & Logging Quality",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["observability", "logging", "error-reporting", "telemetry"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "observability/pii-in-logs",
      "severity": "high",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "observability/missing-structured-logging",
      "severity": "low",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "observability/missing-error-reporting",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/observability/checks.md

# checks.md — Observability & Logging Quality Pack

---

## Scope

This pack audits source code for observability anti-patterns: logging calls that emit user objects or PII fields directly (leaking personal data into log infrastructure), raw `console.log` statements used in server-side code where a structured logger is expected, and caught errors that are silently swallowed with no telemetry or error-reporting call. All checks use LLM detection targeting code behavior. This pack does NOT audit privacy policy compliance, consent gates, or data retention — those belong in the `ccgm/privacy` pack. It also does NOT audit general empty-catch patterns without considering the observability context (that overlaps `code-quality/empty-catch-block`); the `observability/missing-error-reporting` check …

View raw (14345 bytes)

skills/audit/packs/ccgm-hygiene/pack.json

{
  "id": "ccgm/ccgm-hygiene",
  "name": "CCGM Hygiene",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["hygiene", "conventions", "env", "cloudflare", "todo"],
  "severity_floor": "info",
  "tools": [],
  "checks": [
    {
      "id": "ccgm/shipped-todo-marker",
      "severity": "info",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "grep",
      "fallback": "llm"
    },
    {
      "id": "ccgm/env-example-drift",
      "severity": "medium",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "ccgm/cloudflare-pages-no-git",
      "severity": "high",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "grep",
      "fallback": "llm"
    }
  ]
}

skills/audit/packs/ccgm-hygiene/checks.md

# checks.md — CCGM Hygiene Pack

---

## Scope

This pack audits source repositories for three hygiene issues that the CCGM conventions
identify as common and costly: committed TODO/FIXME/XXX/HACK markers that signal deferred
work shipped to production; environment-variable drift between code and `.env.example`
(variables referenced in code but undocumented, or documented but no longer referenced);
and Cloudflare Pages projects created as direct-upload (non-Git-connected) deployments,
which cannot be retrofitted with Git integration and must be deleted and recreated to fix.

All three checks gate themselves to applicable repos inside their detection instructions
(self-scoping). A repo with no `.env.example` and no env-var references produces no
`ccgm/env-example-drift` findings. A repo wit…

View raw (13444 bytes)

skills/audit/packs/ccgm-standards/pack.json

{
  "id": "ccgm/ccgm-standards",
  "name": "CCGM Project Standards",
  "version": "1.0.0",
  "applies_when": ["always"],
  "tags": ["standards", "conventions", "mcp", "project-rules"],
  "severity_floor": "low",
  "tools": [],
  "checks": [
    {
      "id": "ccgm/project-standards-conformance",
      "severity": "medium",
      "confidence": "low",
      "detection": "llm",
      "auto_fixable": false
    },
    {
      "id": "ccgm/mcp-tool-annotations",
      "severity": "low",
      "confidence": "medium",
      "detection": "llm",
      "auto_fixable": false
    }
  ]
}

skills/audit/packs/ccgm-standards/checks.md

# checks.md — CCGM Project Standards Pack

---

## Scope

This pack audits repos for two project-convention gaps surfaced by the CCGM ruleset:
code that violates rules explicitly declared in the repo's own `CLAUDE.md` or `AGENTS.md`
project standards file; and MCP server tool definitions that are missing the safety
annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) required
by the CCGM MCP development rules.

Both checks are LLM-detected and self-scope to repos where the relevant signals exist:
`ccgm/project-standards-conformance` only runs when a `CLAUDE.md` or `AGENTS.md` file is
present; `ccgm/mcp-tool-annotations` only runs when MCP server registration patterns are
detected in source.

This pack does NOT cover general code quality, security, environment-…

View raw (10398 bytes)

skills/audit/packs/infra-iac/pack.json

{
  "id": "ccgm/infra-iac",
  "name": "Infrastructure & IaC Security",
  "version": "1.0.0",
  "applies_when": ["has_iac"],
  "tags": ["security", "iac", "infrastructure", "docker", "terraform", "kubernetes"],
  "severity_floor": "medium",
  "tools": ["hadolint", "checkov", "trivy"],
  "checks": [
    {
      "id": "iac/dockerfile-root-user",
      "severity": "high",
      "confidence": "high",
      "detection": "hybrid",
      "tool": "hadolint",
      "rule": "DL3002",
      "fallback": "grep"
    },
    {
      "id": "iac/dockerfile-latest-tag",
      "severity": "medium",
      "confidence": "high",
      "detection": "tool",
      "tool": "hadolint",
      "rule": "DL3007"
    },
    {
      "id": "iac/public-ingress",
      "severity": "high",
      "confidence": "medium",
      "d…

View raw (1233 bytes)

skills/audit/packs/infra-iac/checks.md

# Infrastructure & IaC Security Pack

## Scope

This pack audits Infrastructure-as-Code files for security and misconfiguration issues. It covers Dockerfiles, Terraform HCL, Kubernetes manifests, and CloudFormation templates. It does NOT audit application source code, dependency vulnerabilities, or secret management systems outside of IaC configuration.

**Pack ID:** `ccgm/infra-iac`
**Applies when:** `["has_iac"]`

---

## applies_when Rationale

| Condition | Reason |
|-----------|--------|
| `has_iac` | Pack is only useful when IaC files exist; running on repos without Dockerfiles, Terraform, k8s manifests, or CloudFormation templates produces zero signal. `has_iac` is true when any of these IaC file types are detected by the ecosystem detector. |

---

## Checks

---

### `iac/dockerfi…

View raw (11857 bytes)