Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/schemas/company-details.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"legal_name": { "type": "string" },
"registered_address": { "type": "string" },
"approx_users": { "type": "integer", "minimum": 0 },
"annual_turnover_eur": { "type": "number", "minimum": 0, "description": "Worldwide annual turnover of the preceding year, for the GDPR Art. 83 percentage caps" },
"jurisdiction": {
"type": "array",
"items": { "type": "string", "enum": ["IN", "EU", "UK", "US", "SG", "other"] }
Expand Down
2 changes: 1 addition & 1 deletion .claude/schemas/finding.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"cross_reference": { "type": "string", "description": "The equivalent clause in the other framework when a finding applies to both, e.g. 'GDPR Art. 33(1)'" },
"issue": { "type": "string", "minLength": 1 },
"recommendation": { "type": "string", "minLength": 1 },
"file": { "type": "string", "description": "Source file path if code-related. Never the data itself." },
"file": { "type": "string", "pattern": "^(?![A-Za-z]:|[\\\\/]|\\.\\./)", "description": "Source file path relative to the audited codebase's root, if code-related. Never absolute, and never the data itself." },
"line": { "type": "integer", "minimum": 1 },
"owner": { "type": "string", "description": "Accountable role, e.g. 'DPO', 'Engineering Lead'" },
"due_date": { "type": "string", "format": "date" },
Expand Down
5 changes: 3 additions & 2 deletions .claude/schemas/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
{ "pattern": "assessments/*/data-map.json", "schema": "data-map.schema.json", "description": "Data map captured during the assessment" },
{ "pattern": "assessments/*/**", "description": "Summaries, remediation plans and other assessment artefacts" },
{ "pattern": "kpis/**", "description": "KPI definitions, raw session data and computed metrics" },
{ "pattern": "examples/**", "description": "Consent notice templates and sector guides" },
{ "pattern": ".claude/**", "description": "Harness assets: skills, schemas, scripts, settings" },
{ "pattern": "examples/**", "description": "Consent notice templates, sector guides and a synthetic app to audit" },
{ "pattern": ".claude/skills/**", "description": "Skills. Hooks, schemas and settings are left out on purpose: agents must not edit their own guards" },
{ "pattern": ".github/**", "description": "CI workflows and issue templates" },
{ "pattern": "docs/**", "description": "Documentation and images" },
{ "pattern": "aranya/**", "description": "Aranya: self-contained environmental-law sub-project staged inside this repo; has its own layout, schemas and validation" },
Expand All @@ -27,6 +27,7 @@
{ "pattern": "CONTRIBUTING.md" },
{ "pattern": "SECURITY.md" },
{ "pattern": "CHANGELOG.md" },
{ "pattern": "CITATION.cff" },
{ "pattern": "LICENSE" },
{ "pattern": "package.json" },
{ "pattern": "package-lock.json" },
Expand Down
13 changes: 13 additions & 0 deletions .claude/schemas/report.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@
"started_at": { "type": "string", "format": "date-time" },
"completed_at": { "type": "string", "format": "date-time" },
"assessor": { "type": "string", "description": "Who or what ran the audit, e.g. 'Vigil on Claude Code'" },
"target": {
"type": "object",
"description": "The codebase audited, so a reader can check the findings against the same code",
"required": ["repo"],
"properties": {
"repo": { "type": "string", "description": "Repository name or URL" },
"commit": { "type": "string", "description": "Commit SHA, if it is a git repository" }
},
"additionalProperties": false
},
"model": { "type": "string", "description": "Model that ran the audit, e.g. 'claude-sonnet-5-5'" },
"vigil_version": { "type": "string", "description": "The version in package.json" },
"law_as_at": { "type": "string", "format": "date", "description": "The 'Last verified' date of the catalogs relied on" },
"coverage": {
"type": "object",
"required": ["applicable", "examined"],
Expand Down
6 changes: 4 additions & 2 deletions .claude/scripts/export-ocsf.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { writeFileSync } from 'node:fs';
import { loadFindings, packageVersion } from './lib.mjs';
import { loadFindings, requireReport, packageVersion } from './lib.mjs';

const args = process.argv.slice(2);
function flag(name) {
Expand All @@ -22,8 +22,10 @@ if (!company) {
console.error('Usage: node .claude/scripts/export-ocsf.mjs --company <id> --output <file>');
process.exit(1);
}
requireReport(company);

const SEVERITY_ID = { low: 2, medium: 3, high: 4, critical: 5 };
const STATUS_ID = { open: 1, in_progress: 2, disputed: 2, accepted: 3, remediated: 4 }; // New, In Progress, Suppressed, Resolved
const findings = loadFindings(company);
const version = packageVersion();

Expand All @@ -33,7 +35,7 @@ const events = findings.map(f => ({
activity_id: 1,
type_uid: 200301,
severity_id: SEVERITY_ID[f.severity] ?? 0,
status_id: f.status === 'remediated' ? 3 : 1,
status_id: STATUS_ID[f.status] ?? 1,
time: Date.parse(f.found_at ?? '') || Date.now(),
metadata: {
version: '1.3.0',
Expand Down
9 changes: 7 additions & 2 deletions .claude/scripts/export-oscal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { loadFindings, packageVersion } from './lib.mjs';
import { loadFindings, requireReport, packageVersion } from './lib.mjs';

const args = process.argv.slice(2);
function flag(name) {
Expand All @@ -24,6 +24,10 @@ if (!company) {
console.error('Usage: node .claude/scripts/export-oscal.mjs --company <id> --output <file>');
process.exit(1);
}
requireReport(company);

// OSCAL target-id is a token: a letter or underscore, then letters, digits, dots, hyphens or underscores.
const token = text => text.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/-+$/, '');

const postureFile = join('company-profile', company, 'posture.json');
const posture = existsSync(postureFile) ? JSON.parse(readFileSync(postureFile, 'utf8')) : null;
Expand All @@ -49,14 +53,15 @@ const oscalFindings = findings.map(f => {
title: f.issue || 'Untitled finding',
description: f.recommendation || '',
target: {
'target-id': f.clause || 'unknown',
'target-id': token(`${f.framework}-${f.clause}`),
type: 'objective-id',
status: { state: f.status === 'remediated' ? 'satisfied' : 'not-satisfied' },
},
...(related.length ? { 'related-observations': related } : {}),
props: [
{ name: 'severity', value: f.severity },
{ name: 'framework', value: f.framework },
{ name: 'clause', value: f.clause },
{ name: 'vigil-id', value: f.id },
],
};
Expand Down
29 changes: 19 additions & 10 deletions .claude/scripts/export-sarif.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { readFileSync, writeFileSync } from 'node:fs';
import { loadFindings, packageVersion } from './lib.mjs';
import { loadFindings, requireReport, packageVersion } from './lib.mjs';

const args = process.argv.slice(2);
function flag(name) {
Expand All @@ -25,10 +25,17 @@ if (!inputFile && !company) {
process.exit(1);
}

const findings = inputFile
if (!inputFile) requireReport(company);

const all = inputFile
? JSON.parse(readFileSync(inputFile, 'utf8')).findings ?? []
: loadFindings(company);

// Code scanning needs a location to display a result, and a closed finding is not a live alert.
const closed = all.filter(f => ['remediated', 'accepted'].includes(f.status));
const unlocated = all.filter(f => !f.file && !closed.includes(f));
const findings = all.filter(f => f.file && !closed.includes(f));

function severityToLevel(sev) {
switch (sev) {
case 'critical': case 'high': return 'error';
Expand All @@ -51,6 +58,8 @@ for (const f of findings) {
rules.set(ruleId, {
id: ruleId,
shortDescription: { text: `${f.framework || 'Unknown'} ${f.clause || 'unknown clause'}` },
fullDescription: { text: `Possible gap against ${f.framework || 'Unknown'} ${f.clause || 'unknown clause'}. Each result states the issue and a recommendation.` },
help: { text: 'Compliance tooling, not legal advice. Check the clause with /dpdp-catalog or /gdpr-catalog before relying on it.' },
defaultConfiguration: { level: severityToLevel(f.severity) },
});
}
Expand All @@ -73,18 +82,18 @@ const sarif = {
level: severityToLevel(f.severity),
// SARIF `fixes` require concrete artifactChanges, so the recommendation travels in the message.
message: { text: f.recommendation ? `${f.issue} Recommendation: ${f.recommendation}` : f.issue || '' },
...(f.file ? {
locations: [{
physicalLocation: {
artifactLocation: { uri: f.file },
...(f.line ? { region: { startLine: f.line } } : {}),
},
}],
} : {}),
locations: [{
physicalLocation: {
artifactLocation: { uri: f.file.replace(/\\/g, '/') },
...(f.line ? { region: { startLine: f.line } } : {}),
},
}],
properties: { id: f.id, severity: f.severity, status: f.status ?? 'open', clause: f.clause },
})),
}],
};

writeFileSync(outputFile, JSON.stringify(sarif, null, 2));
console.log(`SARIF 2.1.0 exported: ${findings.length} findings -> ${outputFile}`);
if (unlocated.length) console.log(`Left out, no file to point at: ${unlocated.map(f => f.id).join(', ')}. They stay in report.json, OSCAL and OCSF.`);
if (closed.length) console.log(`Left out, remediated or accepted: ${closed.map(f => f.id).join(', ')}.`);
29 changes: 20 additions & 9 deletions .claude/scripts/kpis.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Compute Vigil KPIs from session transcripts and workspace state.
*
* Usage: npm run kpis
* npm run kpis -- --company <id> (one company; otherwise every company is pooled)
*
* Reads:
* - kpis/data/raw/sessions/ (session transcripts from headless runs)
Expand All @@ -16,10 +17,14 @@

import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { listFiles, loadReports } from './lib.mjs';
import { listFiles, loadReports, latestReports, requireReport } from './lib.mjs';

const root = process.cwd();
const DAY = 24 * 60 * 60 * 1000;
const companyArg = process.argv.indexOf('--company');
const company = companyArg === -1 ? undefined : process.argv[companyArg + 1];
if (company) requireReport(company, root);
const inScope = r => !company || r.company_id === company;

function readJson(rel) {
try { return JSON.parse(readFileSync(join(root, rel), 'utf8')); }
Expand All @@ -42,20 +47,26 @@ for (const s of sessions) {
} catch { /* harness without cost data */ }
}

const reports = loadReports(root).sort((a, b) => a.started_at.localeCompare(b.started_at));
// Each company's latest report is its current state; older ones would count the same findings twice.
const reports = latestReports(root).filter(inScope);
const findings = reports.flatMap(r => r.findings);

// 2. Coverage: latest report per company that recorded coverage.
const latestCoverage = new Map();
for (const r of reports) if (r.coverage) latestCoverage.set(r.company_id, r.coverage);
const applicable = [...latestCoverage.values()].reduce((n, c) => n + c.applicable, 0);
const examined = [...latestCoverage.values()].reduce((n, c) => n + c.examined, 0);
const coverages = reports.map(r => r.coverage).filter(Boolean);
const applicable = coverages.reduce((n, c) => n + c.applicable, 0);
const examined = coverages.reduce((n, c) => n + c.examined, 0);

// 3. Actionability: recommendation, clause, owner and deadline all present.
const actionable = findings.filter(f => f.recommendation && f.clause && f.owner && f.due_date);

// 4. Time to remediation, in days.
const days = findings.filter(f => f.found_at && f.remediated_at)
// 4. Time to remediation, in days. Every report counts, since a fix may drop out of the next audit.
const remediated = new Map();
for (const r of loadReports(root).filter(inScope)) {
for (const f of r.findings) {
if (f.found_at && f.remediated_at) remediated.set(`${r.company_id}|${f.clause}|${f.file}|${f.remediated_at}`, f);
}
}
const days = [...remediated.values()]
.map(f => Math.max(0, Math.round((Date.parse(f.remediated_at) - Date.parse(f.found_at)) / DAY)))
.sort((a, b) => a - b);

Expand All @@ -64,7 +75,7 @@ const RANK = { non_compliant: 0, partially_compliant: 1, compliant: 2 };
const snapshots = new Map();
for (const rel of listFiles(root).filter(f => /^(assessments|company-profile)\/[^/]+\/posture\.json$/.test(f))) {
const p = readJson(rel);
if (!p) continue;
if (!p || !inScope(p)) continue;
const list = snapshots.get(p.company_id) ?? [];
if (!list.some(s => s.assessed_at === p.assessed_at)) list.push(p);
snapshots.set(p.company_id, list);
Expand Down
40 changes: 37 additions & 3 deletions .claude/scripts/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,32 @@
*/

import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, extname, relative } from 'node:path';
import { join, extname, relative, resolve, dirname } from 'node:path';

const SCHEMA_DIR = '.claude/schemas';
const SKIP_DIRS = new Set(['.git', 'node_modules']);

/** The nearest directory at or above `start` holding a workspace layout, so hooks survive a `cd` and worktrees. */
export function workspaceRoot(start = process.cwd()) {
for (let dir = resolve(start); ; dir = dirname(dir)) {
if (existsSync(join(dir, SCHEMA_DIR, 'layout.json'))) return dir;
if (dirname(dir) === dir) return undefined;
}
}

/** Problems with a SKILL.md frontmatter. A bare `: ` or ` #` in a value is not valid YAML. */
export function frontmatterErrors(text, name) {
const front = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const errors = [];
if (!new RegExp(`^name: ${name}\\s*$`, 'm').test(front)) errors.push(`frontmatter needs "name: ${name}"`);
const description = front.match(/^description: (.+?)\s*$/m)?.[1];
if (!description) errors.push('frontmatter needs a description');
else if (description.startsWith('"') ? !/^"(?:[^"\\]|\\.)*"$/.test(description) : /: | #/.test(description)) {
errors.push('description is not valid YAML: wrap it in double quotes and escape inner quotes');
}
return errors;
}

export function loadLayout(root = process.cwd()) {
const layout = JSON.parse(readFileSync(join(root, SCHEMA_DIR, 'layout.json'), 'utf8'));
return layout.paths.map(rule => ({ ...rule, regex: patternToRegex(rule.pattern) }));
Expand Down Expand Up @@ -142,13 +163,26 @@ export function checkFile(relPath, root = process.cwd(), layout = loadLayout(roo
// ---------------------------------------------------------------------------
// Findings

/** Findings from every assessments/<id>/report.json, optionally for one company. */
/** Findings from each company's latest report, so a re-audit replaces the last one instead of adding to it. */
export function loadFindings(company, root = process.cwd()) {
return loadReports(root)
return latestReports(root)
.filter(r => !company || r.company_id === company)
.flatMap(r => (r.findings ?? []).map(f => ({ ...f, company_id: r.company_id, assessment_id: r.assessment_id })));
}

export function latestReports(root = process.cwd()) {
const latest = new Map();
for (const r of loadReports(root).sort((a, b) => Date.parse(a.started_at) - Date.parse(b.started_at))) latest.set(r.company_id, r);
return [...latest.values()];
}

/** Exits with a message when a company has no report, so a typo is not mistaken for a clean audit. */
export function requireReport(company, root = process.cwd()) {
if (loadReports(root).some(r => r.company_id === company)) return;
console.error(`No report found for company "${company}" under assessments/. Run /audit first, or check the id.`);
process.exit(1);
}

export function loadReports(root = process.cwd()) {
const dir = join(root, 'assessments');
if (!existsSync(dir)) return [];
Expand Down
Loading
Loading