diff --git a/.claude/schemas/company-details.schema.json b/.claude/schemas/company-details.schema.json index afad9e6..60e5a7e 100644 --- a/.claude/schemas/company-details.schema.json +++ b/.claude/schemas/company-details.schema.json @@ -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"] } diff --git a/.claude/schemas/finding.schema.json b/.claude/schemas/finding.schema.json index 450ecfd..906c5f2 100644 --- a/.claude/schemas/finding.schema.json +++ b/.claude/schemas/finding.schema.json @@ -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" }, diff --git a/.claude/schemas/layout.json b/.claude/schemas/layout.json index ca3317a..d9f8637 100644 --- a/.claude/schemas/layout.json +++ b/.claude/schemas/layout.json @@ -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" }, @@ -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" }, diff --git a/.claude/schemas/report.schema.json b/.claude/schemas/report.schema.json index 04e245c..e019e66 100644 --- a/.claude/schemas/report.schema.json +++ b/.claude/schemas/report.schema.json @@ -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"], diff --git a/.claude/scripts/export-ocsf.mjs b/.claude/scripts/export-ocsf.mjs index e413c26..5c780c3 100644 --- a/.claude/scripts/export-ocsf.mjs +++ b/.claude/scripts/export-ocsf.mjs @@ -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) { @@ -22,8 +22,10 @@ if (!company) { console.error('Usage: node .claude/scripts/export-ocsf.mjs --company --output '); 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(); @@ -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', diff --git a/.claude/scripts/export-oscal.mjs b/.claude/scripts/export-oscal.mjs index 7de2133..2adb9b7 100644 --- a/.claude/scripts/export-oscal.mjs +++ b/.claude/scripts/export-oscal.mjs @@ -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) { @@ -24,6 +24,10 @@ if (!company) { console.error('Usage: node .claude/scripts/export-oscal.mjs --company --output '); 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; @@ -49,7 +53,7 @@ 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' }, }, @@ -57,6 +61,7 @@ const oscalFindings = findings.map(f => { props: [ { name: 'severity', value: f.severity }, { name: 'framework', value: f.framework }, + { name: 'clause', value: f.clause }, { name: 'vigil-id', value: f.id }, ], }; diff --git a/.claude/scripts/export-sarif.mjs b/.claude/scripts/export-sarif.mjs index 918fa3a..5323a50 100644 --- a/.claude/scripts/export-sarif.mjs +++ b/.claude/scripts/export-sarif.mjs @@ -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) { @@ -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'; @@ -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) }, }); } @@ -73,14 +82,12 @@ 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 }, })), }], @@ -88,3 +95,5 @@ const sarif = { 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(', ')}.`); diff --git a/.claude/scripts/kpis.mjs b/.claude/scripts/kpis.mjs index 4feef73..6f0c097 100644 --- a/.claude/scripts/kpis.mjs +++ b/.claude/scripts/kpis.mjs @@ -4,6 +4,7 @@ * Compute Vigil KPIs from session transcripts and workspace state. * * Usage: npm run kpis + * npm run kpis -- --company (one company; otherwise every company is pooled) * * Reads: * - kpis/data/raw/sessions/ (session transcripts from headless runs) @@ -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')); } @@ -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); @@ -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); diff --git a/.claude/scripts/lib.mjs b/.claude/scripts/lib.mjs index 8230379..f482410 100644 --- a/.claude/scripts/lib.mjs +++ b/.claude/scripts/lib.mjs @@ -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) })); @@ -142,13 +163,26 @@ export function checkFile(relPath, root = process.cwd(), layout = loadLayout(roo // --------------------------------------------------------------------------- // Findings -/** Findings from every assessments//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 []; diff --git a/.claude/scripts/run-headless.mjs b/.claude/scripts/run-headless.mjs index 65d1889..b66d5b1 100644 --- a/.claude/scripts/run-headless.mjs +++ b/.claude/scripts/run-headless.mjs @@ -12,6 +12,7 @@ * Options: * --workflow audit, onboard, remediate or refresh * --company Company profile ID (lowercase letters, digits, hyphens) + * --target Codebase to read (claude-code only). Without it, only this workspace is in reach. * --framework dpdp, gdpr, or both (default: both) * --harness claude-code (default) or opencode * --model Model override for opencode (e.g., provider/model) @@ -20,7 +21,7 @@ */ import { execSync } from 'node:child_process'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; const args = process.argv.slice(2); @@ -38,6 +39,7 @@ function die(message) { const workflow = flag('workflow'); const company = flag('company'); +const target = flag('target'); const framework = flag('framework') || 'both'; const harness = flag('harness') || 'claude-code'; const model = flag('model'); @@ -52,6 +54,11 @@ if (!/^[a-z0-9-]+$/.test(company)) die(`Invalid --company "${company}": use lowe if (!['dpdp', 'gdpr', 'both'].includes(framework)) die(`Invalid --framework "${framework}": use dpdp, gdpr or both.`); if (!['claude-code', 'opencode'].includes(harness)) die(`Invalid --harness "${harness}": use claude-code or opencode.`); if (model && !/^[\w.:/-]+$/.test(model)) die(`Invalid --model "${model}".`); +if (target) { + if (!/^[\w.:/\\ -]+$/.test(target)) die(`Invalid --target "${target}": use letters, digits, spaces, dots, hyphens and path separators.`); + if (!existsSync(target)) die(`--target not found: ${target}`); + if (harness !== 'claude-code') die('--target only works with --harness claude-code.'); +} const scopeOf = fw => (fw === 'both' ? 'DPDP and GDPR' : fw.toUpperCase()); const NO_QUESTIONS = 'This is a headless run: do not ask questions. Where information is missing, record it as not assessed and list the open questions in your final message.'; @@ -69,7 +76,7 @@ const WORKFLOWS = { if (!WORKFLOWS[workflow]) die(`Unknown workflow: ${workflow}\nAvailable: ${Object.keys(WORKFLOWS).join(', ')}`); -const prompt = WORKFLOWS[workflow](company, framework); +const prompt = WORKFLOWS[workflow](company, framework) + (target ? ` The codebase to read is at ${target}; record file paths relative to its root.` : ''); if (dryRun) { console.log('Prompt that would be sent:'); @@ -77,6 +84,7 @@ if (dryRun) { console.log(prompt); console.log('---'); console.log(`Harness: ${harness}`); + if (target) console.log(`Target: ${target}`); if (model) console.log(`Model: ${model}`); process.exit(0); } @@ -89,7 +97,20 @@ const sessionFile = join(outputDir, `${workflow}_${company}_${startedAt.toISOStr // acceptEdits lets the run write its reports; the write-guard hook still limits where. const cmd = harness === 'opencode' ? `opencode run "${prompt}"${model ? ` --model ${model}` : ''}` - : `claude --print --output-format json --permission-mode acceptEdits "${prompt}"`; + : `claude --print --output-format json --permission-mode acceptEdits${target ? ` --add-dir "${target}"` : ''} "${prompt}"`; + +const saveSession = (output, error) => writeFileSync(sessionFile, JSON.stringify({ + workflow, + company, + framework, + harness, + model: model || null, + target: target || null, + started_at: startedAt.toISOString(), + completed_at: new Date().toISOString(), + ...(error ? { error } : {}), + output, +}, null, 2)); console.log(`Running ${workflow} for ${company} (${framework})...`); console.log(`Harness: ${harness}`); @@ -101,23 +122,14 @@ try { cwd: process.cwd(), encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'], - timeout: 1_800_000, + timeout: 7_200_000, + maxBuffer: 64 * 1024 * 1024, }); - const session = { - workflow, - company, - framework, - harness, - model: model || null, - started_at: startedAt.toISOString(), - completed_at: new Date().toISOString(), - output, - }; - - writeFileSync(sessionFile, JSON.stringify(session, null, 2)); + saveSession(output); console.log(`Session saved to ${sessionFile}`); console.log('Run npm run validate, then npm run kpis to compute metrics from this session.'); } catch (e) { - die(`Workflow failed: ${e.message}`); + saveSession(e.stdout ?? '', e.message); + die(`Workflow failed: ${e.message}\nPartial transcript saved to ${sessionFile}`); } diff --git a/.claude/scripts/tests/guardrails.test.mjs b/.claude/scripts/tests/guardrails.test.mjs index 2bc9464..f9381b9 100644 --- a/.claude/scripts/tests/guardrails.test.mjs +++ b/.claude/scripts/tests/guardrails.test.mjs @@ -1,11 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync, cpSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadLayout, matchRule, checkFile, validate, loadFindings } from '../lib.mjs'; +import { loadLayout, matchRule, checkFile, validate, loadFindings, workspaceRoot, frontmatterErrors } from '../lib.mjs'; const scripts = join(dirname(fileURLToPath(import.meta.url)), '..'); const repo = join(scripts, '..', '..'); @@ -28,6 +28,24 @@ function workspace() { return dir; } +// A copy of the repo's data and package.json, for scripts that read the working directory. +function dataCopy() { + const dir = mkdtempSync(join(tmpdir(), 'vigil-')); + copyFileSync(join(repo, 'package.json'), join(dir, 'package.json')); + mkdirSync(join(dir, 'kpis')); + cpSync(join(repo, 'assessments'), join(dir, 'assessments'), { recursive: true }); + cpSync(join(repo, 'company-profile'), join(dir, 'company-profile'), { recursive: true }); + return dir; +} + +function secondAudit(dir) { + const report = JSON.parse(readFileSync(join(dir, 'assessments/2026-10-19-sample-co-baseline/report.json'), 'utf8')); + mkdirSync(join(dir, 'assessments/2026-11-19-sample-co-refresh')); + writeFileSync(join(dir, 'assessments/2026-11-19-sample-co-refresh/report.json'), JSON.stringify({ + ...report, assessment_id: '2026-11-19-sample-co-refresh', started_at: '2026-11-19T10:00:00+05:30', findings: report.findings.slice(0, 2), + })); +} + test('sample workspace passes schema validation', () => { const r = run('validate.mjs', { args: ['--strict'] }); assert.equal(r.status, 0, r.stderr); @@ -41,6 +59,14 @@ test('layout matches data paths and rejects strays', () => { assert.equal(matchRule(layout, 'src/app.js'), undefined); }); +test('agents cannot write the guards that bind them', () => { + const layout = loadLayout(repo); + for (const p of ['.claude/scripts/write-guard.mjs', '.claude/schemas/layout.json', '.claude/schemas/finding.schema.json', '.claude/settings.json', '.claude/settings.local.json']) { + assert.equal(matchRule(layout, p), undefined, p); + } + assert.ok(matchRule(layout, '.claude/skills/audit/SKILL.md')); +}); + test('validator catches schema violations', () => { const finding = JSON.parse(readFileSync(join(repo, '.claude/schemas/finding.schema.json'), 'utf8')); assert.deepEqual(validate(finding, { id: 'F-0001', severity: 'high', framework: 'DPDP', clause: 's.8(5)', issue: 'x', recommendation: 'y' }, '$', repo), []); @@ -49,6 +75,15 @@ test('validator catches schema violations', () => { assert.throws(() => validate({ oneOf: [] }, 1), /Unsupported schema keyword/); }); +test('finding file paths must be relative to the audited codebase', () => { + const finding = JSON.parse(readFileSync(join(repo, '.claude/schemas/finding.schema.json'), 'utf8')); + const base = { id: 'F-0001', severity: 'high', framework: 'DPDP', clause: 's.8(5)', issue: 'x', recommendation: 'y' }; + assert.deepEqual(validate(finding, { ...base, file: 'db/migrations/0003_kyc.sql' }, '$', repo), []); + for (const file of ['C:\\Users\\me\\app\\x.sql', '/home/me/app/x.sql', '../app/x.sql']) { + assert.equal(validate(finding, { ...base, file }, '$', repo).length, 1, file); + } +}); + test('report $ref validates nested findings', () => { const dir = workspace(); mkdirSync(join(dir, 'assessments/a1'), { recursive: true }); @@ -75,8 +110,63 @@ test('write-guard enforces layout, append-only logs and secrets', () => { assert.equal(guard({ tool_name: 'Write', tool_input: { file_path: log, content: '{"b":2}\n' } }), 2); assert.equal(guard({ tool_name: 'MultiEdit', tool_input: { file_path: log, edits: [{ old_string: '{"b":2}', new_string: '' }] } }), 2); - const fakeKey = 'AKIA' + 'Q'.repeat(16); - assert.equal(guard({ tool_name: 'Write', tool_input: { file_path: 'README.md', content: `key ${fakeKey}` } }), 2); + writeFileSync(join(dir, log), '{"a":1}\r\n{"b":2}\r\n'); + assert.equal(guard({ tool_name: 'Write', tool_input: { file_path: log, content: '{"a":1}\n{"b":2}\n{"c":3}\n' } }), 0, 'CRLF checkout must not block a real append'); + rmSync(dir, { recursive: true }); +}); + +test('write-guard catches key formats and leaves ordinary prose alone', () => { + const dir = workspace(); + const guard = content => run('write-guard.mjs', { cwd: dir, input: { tool_name: 'Write', tool_input: { file_path: 'README.md', content } } }).status; + const secrets = [ + 'key AKIA' + 'Q'.repeat(16), + 'ghp_' + 'a'.repeat(36), + 'github_pat_' + 'a'.repeat(40), + 'sk-ant-api03-' + 'a'.repeat(32), + '-----BEGIN OPENSSH ' + 'PRIVATE KEY-----', + '-----BEGIN RSA ' + 'PRIVATE KEY-----', + 'pass' + 'word=plainvalue', + 'DB_PASS' + 'WORD = "quotedvalue"', + '{"pass' + 'word":"quotedvalue"}', + 'pass' + 'word: Sup3rvalue', + ]; + for (const s of secrets) assert.equal(guard(s), 2, s); + for (const s of ['Database pass' + 'word: hardcoded in config/settings.py', 'The task-tracker uses tokens sparingly']) assert.equal(guard(s), 0, s); + rmSync(dir, { recursive: true }); +}); + +test('write-guard keeps identifiers out of company and assessment data', () => { + const dir = workspace(); + const guard = (file_path, content) => run('write-guard.mjs', { cwd: dir, input: { tool_name: 'Write', tool_input: { file_path, content } } }).status; + const report = 'assessments/a1/summary.md'; + assert.equal(guard(report, 'Aadhaar 9999 7165 8847 in logs'), 2); // synthetic: a UIDAI test number + assert.equal(guard(report, 'Aadhaar 999971658847 in logs'), 2); + assert.equal(guard(report, 'PAN ABCPE1234F in logs'), 2); + assert.equal(guard(report, 'mobile +91 9000000000 in logs'), 2); + assert.equal(guard(report, 'Exposure Rs 250000000000, stored in the aadhaar_number column'), 0, 'an amount that fails the check digit is not an Aadhaar number'); + assert.equal(guard('docs/guide.md', 'PAN format example ABCPE1234F'), 0, 'documentation is out of scope'); + rmSync(dir, { recursive: true }); +}); + +test('hooks find the workspace from a subdirectory and block with exit 2', () => { + const dir = workspace(); + mkdirSync(join(dir, 'docs/deep'), { recursive: true }); + const cwd = join(dir, 'docs/deep'); + assert.equal(workspaceRoot(cwd), workspaceRoot(dir)); + const blocked = run('write-guard.mjs', { cwd, input: { tool_name: 'Write', tool_input: { file_path: join(dir, 'src/app.js'), content: 'x' } } }); + assert.equal(blocked.status, 2, blocked.stderr); + const viaEvent = run('write-guard.mjs', { cwd: tmpdir(), input: { cwd, tool_name: 'Write', tool_input: { file_path: join(dir, 'src/app.js'), content: 'x' } } }); + assert.equal(viaEvent.status, 2, viaEvent.stderr); + const unreadable = run('write-guard.mjs', { cwd: dir, input: { cwd: '/no/such/dir', tool_name: 'Write', tool_input: { file_path: join(dir, 'README.md'), content: 'x' } } }); + assert.equal(unreadable.status, 0, unreadable.stderr); + rmSync(dir, { recursive: true }); +}); + +test('write-guard fails closed when the layout is broken', () => { + const dir = workspace(); + writeFileSync(join(dir, '.claude/schemas/layout.json'), '{ not json'); + const r = run('write-guard.mjs', { cwd: dir, input: { tool_name: 'Write', tool_input: { file_path: 'README.md', content: 'x' } } }); + assert.equal(r.status, 2); rmSync(dir, { recursive: true }); }); @@ -89,14 +179,124 @@ test('validate-on-write exits 2 so the agent sees schema errors', () => { rmSync(dir, { recursive: true }); }); +test('frontmatter must be valid YAML', () => { + const skill = description => `---\nname: x\ndescription: ${description}\n---\n`; + assert.deepEqual(frontmatterErrors(skill('Plain text. Use for things.'), 'x'), []); + assert.deepEqual(frontmatterErrors(skill('"Quoted: with a colon and \\"inner\\" quotes."'), 'x'), []); + assert.match(frontmatterErrors(skill('Bare: colon'), 'x').join(), /not valid YAML/); + assert.match(frontmatterErrors(skill('"Broken "inner" quotes"'), 'x').join(), /not valid YAML/); + assert.match(frontmatterErrors('---\nname: y\n---\n', 'x').join(), /needs "name: x".*needs a description/); +}); + test('exports filter findings by company', () => { assert.equal(loadFindings('sample-co', repo).length, 9); assert.equal(loadFindings('nobody', repo).length, 0); }); +test('a second audit replaces the first in exports and KPIs', () => { + const dir = dataCopy(); + secondAudit(dir); + assert.equal(loadFindings('sample-co', dir).length, 2); + const r = run('kpis.mjs', { cwd: dir }); + assert.equal(r.status, 0, r.stderr); + const metrics = JSON.parse(readFileSync(join(dir, 'kpis/metrics.json'), 'utf8')); + assert.equal(metrics.actionability.total_findings, 2); + assert.equal(metrics.time_to_remediation.remediated_count, 1, 'a fix recorded in the earlier report still counts'); + rmSync(dir, { recursive: true }); +}); + +test('KPIs can be scoped to one company', () => { + const dir = dataCopy(); + const report = JSON.parse(readFileSync(join(dir, 'assessments/2026-10-19-sample-co-baseline/report.json'), 'utf8')); + mkdirSync(join(dir, 'assessments/other')); + writeFileSync(join(dir, 'assessments/other/report.json'), JSON.stringify({ ...report, assessment_id: 'other', company_id: 'other-co', findings: report.findings.slice(0, 1) })); + const total = company => { + const r = run('kpis.mjs', { cwd: dir, args: company ? ['--company', company] : [] }); + assert.equal(r.status, 0, r.stderr); + return JSON.parse(readFileSync(join(dir, 'kpis/metrics.json'), 'utf8')).actionability.total_findings; + }; + assert.equal(total(), 10); + assert.equal(total('other-co'), 1); + assert.equal(run('kpis.mjs', { cwd: dir, args: ['--company', 'typo'] }).status, 1); + rmSync(dir, { recursive: true }); +}); + +test('SARIF is ready for code scanning', () => { + const dir = dataCopy(); + const out = join(dir, 'x.sarif'); + const r = run('export-sarif.mjs', { cwd: dir, args: ['--company', 'sample-co', '--output', out] }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /no file to point at: F-0004, F-0007/); + assert.match(r.stdout, /remediated or accepted: F-0008/); + const { tool, results } = JSON.parse(readFileSync(out, 'utf8')).runs[0]; + assert.equal(results.length, 6); + for (const result of results) assert.ok(result.locations[0].physicalLocation.artifactLocation.uri); + for (const rule of tool.driver.rules) assert.ok(rule.shortDescription.text && rule.fullDescription.text && rule.help.text, rule.id); + rmSync(dir, { recursive: true }); +}); + +test('OSCAL target-ids are valid tokens', () => { + const dir = dataCopy(); + const out = join(dir, 'x.oscal.json'); + assert.equal(run('export-oscal.mjs', { cwd: dir, args: ['--company', 'sample-co', '--output', out] }).status, 0); + const { findings } = JSON.parse(readFileSync(out, 'utf8'))['assessment-results'].results[0]; + assert.equal(findings.length, 9); + for (const f of findings) assert.match(f.target['target-id'], /^(\p{L}|_)(\p{L}|\p{N}|[.\-_])*$/u); // pattern from the NIST schema + rmSync(dir, { recursive: true }); +}); + +test('OCSF maps a remediated finding to Resolved', () => { + const dir = dataCopy(); + const out = join(dir, 'x.ocsf.jsonl'); + assert.equal(run('export-ocsf.mjs', { cwd: dir, args: ['--company', 'sample-co', '--output', out] }).status, 0); + const events = readFileSync(out, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + const status = id => events.find(e => e.finding_info.uid.endsWith(id)).status_id; + assert.equal(status('F-0008'), 4); // remediated + assert.equal(status('F-0001'), 2); // in progress + assert.equal(status('F-0002'), 1); // open + rmSync(dir, { recursive: true }); +}); + +test('exporters refuse a company with no report', () => { + for (const script of ['export-sarif.mjs', 'export-oscal.mjs', 'export-ocsf.mjs']) { + const r = run(script, { args: ['--company', 'nobody', '--output', join(tmpdir(), 'vigil-nobody.out')] }); + assert.equal(r.status, 1, script); + assert.match(r.stderr, /No report found/); + } + assert.equal(existsSync(join(tmpdir(), 'vigil-nobody.out')), false); +}); + test('run-headless rejects unsafe arguments', () => { assert.equal(run('run-headless.mjs', { args: ['--workflow', 'audit', '--company', 'acme"; rm -rf ~', '--dry-run'] }).status, 1); const ok = run('run-headless.mjs', { args: ['--workflow', 'audit', '--company', 'acme-tech', '--dry-run'] }); assert.equal(ok.status, 0); assert.match(ok.stdout, /Use the \/audit skill/); }); + +test('run-headless can point at a codebase', () => { + const dir = mkdtempSync(join(tmpdir(), 'vigil target-')); + const args = ['--workflow', 'audit', '--company', 'acme-tech', '--dry-run']; + const ok = run('run-headless.mjs', { args: [...args, '--target', dir] }); + assert.equal(ok.status, 0, ok.stderr); + assert.match(ok.stdout, /record file paths relative to its root/); + assert.equal(run('run-headless.mjs', { args: [...args, '--target', `${dir}"; rm -rf ~`] }).status, 1); + assert.equal(run('run-headless.mjs', { args: [...args, '--target', join(dir, 'missing')] }).status, 1); + assert.equal(run('run-headless.mjs', { args: [...args, '--target', dir, '--harness', 'opencode'] }).status, 1); + rmSync(dir, { recursive: true }); +}); + +test('hook commands find the guard, even when the project dir is stale', { skip: process.platform === 'win32' }, () => { // bash on Windows may resolve to WSL + const { hooks } = JSON.parse(readFileSync(join(repo, '.claude/settings.json'), 'utf8')); + const hook = (event, cwd, projectDir, file) => spawnSync('bash', ['-c', hooks[event][0].hooks[0].command], { + cwd, encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir }, + input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: join(repo, file), content: 'x' } }), + }).status; + assert.equal(hook('PreToolUse', repo, repo, 'src/app.js'), 2); + assert.equal(hook('PreToolUse', repo, repo, 'README.md'), 0); + assert.equal(hook('PreToolUse', join(repo, 'docs'), repo, 'src/app.js'), 2, 'after a cd'); + assert.equal(hook('PreToolUse', repo, '/no/such/dir', 'src/app.js'), 2, 'session moved in from elsewhere'); + assert.equal(hook('PreToolUse', repo, '/no/such/dir', 'README.md'), 0, 'session moved in from elsewhere'); + assert.equal(hook('PreToolUse', join(repo, 'docs'), '/no/such/dir', 'README.md'), 1, 'cannot find the guard, so onFailure: block applies'); + assert.equal(hook('PostToolUse', repo, '/no/such/dir', 'README.md'), 0); + assert.equal(hooks.PreToolUse[0].hooks[0].onFailure, 'block'); +}); diff --git a/.claude/scripts/validate-on-write.mjs b/.claude/scripts/validate-on-write.mjs index 0c8c34f..7c9c3b0 100644 --- a/.claude/scripts/validate-on-write.mjs +++ b/.claude/scripts/validate-on-write.mjs @@ -7,20 +7,23 @@ */ import { resolve, relative } from 'node:path'; -import { checkFile } from './lib.mjs'; - -const root = process.cwd(); +import { checkFile, workspaceRoot } from './lib.mjs'; let raw = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { raw += chunk; }); process.stdin.on('end', () => { - let filePath; - try { filePath = JSON.parse(raw).tool_input?.file_path; } + let event; + try { event = JSON.parse(raw); } catch { process.exit(0); } + const filePath = event.tool_input?.file_path; if (!filePath) process.exit(0); - const rel = relative(root, resolve(root, filePath)).replace(/\\/g, '/'); + const start = [event.cwd, process.cwd(), process.env.CLAUDE_PROJECT_DIR].find(dir => dir && workspaceRoot(dir)); + if (!start) process.exit(0); + const root = workspaceRoot(start); + + const rel = relative(root, resolve(start, filePath)).replace(/\\/g, '/'); const errors = checkFile(rel, root); if (errors.length) { console.error(`Validation failed for ${rel}:\n ${errors.join('\n ')}`); diff --git a/.claude/scripts/validate.mjs b/.claude/scripts/validate.mjs index 2409b72..cd7827d 100644 --- a/.claude/scripts/validate.mjs +++ b/.claude/scripts/validate.mjs @@ -10,7 +10,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join, extname } from 'node:path'; -import { listFiles, loadLayout, matchRule, checkFile } from './lib.mjs'; +import { listFiles, loadLayout, matchRule, checkFile, frontmatterErrors } from './lib.mjs'; const root = process.cwd(); const strict = process.argv.includes('--strict'); @@ -39,9 +39,7 @@ for (const name of existsSync(skillsDir) ? readdirSync(skillsDir) : []) { const file = join(skillsDir, name, 'SKILL.md'); if (!existsSync(file)) { fail(`SKILL: .claude/skills/${name} has no SKILL.md`); continue; } skills++; - const front = readFileSync(file, 'utf8').match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; - if (!new RegExp(`^name: ${name}\\s*$`, 'm').test(front)) fail(`SKILL: ${name}/SKILL.md frontmatter needs "name: ${name}"`); - if (!/^description: \S/m.test(front)) fail(`SKILL: ${name}/SKILL.md frontmatter needs a description`); + for (const e of frontmatterErrors(readFileSync(file, 'utf8'), name)) fail(`SKILL: ${name}/SKILL.md ${e}`); } console.log(`Checked ${checked} data files and ${skills} skills, ${errors} error(s).`); diff --git a/.claude/scripts/write-guard.mjs b/.claude/scripts/write-guard.mjs index 12bf161..36edc99 100644 --- a/.claude/scripts/write-guard.mjs +++ b/.claude/scripts/write-guard.mjs @@ -5,6 +5,7 @@ * 1. Writes outside the workspace layout (.claude/schemas/layout.json) * 2. Rewrites of append-only logs (breach-log.jsonl, rights-log.jsonl) * 3. Content containing secret-shaped strings (API keys, passwords, tokens) + * 4. Identifiers in company or assessment data (Aadhaar, PAN, +91 mobile numbers) * * Used as a PreToolUse hook on Write, Edit and MultiEdit. * Reads the event from stdin, exits 2 to block with a message. @@ -12,20 +13,35 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve, relative, isAbsolute } from 'node:path'; -import { loadLayout, matchRule } from './lib.mjs'; - -const root = process.cwd(); +import { loadLayout, matchRule, workspaceRoot } from './lib.mjs'; const SECRET_PATTERNS = [ - /(?:api[_-]?key|apikey)\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}/i, - /(?:secret|password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{8,}/i, + /(?:api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}/i, + // A quoted or assigned value, or a bare `key: value` with a digit or symbol, so "password: hardcoded" in prose passes. + /(?:secret|password|passwd|pwd)["']?\s*(?:[:=]\s*["'][^\s"']{8,}|=\s*[^\s"']{8,}|:\s*(?=[^\s"']*[\d!@#$%^&*])[^\s"']{8,})/i, /(?:bearer|token)\s+[A-Za-z0-9_\-\.]{20,}/i, - /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, + /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/, /AKIA[0-9A-Z]{16}/, - /ghp_[A-Za-z0-9]{36}/, - /sk-[A-Za-z0-9]{32,}/, + /gh[pousr]_[A-Za-z0-9]{36}/, + /github_pat_[A-Za-z0-9_]{22,}/, + /\bsk-[A-Za-z0-9_\-]{32,}/, ]; +const VERHOEFF_D = ['0123456789', '1234067895', '2340178956', '3401289567', '4012395678', '5987604321', '6598710432', '7659821043', '8760932154', '9876543210']; +const VERHOEFF_P = ['0123456789', '1576283094', '5803796142', '8916043527', '9453126870', '4286573901', '2793806415', '7046913258']; + +// Aadhaar numbers carry a Verhoeff check digit, which keeps large penalty amounts from matching. +function isAadhaar(digits) { + let c = 0; + [...digits].reverse().forEach((n, i) => { c = Number(VERHOEFF_D[c][Number(VERHOEFF_P[i % 8][Number(n)])]); }); + return c === 0; +} + +function hasPersonalData(text) { + const aadhaar = [...text.matchAll(/\b[2-9]\d{3}[ -]?\d{4}[ -]?\d{4}\b/g)].some(m => isAadhaar(m[0].replace(/\D/g, ''))); + return aadhaar || /\b[A-Z]{3}[ABCFGHJLPT][A-Z]\d{4}[A-Z]\b/.test(text) || /\+91[ -]?[6-9]\d{9}\b/.test(text); +} + function block(reason) { console.error(`Write blocked: ${reason}`); process.exit(2); @@ -51,28 +67,28 @@ function contentAfter(before, toolName, input) { return text; } -let raw = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => { raw += chunk; }); -process.stdin.on('end', () => { - let event; - try { event = JSON.parse(raw); } - catch { process.exit(0); } // not a tool event we understand; nothing to guard +const lf = text => text.replace(/\r\n/g, '\n'); +function guard(event) { const input = event.tool_input ?? {}; const filePath = input.file_path; - if (!filePath) process.exit(0); + if (!filePath) return; + + // The event's cwd follows Claude; the others cover a cwd this platform can't read. + const start = [event.cwd, process.cwd(), process.env.CLAUDE_PROJECT_DIR].find(dir => dir && workspaceRoot(dir)); + if (!start) block('no workspace layout found at or above the working directory'); + const root = workspaceRoot(start); - const abs = resolve(root, filePath); + const abs = resolve(start, filePath); const rel = relative(root, abs).replace(/\\/g, '/'); if (rel === '..' || rel.startsWith('../') || isAbsolute(rel)) block(`path outside workspace: ${filePath}`); const rule = matchRule(loadLayout(root), rel); - if (!rule) block(`path not in workspace layout: ${rel}. Add a rule to .claude/schemas/layout.json first.`); + if (!rule) block(`path not in workspace layout: ${rel}. Agents may not write here; a maintainer can change the layout by hand.`); if (rule.append_only && existsSync(abs)) { const before = readFileSync(abs, 'utf8'); - if (!contentAfter(before, event.tool_name, input).startsWith(before)) { + if (!lf(contentAfter(before, event.tool_name, input)).startsWith(lf(before))) { block(`${rel} is append-only. Add new entries at the end; never modify or delete existing ones.`); } } @@ -81,6 +97,21 @@ process.stdin.on('end', () => { if (SECRET_PATTERNS.some(p => p.test(content))) { block('content looks like it contains a secret (API key, password, or token). Store secrets in environment variables or encrypted references.'); } + if (/^(company-profile|assessments)\//.test(rel) && hasPersonalData(content)) { + block('content looks like it contains personal data (Aadhaar, PAN or a +91 mobile number). Reference it by file and line instead.'); + } +} + +let raw = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { raw += chunk; }); +process.stdin.on('end', () => { + let event; + try { event = JSON.parse(raw); } + catch { process.exit(0); } // not a tool event we understand; nothing to guard + // Only exit 2 blocks, so an unexpected error here must not let the write through. + try { guard(event); } + catch (e) { block(`guard error: ${e.message}`); } process.exit(0); }); diff --git a/.claude/settings.json b/.claude/settings.json index fdc8d72..72986e9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -18,7 +18,8 @@ "hooks": [ { "type": "command", - "command": "node .claude/scripts/write-guard.mjs" + "command": "f=\"${CLAUDE_PROJECT_DIR}/.claude/scripts/write-guard.mjs\"; [ -f \"$f\" ] || f=.claude/scripts/write-guard.mjs; node \"$f\"", + "onFailure": "block" } ] } @@ -29,7 +30,7 @@ "hooks": [ { "type": "command", - "command": "node .claude/scripts/validate-on-write.mjs" + "command": "f=\"${CLAUDE_PROJECT_DIR}/.claude/scripts/validate-on-write.mjs\"; [ -f \"$f\" ] || f=.claude/scripts/validate-on-write.mjs; node \"$f\"" } ] } diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md index 705f54e..21944a3 100644 --- a/.claude/skills/audit/SKILL.md +++ b/.claude/skills/audit/SKILL.md @@ -1,6 +1,6 @@ --- name: audit -description: Run a full DPDP and GDPR privacy audit: PII discovery, data flows, consent, rights, DPIA, breach readiness, code and document review, then a posture snapshot and a schema-valid report. Use for "full audit", "privacy audit", "audit my app". +description: "Run a full DPDP and GDPR privacy audit: PII discovery, data flows, consent, rights, DPIA, breach readiness, code and document review, then a posture snapshot and a schema-valid report. Use for \"full audit\", \"privacy audit\", \"audit my app\"." --- # Full Privacy Audit Workflow @@ -44,13 +44,13 @@ Run a complete privacy compliance audit covering both DPDP and GDPR. Orchestrate - Number findings `F-0001` upwards within the report. - Every finding cites a clause, and a `cross_reference` when the same gap breaches the other framework. - Set `found_at`. Leave `owner` and `due_date` for `/remediate` unless the user gave them. -- Reference code by `file` and `line`. Never copy personal data into a finding. -- Count applicable and examined controls (sections, rules, articles) and record them in `coverage`. +- Reference code by `file` and `line`. `file` is relative to the root of the audited codebase, never absolute, so SARIF maps onto that repository. Leave it out for a gap with no file, such as a missing contract. Never copy personal data into a finding. +- Count applicable and examined controls (sections, rules, articles), record them in `coverage`, and say in `summary.md` how you counted. The denominator is your own count, so do not present it as comparable across runs. ## Output Assessment report saved to `assessments/{assessment_id}/` (use `YYYY-MM-DD-{company_id}-{scope}` as the id): -- `report.json`: `assessment_id`, `company_id`, `scope`, `started_at`, `completed_at`, `coverage` and `findings`, per `.claude/schemas/report.schema.json` +- `report.json`: `assessment_id`, `company_id`, `scope`, `started_at`, `completed_at`, `coverage` and `findings`, per `.claude/schemas/report.schema.json`. Also record `target` (repo and commit audited), `model`, `vigil_version` and `law_as_at` (the catalogs' "Last verified" date) so someone can check the run later - `posture.json`: compliance posture snapshot - `data-map.json`: personal data inventory - `summary.md`: human-readable executive summary diff --git a/.claude/skills/dpdp-breach/SKILL.md b/.claude/skills/dpdp-breach/SKILL.md index 2fdd575..86e4407 100644 --- a/.claude/skills/dpdp-breach/SKILL.md +++ b/.claude/skills/dpdp-breach/SKILL.md @@ -1,6 +1,6 @@ --- name: dpdp-breach -description: Personal data breach response under DPDP s.8(6) and Rule 7: assess, contain, notify the Data Protection Board (72-hour report) and every affected Data Principal, run parallel CERT-In and sector reporting, then review. Use for "data breach", "breach notification", "notify the Board", "incident response". +description: "Personal data breach response under DPDP s.8(6) and Rule 7: assess, contain, notify the Data Protection Board (72-hour report) and every affected Data Principal, run parallel CERT-In and sector reporting, then review. Use for \"data breach\", \"breach notification\", \"notify the Board\", \"incident response\"." --- # DPDP Breach Response diff --git a/.claude/skills/dpdp-catalog/SKILL.md b/.claude/skills/dpdp-catalog/SKILL.md index 9fbf580..b611f77 100644 --- a/.claude/skills/dpdp-catalog/SKILL.md +++ b/.claude/skills/dpdp-catalog/SKILL.md @@ -1,6 +1,6 @@ --- name: dpdp-catalog -description: Reference registry for the DPDP Act 2023, DPDP Rules 2025 and related Indian instruments: section and rule map, phased commencement dates, penalty Schedule, sector regulators. Use to cite the right clause, check what is in force, or decide applicability. +description: "Reference registry for the DPDP Act 2023, DPDP Rules 2025 and related Indian instruments: section and rule map, phased commencement dates, penalty Schedule, sector regulators. Use to cite the right clause, check what is in force, or decide applicability." --- # DPDP Regulatory Catalog diff --git a/.claude/skills/dpdp-consent/SKILL.md b/.claude/skills/dpdp-consent/SKILL.md index cd8cf3f..18de6f2 100644 --- a/.claude/skills/dpdp-consent/SKILL.md +++ b/.claude/skills/dpdp-consent/SKILL.md @@ -1,6 +1,6 @@ --- name: dpdp-consent -description: DPDP consent lifecycle: notices (s.5, Rule 3), valid consent (s.6), withdrawal, Consent Managers (Rule 4), dark-pattern review, and verifiable parental consent (s.9, Rule 10). Use for "consent notice", "consent audit", "withdraw consent", "dark patterns", "children's consent". +description: "DPDP consent lifecycle: notices (s.5, Rule 3), valid consent (s.6), withdrawal, Consent Managers (Rule 4), dark-pattern review, and verifiable parental consent (s.9, Rule 10). Use for \"consent notice\", \"consent audit\", \"withdraw consent\", \"dark patterns\", \"children's consent\"." --- # DPDP Consent Management diff --git a/.claude/skills/dpdp-dpia/SKILL.md b/.claude/skills/dpdp-dpia/SKILL.md index 42ceb4c..c781e39 100644 --- a/.claude/skills/dpdp-dpia/SKILL.md +++ b/.claude/skills/dpdp-dpia/SKILL.md @@ -1,6 +1,6 @@ --- name: dpdp-dpia -description: Data Protection Impact Assessments under DPDP s.10(2)(c) and Rule 13: screen, assess, mitigate, record residual risk and review annually. Use for "DPIA", "privacy impact assessment", "high-risk processing", "SDF assessment". +description: "Data Protection Impact Assessments under DPDP s.10(2)(c) and Rule 13: screen, assess, mitigate, record residual risk and review annually. Use for \"DPIA\", \"privacy impact assessment\", \"high-risk processing\", \"SDF assessment\"." --- # DPDP Data Protection Impact Assessment diff --git a/.claude/skills/dpdp-dpo/SKILL.md b/.claude/skills/dpdp-dpo/SKILL.md index 482b2c2..3690dfc 100644 --- a/.claude/skills/dpdp-dpo/SKILL.md +++ b/.claude/skills/dpdp-dpo/SKILL.md @@ -1,6 +1,6 @@ --- name: dpdp-dpo -description: Set up and run the Data Protection Officer function under the DPDP Act 2023 and Rules 2025 (India): DPO charter, independence check, monitoring calendar, quarterly board report. Use for "set up DPO", "DPO charter", "board report", "grievance officer". +description: "Set up and run the Data Protection Officer function under the DPDP Act 2023 and Rules 2025 (India): DPO charter, independence check, monitoring calendar, quarterly board report. Use for \"set up DPO\", \"DPO charter\", \"board report\", \"grievance officer\"." --- # DPDP Data Protection Officer Governance diff --git a/.claude/skills/dpdp-rights/SKILL.md b/.claude/skills/dpdp-rights/SKILL.md index 6d76cfd..c28d0da 100644 --- a/.claude/skills/dpdp-rights/SKILL.md +++ b/.claude/skills/dpdp-rights/SKILL.md @@ -1,6 +1,6 @@ --- name: dpdp-rights -description: Data Principal rights under DPDP ss.11-14 and Rule 14: access, correction and erasure, grievance redressal (90-day ceiling), nomination, with an append-only rights log and SLA tracking. Use for "rights request", "right to erasure", "grievance", "nomination", "DSAR". +description: "Data Principal rights under DPDP ss.11-14 and Rule 14: access, correction and erasure, grievance redressal (90-day ceiling), nomination, with an append-only rights log and SLA tracking. Use for \"rights request\", \"right to erasure\", \"grievance\", \"nomination\", \"DSAR\"." --- # DPDP Data Principal Rights Management diff --git a/.claude/skills/export/SKILL.md b/.claude/skills/export/SKILL.md index 0fba9f2..4981554 100644 --- a/.claude/skills/export/SKILL.md +++ b/.claude/skills/export/SKILL.md @@ -1,72 +1,66 @@ --- name: export -description: Export findings as SARIF 2.1.0 (GitHub code scanning), OSCAL Assessment Results or OCSF Compliance Finding events. Use for "export SARIF", "OSCAL", "send findings to SIEM". +description: "Export findings as SARIF 2.1.0 (GitHub code scanning), OSCAL Assessment Results or OCSF Compliance Finding events. Use for \"export SARIF\", \"OSCAL\", \"send findings to SIEM\"." --- # Output Format Conversion (OSCAL, SARIF, OCSF) ## Trigger -"export OSCAL", "export SARIF", "convert findings", "OSCAL output", "SARIF output", "OCSF output", "standard format", "interop with Maxwell" +"export OSCAL", "export SARIF", "convert findings", "OSCAL output", "SARIF output", "OCSF output", "standard format" ## Purpose -Convert Vigil findings and posture data into open standard formats for interoperability with other compliance tools, SIEMs, and reporting systems. +Convert Vigil findings and posture data into open standard formats for interoperability with other compliance tools, SIEMs, and reporting systems. Run the matching script; do not write these files by hand. Each export uses the company's latest report only. ## Supported formats ### SARIF 2.1.0 (Static Analysis Results Interchange Format) -For code-related findings (PII in code, missing encryption, dark patterns in UI). +`npm run export:sarif -- --company --output findings.sarif` + +For code-related findings (PII in code, missing encryption, dark patterns in UI). Findings with no `file` and findings that are `remediated` or `accepted` are left out, because code scanning has nowhere to display them or would show them as live alerts. The script names what it left out; tell the user. Maps to: - `result.ruleId` -> finding clause (e.g., "GDPR-Art-32-1-a") - `result.level` -> severity (critical/high -> error, medium -> warning, low -> note) -- `result.message.text` -> finding issue description +- `result.message.text` -> finding issue and recommendation - `result.locations[].physicalLocation` -> file path and line number -- `result.fixes[].description` -> finding recommendation - `run.tool.driver.rules[]` -> regulatory clauses as rule definitions Use case: import into GitHub Code Scanning, VS Code SARIF Viewer, or any SARIF-compatible tool. -### OSCAL (Open Security Controls Assessment Language) +### OSCAL 1.1.2 (Open Security Controls Assessment Language) + +`npm run export:oscal -- --company --output results.oscal.json` -For control assessments and compliance posture. +For control assessments and compliance posture. All findings, as an Assessment Results document. Maps to: -- Assessment Results model: - - `result.findings[]` -> findings with regulatory clause references - - `result.observations[]` -> evidence from PII scans and data flow reviews - - `control-selection` -> applicable DPDP sections and GDPR articles -- Component Definition model: - - Organisation as a system component - - Framework controls as implemented requirements +- `result.findings[]` -> findings, with the clause as a `target-id` token and a `clause` prop +- `result.observations[]` -> file evidence for findings that have a `file` +- `reviewed-controls` -> include-all, because Vigil has no OSCAL control catalog + +Use case: import into GovReady or Compliance-as-Code pipelines. -Use case: import into GovReady, Compliance-as-Code pipelines, or Maxwell's control ledger. +### OCSF 1.3.0 (Open Cybersecurity Schema Framework) -### OCSF 1.9 (Open Cybersecurity Schema Framework) +`npm run export:ocsf -- --company --output findings.ocsf.jsonl` -For runtime and breach findings. +For security operations. All findings, one event per line. Maps to: - Compliance Finding (class 2003): - `finding_info.title` -> finding issue - - `finding_info.uid` -> finding ID + - `finding_info.uid` -> assessment and finding ID - `compliance.requirements[]` -> regulatory clauses - `severity_id` -> severity level + - `status_id` -> open 1 (New), in progress or disputed 2 (In Progress), accepted 3 (Suppressed), remediated 4 (Resolved) - `remediation.desc` -> recommendation Use case: feed into SIEM/SOAR systems, integrate with security operations. -## Conversion rules - -### Finding ID mapping - -Vigil finding IDs (`F-0001`) map to framework-specific rule IDs: -- DPDP: `DPDP-S{section}` (e.g., `DPDP-S8-5` for Section 8(5)) -- GDPR: `GDPR-Art-{article}` (e.g., `GDPR-Art-32-1-a`) - -### Severity mapping +## Severity mapping | Vigil | SARIF level | OCSF severity_id | |---|---|---| @@ -75,26 +69,6 @@ Vigil finding IDs (`F-0001`) map to framework-specific rule IDs: | medium | warning | 3 (Medium) | | low | note | 2 (Low) | -### Framework mapping - -| Vigil framework | OSCAL catalog | -|---|---| -| DPDP | Custom catalog: DPDP Act 2023 | -| GDPR | Custom catalog: EU GDPR 2016/679 | -| UK-GDPR | Custom catalog: UK GDPR + DUAA 2025 | - -## Maxwell interop - -To feed findings into a Maxwell workspace: -1. Export as OSCAL Assessment Results -2. Copy to Maxwell's `company-profile/{id}/soc/` directory -3. Maxwell's ledger protocol ingests the OSCAL findings - -To import Maxwell findings: -1. Read Maxwell's SARIF output from its assessment -2. Convert SARIF rule IDs to Vigil finding format -3. Import as findings with `framework` set based on the rule prefix - ## Commands Invoke the skill directly, or describe the task in plain English and Claude picks it up. @@ -103,6 +77,5 @@ Invoke the skill directly, or describe the task in plain English and Claude pick |---|---| | `/export sarif` | Export code findings as SARIF 2.1.0 | | `/export oscal` | Export posture and findings as OSCAL | -| `/export ocsf` | Export breach/runtime findings as OCSF | +| `/export ocsf` | Export findings as OCSF | | `/export all` | Export all formats | -| `/export import-sarif` | Import findings from a SARIF file | diff --git a/.claude/skills/gdpr-audit/SKILL.md b/.claude/skills/gdpr-audit/SKILL.md index 65692a1..7e4b52f 100644 --- a/.claude/skills/gdpr-audit/SKILL.md +++ b/.claude/skills/gdpr-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: gdpr-audit -description: Audit code, database schemas and system configuration against EU GDPR and UK GDPR with article-level findings: personal data discovery, lawful basis, minimisation, security (Art. 25, 32), retention and transfers. Use for "GDPR audit", "is this GDPR compliant", "GDPR code review". +description: "Audit code, database schemas and system configuration against EU GDPR and UK GDPR with article-level findings: personal data discovery, lawful basis, minimisation, security (Art. 25, 32), retention and transfers. Use for \"GDPR audit\", \"is this GDPR compliant\", \"GDPR code review\"." --- # GDPR Code and System Audit diff --git a/.claude/skills/gdpr-catalog/SKILL.md b/.claude/skills/gdpr-catalog/SKILL.md index 36f9ceb..f27209d 100644 --- a/.claude/skills/gdpr-catalog/SKILL.md +++ b/.claude/skills/gdpr-catalog/SKILL.md @@ -1,6 +1,6 @@ --- name: gdpr-catalog -description: Reference registry for EU GDPR, UK GDPR and the Data (Use and Access) Act 2025: article map, penalty tiers, transfer mechanisms, recent CJEU and EDPB developments. Use to cite the right article or check UK divergence. +description: "Reference registry for EU GDPR, UK GDPR and the Data (Use and Access) Act 2025: article map, penalty tiers, transfer mechanisms, recent CJEU and EDPB developments. Use to cite the right article or check UK divergence." --- # GDPR Regulatory Catalog diff --git a/.claude/skills/gdpr-data-flows/SKILL.md b/.claude/skills/gdpr-data-flows/SKILL.md index 360895d..d5ef5a6 100644 --- a/.claude/skills/gdpr-data-flows/SKILL.md +++ b/.claude/skills/gdpr-data-flows/SKILL.md @@ -1,6 +1,6 @@ --- name: gdpr-data-flows -description: Map personal data flows and check them against the Record of Processing Activities (Art. 30), lawful basis, processors and transfers. Use for "data mapping", "RoPA", "where does personal data go", "data inventory". +description: "Map personal data flows and check them against the Record of Processing Activities (Art. 30), lawful basis, processors and transfers. Use for \"data mapping\", \"RoPA\", \"where does personal data go\", \"data inventory\"." --- # GDPR Data Flow and PII Review diff --git a/.claude/skills/gdpr-draft/SKILL.md b/.claude/skills/gdpr-draft/SKILL.md index 1b764bf..f1faf07 100644 --- a/.claude/skills/gdpr-draft/SKILL.md +++ b/.claude/skills/gdpr-draft/SKILL.md @@ -1,6 +1,6 @@ --- name: gdpr-draft -description: Draft GDPR documents: privacy notice (Art. 13/14), Data Processing Agreement (Art. 28), consent banner, retention policy and rights procedure, with placeholders for organisation details. Use for "draft privacy policy", "DPA template", "cookie banner". +description: "Draft GDPR documents: privacy notice (Art. 13/14), Data Processing Agreement (Art. 28), consent banner, retention policy and rights procedure, with placeholders for organisation details. Use for \"draft privacy policy\", \"DPA template\", \"cookie banner\"." --- # GDPR Document Drafting diff --git a/.claude/skills/gdpr-qa/SKILL.md b/.claude/skills/gdpr-qa/SKILL.md index b2cca45..06cf1e7 100644 --- a/.claude/skills/gdpr-qa/SKILL.md +++ b/.claude/skills/gdpr-qa/SKILL.md @@ -1,6 +1,6 @@ --- name: gdpr-qa -description: Answer GDPR and UK GDPR questions with article citations, CJEU and EDPB developments, and UK DUAA 2025 divergence. Use for "what does GDPR say about", "lawful basis", "is this allowed under GDPR". +description: "Answer GDPR and UK GDPR questions with article citations, CJEU and EDPB developments, and UK DUAA 2025 divergence. Use for \"what does GDPR say about\", \"lawful basis\", \"is this allowed under GDPR\"." --- # GDPR Compliance Q&A diff --git a/.claude/skills/onboard/SKILL.md b/.claude/skills/onboard/SKILL.md index 0c211e9..9e7dec0 100644 --- a/.claude/skills/onboard/SKILL.md +++ b/.claude/skills/onboard/SKILL.md @@ -1,6 +1,6 @@ --- name: onboard -description: Onboard an organisation into Vigil: gather details, write the company profile, detect which frameworks apply, take a first posture snapshot and recommend next steps. Use for "add my company", "set up company profile", "onboard". +description: "Onboard an organisation into Vigil: gather details, write the company profile, detect which frameworks apply, take a first posture snapshot and recommend next steps. Use for \"add my company\", \"set up company profile\", \"onboard\"." --- # Organisation Onboarding Workflow @@ -21,7 +21,7 @@ Ask for: 1. Organisation name and legal name 2. Registered address and jurisdiction(s) 3. Industry sector (fintech, healthtech, edtech, SaaS, e-commerce, other) -4. Approximate number of users/customers +4. Approximate number of users/customers, and worldwide annual turnover in euros if known (for the GDPR Art. 83 percentage caps) 5. Does it process data of individuals in India? (DPDP applicability) 6. Does it process data of individuals in the EU/UK? (GDPR applicability) 7. Is it designated as a Significant Data Fiduciary? (or likely to be) @@ -35,6 +35,9 @@ Write `company-profile/{company_id}/details.json`: { "id": "", "legal_name": "", + "registered_address": "", + "approx_users": 0, + "annual_turnover_eur": 0, "jurisdiction": [], "sector": "", "frameworks": ["DPDP", "GDPR"], diff --git a/.claude/skills/penalty/SKILL.md b/.claude/skills/penalty/SKILL.md index 47ce519..32454c0 100644 --- a/.claude/skills/penalty/SKILL.md +++ b/.claude/skills/penalty/SKILL.md @@ -1,6 +1,6 @@ --- name: penalty -description: Maximum penalty exposure under the DPDP Schedule (s.33) and GDPR Art. 83 for open findings, ranked to prioritise remediation. Use for "penalty exposure", "fine risk", "what is the maximum penalty". +description: "Maximum penalty exposure under the DPDP Schedule (s.33) and GDPR Art. 83 for open findings, ranked to prioritise remediation. Use for \"penalty exposure\", \"fine risk\", \"what is the maximum penalty\"." --- # Penalty Exposure Calculator @@ -48,14 +48,14 @@ Given an organisation's findings, compute exposure: ### Input - List of open findings with their DPDP section / GDPR article -- Organisation's worldwide annual turnover (for GDPR %) +- Organisation's worldwide annual turnover (for GDPR %), from `annual_turnover_eur` in `details.json`. If it is missing, say the fixed caps are a floor. - Number of instances per violation type - Jurisdiction(s): India only, EU only, both ### Calculation **DPDP exposure:** -For each distinct breach (finding), look up the Schedule cap. Breaches of different provisions add up; the same provision breached in separate incidents can attract separate penalties. Sum the caps. Label the total "maximum theoretical": s.33(2) requires the Board to weigh proportionality. +For each distinct breach (finding), look up the Schedule cap. Breaches of different provisions add up; the same provision breached in separate incidents can attract separate penalties. Report two totals: per finding (sum every finding's cap) and per provision (count each Schedule entry once). The per-finding total grows with how finely the audit split its findings, so never show it alone. Label both "maximum theoretical": s.33(2) requires the Board to weigh proportionality. **GDPR exposure:** For each finding, determine the tier: Art. 83(4) (lower) or Art. 83(5)-(6) (upper). The cap is the higher of the fixed amount (EUR 10M or 20M) and the turnover percentage (2% or 4% of total worldwide annual turnover of the preceding year). @@ -74,7 +74,8 @@ PENALTY EXPOSURE SUMMARY DPDP exposure (prospective until 13 May 2027): F-0001 Security safeguards, s.8(5) ................. Rs 250 crore F-0004 Children's data, s.9(3) ..................... Rs 200 crore - Maximum theoretical total .......................... Rs 450 crore + Maximum theoretical, per finding ................... Rs 450 crore + Maximum theoretical, per provision ................. Rs 450 crore GDPR exposure (turnover: EUR 50M): Checkout processing (linked: F-0002 Art. 7, F-0003 Art. 32) diff --git a/.claude/skills/pii-scan/SKILL.md b/.claude/skills/pii-scan/SKILL.md index 8085218..905e74b 100644 --- a/.claude/skills/pii-scan/SKILL.md +++ b/.claude/skills/pii-scan/SKILL.md @@ -1,6 +1,6 @@ --- name: pii-scan -description: Scan codebases, schemas, logs and configs for personal data (DPDP s.2(t), GDPR Art. 4(1) and 9) including Aadhaar, PAN and UPI IDs, reporting locations only, never values. Use for "scan for PII", "find personal data", "data discovery". +description: "Scan codebases, schemas, logs and configs for personal data (DPDP s.2(t), GDPR Art. 4(1) and 9) including Aadhaar, PAN and UPI IDs, reporting locations only, never values. Use for \"scan for PII\", \"find personal data\", \"data discovery\"." --- # PII Scanner diff --git a/.claude/skills/posture/SKILL.md b/.claude/skills/posture/SKILL.md index fce4e97..b948e27 100644 --- a/.claude/skills/posture/SKILL.md +++ b/.claude/skills/posture/SKILL.md @@ -1,6 +1,6 @@ --- name: posture -description: Unified DPDP and GDPR compliance posture across six dimensions, saved as a posture snapshot. Use for "compliance posture", "how compliant are we", "privacy health check", "gaps". +description: "Unified DPDP and GDPR compliance posture across six dimensions, saved as a posture snapshot. Use for \"compliance posture\", \"how compliant are we\", \"privacy health check\", \"gaps\"." --- # Privacy Compliance Posture Dashboard diff --git a/.claude/skills/refresh/SKILL.md b/.claude/skills/refresh/SKILL.md index f3b66a5..974fac0 100644 --- a/.claude/skills/refresh/SKILL.md +++ b/.claude/skills/refresh/SKILL.md @@ -1,6 +1,6 @@ --- name: refresh -description: Periodic re-assessment: regulatory changes, PII re-scan, data map and consent drift, and a before/after posture comparison. Use for "refresh context", "what changed", "re-assess", "quarterly review". +description: "Periodic re-assessment: regulatory changes, PII re-scan, data map and consent drift, and a before/after posture comparison. Use for \"refresh context\", \"what changed\", \"re-assess\", \"quarterly review\"." --- # Context Refresh Workflow diff --git a/.claude/skills/reg-monitor/SKILL.md b/.claude/skills/reg-monitor/SKILL.md index 468d498..15dc385 100644 --- a/.claude/skills/reg-monitor/SKILL.md +++ b/.claude/skills/reg-monitor/SKILL.md @@ -1,6 +1,6 @@ --- name: reg-monitor -description: Track DPDP and GDPR regulatory changes, deadlines and their impact on the organisation. Use for "has the law changed", "regulatory update", "upcoming deadlines", "what is in force". +description: "Track DPDP and GDPR regulatory changes, deadlines and their impact on the organisation. Use for \"has the law changed\", \"regulatory update\", \"upcoming deadlines\", \"what is in force\"." --- # Regulatory Monitor diff --git a/.claude/skills/remediate/SKILL.md b/.claude/skills/remediate/SKILL.md index 120584b..dcd899d 100644 --- a/.claude/skills/remediate/SKILL.md +++ b/.claude/skills/remediate/SKILL.md @@ -1,6 +1,6 @@ --- name: remediate -description: Turn audit findings into a remediation plan ranked by penalty exposure, with owners, due dates, code fixes and verification. Use for "remediation plan", "fix findings", "close gaps". +description: "Turn audit findings into a remediation plan ranked by penalty exposure, with owners, due dates, code fixes and verification. Use for \"remediation plan\", \"fix findings\", \"close gaps\"." --- # Remediation Workflow diff --git a/.gitignore b/.gitignore index e2e9ec5..6767fac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,15 @@ node_modules/ .env *.local +.claude/settings.local.json +# Real organisations stay out of git; a `git add .` in a public fork would publish their gaps. Only the synthetic demo is tracked. +company-profile/* +!company-profile/.gitkeep +!company-profile/sample-co/ +assessments/* +!assessments/2026-10-19-sample-co-baseline/ +!assessments/templates/ +# After the rules above, so the re-included demo folders are covered too. company-profile/*/credentials.json assessments/**/raw/ kpis/metrics.json diff --git a/AGENTS.md b/AGENTS.md index ac4d897..442b137 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ These rules bind all agents (AI and human contributors) working in this reposito ## Layout -Only write files matching the paths defined in `.claude/schemas/layout.json`. A new file type needs a rule in that layout and a corresponding schema. +Only write files matching the paths defined in `.claude/schemas/layout.json`. A new file type needs a rule in that layout and a corresponding schema. Agents do not edit `.claude/scripts`, `.claude/schemas` or `.claude/settings.json`; a human changes those by hand. ## Structured data diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2b2ec..0941577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Exports +- OSCAL: `target-id` is now a valid token. Every finding failed NIST's 1.1.2 schema before. +- SARIF: rules carry `fullDescription` and `help`. Findings with no file and closed findings are left out, because code scanning needs a location and would show them as live alerts. The exporter names what it left out. +- OCSF: a remediated finding is `status_id` 4 (Resolved), not 3 (Suppressed). In-progress findings are 2. +- Exporters use each company's latest report, so a re-audit no longer doubles the findings, and they exit 1 for a company with no report. + +### Guardrails +- Hooks find the guard under `${CLAUDE_PROJECT_DIR}`, or relative to the working directory in a session that moved here from elsewhere, and find the workspace from the event's `cwd`. Before, a `cd` into a subdirectory made the hook crash with exit 1, which does not block, so writes went through. +- The write-guard fails closed: a broken layout blocks the write, and `onFailure: block` covers a hook that cannot start (Claude Code 2.1.295 or newer). +- `.claude/scripts`, `.claude/schemas` and `.claude/settings.json` are out of the writable layout. Agents could rewrite their own guards before. +- New: Aadhaar (with its check digit), PAN and +91 mobile numbers are blocked in company and assessment data. +- Secret patterns catch OpenSSH keys, `github_pat_` and `sk-ant-` keys, and JSON-style passwords. "password: hardcoded" in prose no longer blocks a write. +- The append-only check ignores CRLF differences. +- `npm run validate` rejects a skill description that is not valid YAML. All 20 are now quoted. + +### Usability +- `examples/acme-demo`: a synthetic app with planted problems and a list of what an audit should find. The README no longer calls the hand-written `sample-co` files real output. +- `run-headless.mjs --target ` points a headless run at a codebase. The timeout is 2 hours, up from 30 minutes, and a failed run saves its transcript. +- `npm run kpis -- --company `. KPIs count each company's latest report only. +- `company-details` accepts the address, user count and turnover that `/onboard` asks for. `/penalty` reads the turnover. +- `report.json` can record the target repo and commit, model, Vigil version and law-as-at date. Finding `file` paths must be relative. +- `/penalty` reports DPDP exposure per finding and per provision, because the per-finding total grows with how finely an audit splits its findings. +- `/export` documents what the scripts do. The import, Component Definition and Maxwell sections described things that did not exist. +- Real company data is gitignored by default. `"private": true` in `package.json`, because `vigil` on npm is an unrelated package. Added `CITATION.cff`. + ## 0.2.0 (2 November 2026) ### Skills are real slash commands now diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..5cf7607 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,16 @@ +cff-version: 1.2.0 +message: "If you use Vigil in research, please cite it as below." +type: software +title: "Vigil: an open-source DPDP and GDPR compliance agent for Claude Code" +authors: + - name: "Vigil contributors" +repository-code: "https://github.com/prathikcannotcode/Vigil" +license: Apache-2.0 +version: 0.2.0 +date-released: 2026-11-02 +keywords: + - dpdp + - gdpr + - privacy + - compliance + - claude-code diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9e8a6d..8a82ca1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ No `npm install`. Everything runs on Node 22+ built-ins. ## Rules of the road 1. **Cite the official source.** Gazette of India, EUR-Lex, legislation.gov.uk, a regulator's own site. Commentary helps, but never as the only source. If the date or clause is uncertain, say so in the skill. -2. **Keep to the layout.** A new file type needs a rule in `.claude/schemas/layout.json` and, for JSON, a schema. The write-guard hook will stop you otherwise, which is the point. +2. **Keep to the layout.** A new file type needs a rule in `.claude/schemas/layout.json` and, for JSON, a schema. The write-guard hook will stop you otherwise, which is the point. Hooks, schemas and settings are off limits to agents, so edit those by hand. 3. **One skill per directory.** `.claude/skills//SKILL.md` with `name` (matching the directory) and `description` frontmatter. Sections: trigger, purpose, legal basis, capabilities, guardrails, commands. 4. **No real personal data.** Not in fixtures, issues, screenshots or test output. Use `sample-co` style synthetic data. 5. **Append-only logs stay append-only.** Even in examples. diff --git a/README.md b/README.md index 2af5405..85014b3 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,14 @@ It won't tell you you're compliant. It will tell you which line of which file sa Illustrative /audit run on the synthetic sample-co workspace: one critical finding (Aadhaar numbers stored in plaintext, DPDP s.8(5), Rule 6(1)(a)) and three high findings, a DPDP and GDPR posture grid across six dimensions, maximum theoretical exposure of ₹650 crore under DPDP and €90M under GDPR, and the three files written. -That run is on **sample-co**, a synthetic lending app that ships with the repo, so you can see real output before pointing Vigil at anything of yours: +That picture is **sample-co**, a made-up lending app. Its files were written by hand to show the format, and no code ships for it, so `/audit sample-co` has nothing to scan: - [`summary.md`](assessments/2026-10-19-sample-co-baseline/summary.md): the human-readable audit, with posture, findings, penalty arithmetic and a fix-first list - [`report.json`](assessments/2026-10-19-sample-co-baseline/report.json): 9 schema-valid findings with owners, due dates and cross-references between DPDP and GDPR - [`breach-log.jsonl`](company-profile/sample-co/breach-log.jsonl): an incident from detection to CERT-In (5h 25m) and the EU authority (44h 10m), one append-only line per step +To watch a real run, audit [`examples/acme-demo`](examples/acme-demo), a small synthetic app with planted problems, and score the result against [the list of what it should find](examples/acme-demo.expected.json). Quick start step 5 shows how. That list is also the place to start if you want to measure Vigil's accuracy; no benchmark results are published yet. + ## Why Vigil | | Spreadsheet checklist | Hosted privacy platform | **Vigil** | @@ -74,12 +76,15 @@ You need [Node.js 22+](https://nodejs.org) and [Claude Code](https://claude.com/ ``` 3. **Look at the demo.** Type `/posture sample-co` or ask *"What's sample-co's penalty exposure?"* 4. **Add your organisation.** Type `/onboard` or say *"Add my company acme-tech, a SaaS company processing data in India and the EU."* -5. **Audit a codebase.** Add the codebase to the session (`/add-dir ../your-app`), then run `/audit`. Findings land in `assessments/`. +5. **Audit a codebase.** Add the codebase to the session (`/add-dir ../your-app`), then run `/audit`. Findings land in `assessments/`. To try it on the synthetic app first, copy it out of the repo so the run can't read the answer list, then add it the same way: + ```bash + cp -r examples/acme-demo ../acme-demo + ``` 6. **Send findings where your team already looks.** ```bash npm run export:sarif -- --company acme-tech --output findings.sarif ``` - Upload `findings.sarif` with [`github/codeql-action/upload-sarif`](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github) and the findings appear in the Security tab next to your code. + Upload `findings.sarif` with [`github/codeql-action/upload-sarif`](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github) and findings with a file and line should appear in the Security tab next to your code. Findings with no file, such as a missing contract, and closed findings are left out of SARIF. The exporter names them, and they stay in `report.json`, OSCAL and OCSF. Delete `company-profile/sample-co/` and `assessments/2026-10-19-sample-co-baseline/` whenever you like. @@ -178,31 +183,35 @@ Skills are Markdown that tell Claude what the law requires and how to check for | Guard | Enforced by | What it stops | |---|---|---| -| Workspace layout | `write-guard.mjs` reading [`layout.json`](.claude/schemas/layout.json) | Writes to any path the layout does not list, and path traversal out of the repo | +| Workspace layout | `write-guard.mjs` reading [`layout.json`](.claude/schemas/layout.json) | Writes to any path the layout does not list, path traversal out of the repo, and edits to the hooks, schemas and settings themselves | | Append-only logs | `write-guard.mjs` | Any write to `breach-log.jsonl` or `rights-log.jsonl` whose result does not start with the existing file. Corrections are new lines. | | Schema validation | `validate-on-write.mjs`, `npm run validate` | A finding without a clause or severity, a breach entry without an event, a posture with an unknown status. 9 schemas. | -| Secrets | `write-guard.mjs` | AWS, GitHub and OpenAI-style keys, private keys, `password=` assignments | -| No personal data in findings | Skill rules and schema shape | Findings carry `file` and `line`, never the value found there | -| Tests | `npm test`, GitHub Actions | Regressions in all of the above. 8 tests, Node's built-in runner. | +| Secrets | `write-guard.mjs` | AWS, GitHub, OpenAI and Anthropic-style keys, private keys, password assignments | +| No personal data in findings | `write-guard.mjs`, skill rules, schema shape | Aadhaar, PAN and +91 mobile numbers in company or assessment data. Findings carry `file` and `line`, never the value found there. Other identifiers rest on the skill rules. | +| Tests | `npm test`, GitHub Actions | Regressions in all of the above. 23 tests, Node's built-in runner. | Known limit: hooks guard Claude Code's Write, Edit and MultiEdit tools. A shell command run through Bash is not intercepted, so `.claude/settings.json` only pre-approves the validate and test commands. Details in [SECURITY.md](SECURITY.md). +Your own company profiles and assessments are gitignored by default; only the synthetic demo is tracked. Use `git add -f` if you do want them in a private fork. + ## Headless, CI and KPIs Run a workflow without an interactive session, for example from a nightly job: ```bash -node .claude/scripts/run-headless.mjs --workflow audit --company acme-tech --framework dpdp +node .claude/scripts/run-headless.mjs --workflow audit --company acme-tech --framework dpdp --target ../your-app ``` -Add `--dry-run` to print the prompt, or `--harness opencode --model ` to run on OpenCode. Every argument is validated before it reaches the shell, and the transcript lands in `kpis/data/raw/sessions/`. +`--target` is the codebase to read. Add `--dry-run` to print the prompt, or `--harness opencode --model ` to run on OpenCode (no `--target` there, and the guardrail hooks are Claude Code settings, so don't assume they run). Every argument is validated before it reaches the shell, and the transcript lands in `kpis/data/raw/sessions/`, including a partial one if the run fails. + +On a fresh clone, run `claude` once interactively and accept the trust dialog first. Until then Claude Code ignores the `permissions.allow` list in `.claude/settings.json`. -`npm run kpis` turns transcripts and reports into six numbers: +`npm run kpis` turns transcripts and each company's latest report into six numbers. Add `-- --company acme-tech` for one company; otherwise `sample-co` is pooled with yours until you delete it. | KPI | Source | On sample-co | |---|---|---| | Cost per audit | `total_cost_usd` from Claude Code's JSON output | no runs recorded yet | -| Coverage | examined / applicable controls in each report | 84.8% | +| Coverage | examined / applicable controls in each report, as counted by the assessor | 84.8% | | Actionability | findings with clause, recommendation, owner and due date | 33.3% | | Time to remediation | `remediated_at` minus `found_at`, median and p90 | 5 days | | Posture trend | dimensions improved or regressed between snapshots | 1 snapshot | @@ -231,7 +240,7 @@ Plus 7 consent notice templates (including Hindi) in [`examples/consent-notices` | `.claude/scripts/` | Hooks, validator, exporters, KPIs, headless runner, tests | | `company-profile/{id}/` | Per-organisation data: details, data map, consent records, DPIAs, logs, posture | | `assessments/{id}/` | Point-in-time audits: report, posture, summary | -| `examples/` | Consent notice templates and sector guides | +| `examples/` | Consent notice templates, sector guides and a synthetic app to audit | | `docs/assets/` | README images, logo, [brand sheet](docs/assets/brand-sheet.png) and [design tokens](docs/assets/vigil-tokens.css) | ## FAQ diff --git a/SECURITY.md b/SECURITY.md index 7d0cf14..d3c994f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,8 +16,11 @@ Include what you found, how to reproduce it, and the impact. ## Known limits - Hooks guard Claude Code's Write, Edit and MultiEdit tools. A shell command run through the Bash tool is not intercepted; keep Bash permissions narrow (`.claude/settings.json` allows only the validate and test commands by default). -- The secret patterns catch common key formats. They are a tripwire, not a scanner. +- The secret and personal-data patterns catch common formats. They are a tripwire, not a scanner. +- `onFailure: block` needs Claude Code 2.1.295 or newer. On older versions, a hook that cannot start at all does not block the write. +- The hook command looks for the guard under `${CLAUDE_PROJECT_DIR}`, the folder a session started in, then relative to the working directory. A session that moved here from elsewhere and then changed into a subdirectory can't find it, so writes are blocked until it returns to the repo root. +- On Windows without Git Bash, Claude Code runs hooks in PowerShell, which can't parse this command. The write-guard then blocks every write, and post-write validation does not run. Install Git for Windows. ## Your data -Vigil runs locally on your machine and your model provider. It has no server and no telemetry. Company profiles you create stay in your clone. If you fork publicly, keep real company profiles out of the fork. +Vigil runs locally on your machine and your model provider. It has no server and no telemetry. Company profiles and assessments you create stay in your clone and are gitignored by default. If you fork publicly, keep them out of the fork. diff --git a/examples/acme-demo.expected.json b/examples/acme-demo.expected.json new file mode 100644 index 0000000..449617f --- /dev/null +++ b/examples/acme-demo.expected.json @@ -0,0 +1,18 @@ +{ + "description": "What an audit of examples/acme-demo should find. Copy the app out of this repo before auditing, so the run cannot read this file. Clauses are the expected anchors, not an exhaustive list; a good run may cite more.", + "expected": [ + { "id": "P1", "file": "db/migrations/0003_kyc.sql", "issue": "Aadhaar and PAN stored in plaintext", "clause": "DPDP s.8(5), Rule 6(1)(a); GDPR Art. 32(1)(a)" }, + { "id": "P2", "file": "web/signup.html", "issue": "One pre-ticked checkbox bundles terms and marketing consent", "clause": "DPDP s.6(1), Rule 3(b); GDPR Art. 7(2)" }, + { "id": "P3", "file": "services/notify.py", "issue": "Mobile numbers and message bodies in INFO logs", "clause": "DPDP s.8(5), Rule 6(1)(c); GDPR Art. 32(1)(b)" }, + { "id": "P4", "file": "docs/runbooks/incident-response.md", "issue": "No Board or Data Principal intimation, no 72-hour clock", "clause": "DPDP s.8(6), Rule 7(2)(b); GDPR Art. 33(1)" }, + { "id": "P5", "file": "services/accounts.py", "issue": "Closed accounts keep personal data indefinitely", "clause": "DPDP s.8(7), Rule 8; GDPR Art. 5(1)(e)" }, + { "id": "P6", "file": "web/settings.html", "issue": "Withdrawal needs an email and up to 30 days", "clause": "DPDP s.6(4); GDPR Art. 7(3)" }, + { "id": "P7", "file": "services/analytics.py", "issue": "EU identifiers sent to a US endpoint with no transfer mechanism or DPA (see docs/vendors.md)", "clause": "GDPR Art. 44, Art. 46(1), Art. 28(3)" }, + { "id": "P8", "file": "services/offers.py", "issue": "Behavioural ad segments with no age gate", "clause": "DPDP s.9(3); GDPR Art. 8(1)" }, + { "id": "P9", "file": "tests/fixtures/users.json", "issue": "Realistic identifiers in a test fixture. The values must not be copied into the report.", "clause": "DPDP s.8(5); GDPR Art. 5(1)(f)" }, + { "id": "P10", "file": "config/settings.py", "issue": "Database credential hardcoded in a tracked file. The value must not be copied into the report.", "clause": "DPDP s.8(5), Rule 6(1)(b); GDPR Art. 32(1)(b)" } + ], + "should_not_flag": [ + { "file": "db/migrations/0004_payments.sql", "why": "The account number is stored as ciphertext with a KMS key reference, so it must not be reported as plaintext." } + ] +} diff --git a/examples/acme-demo/README.md b/examples/acme-demo/README.md new file mode 100644 index 0000000..ccdfa18 --- /dev/null +++ b/examples/acme-demo/README.md @@ -0,0 +1,5 @@ +# Acme Lend (synthetic) + +A made-up consumer lending app for India and the EU, written to be audited by Vigil. It has no real data and does not run. The issues planted in it are listed in [`../acme-demo.expected.json`](../acme-demo.expected.json). + +The identifiers in `tests/fixtures/users.json` are synthetic: a UIDAI test Aadhaar number, a placeholder phone number and a format-example PAN. The password in `config/settings.py` is not a real credential. diff --git a/examples/acme-demo/config/settings.py b/examples/acme-demo/config/settings.py new file mode 100644 index 0000000..efb17cf --- /dev/null +++ b/examples/acme-demo/config/settings.py @@ -0,0 +1,2 @@ +DB_HOST = "db.internal" +DB_PASSWORD = "not-a-real-password-fixture" diff --git a/examples/acme-demo/db/migrations/0003_kyc.sql b/examples/acme-demo/db/migrations/0003_kyc.sql new file mode 100644 index 0000000..ccef79e --- /dev/null +++ b/examples/acme-demo/db/migrations/0003_kyc.sql @@ -0,0 +1,10 @@ +-- KYC storage +CREATE TABLE kyc_documents ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + aadhaar_number VARCHAR(12) NOT NULL, + pan_number VARCHAR(10) NOT NULL, + full_name TEXT NOT NULL, + date_of_birth DATE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); diff --git a/examples/acme-demo/db/migrations/0004_payments.sql b/examples/acme-demo/db/migrations/0004_payments.sql new file mode 100644 index 0000000..8554059 --- /dev/null +++ b/examples/acme-demo/db/migrations/0004_payments.sql @@ -0,0 +1,7 @@ +-- Card tokens live with the PSP. The bank account number is envelope-encrypted with a KMS key before insert. +CREATE TABLE payout_accounts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + account_ciphertext BYTEA NOT NULL, + kms_key_id TEXT NOT NULL +); diff --git a/examples/acme-demo/docs/runbooks/incident-response.md b/examples/acme-demo/docs/runbooks/incident-response.md new file mode 100644 index 0000000..408a951 --- /dev/null +++ b/examples/acme-demo/docs/runbooks/incident-response.md @@ -0,0 +1,4 @@ +# Incident response +1. Page the on-call engineer. +2. Contain and patch. +3. Write a postmortem within two weeks. diff --git a/examples/acme-demo/docs/vendors.md b/examples/acme-demo/docs/vendors.md new file mode 100644 index 0000000..54eddfe --- /dev/null +++ b/examples/acme-demo/docs/vendors.md @@ -0,0 +1,2 @@ +# Vendors +- Mixpanel (US): product analytics. Contract: click-through terms, no DPA signed, no SCCs. diff --git a/examples/acme-demo/services/accounts.py b/examples/acme-demo/services/accounts.py new file mode 100644 index 0000000..1738cd6 --- /dev/null +++ b/examples/acme-demo/services/accounts.py @@ -0,0 +1,4 @@ +def close_account(user): + user.status = "closed" + user.save() + # we keep everything on closed accounts, storage is cheap diff --git a/examples/acme-demo/services/analytics.py b/examples/acme-demo/services/analytics.py new file mode 100644 index 0000000..ebff482 --- /dev/null +++ b/examples/acme-demo/services/analytics.py @@ -0,0 +1,7 @@ +import requests + +def track(user, event): + # Mixpanel US endpoint, all regions including EU users + requests.post("https://api.mixpanel.com/track", json={ + "email": user.email, "device_id": user.device_id, "ip": user.ip, "event": event, + }) diff --git a/examples/acme-demo/services/notify.py b/examples/acme-demo/services/notify.py new file mode 100644 index 0000000..1a04042 --- /dev/null +++ b/examples/acme-demo/services/notify.py @@ -0,0 +1,6 @@ +import logging +logger = logging.getLogger(__name__) + +def send_sms(phone, body): + logger.info("sending sms to %s: %s", phone, body) + return gateway_send(phone, body) diff --git a/examples/acme-demo/services/offers.py b/examples/acme-demo/services/offers.py new file mode 100644 index 0000000..1220a84 --- /dev/null +++ b/examples/acme-demo/services/offers.py @@ -0,0 +1,3 @@ +def target_offers(user): + # no age gate: under-18 users get the same behavioural ad segments + return ad_segments_for(user.browsing_history) diff --git a/examples/acme-demo/services/rights.py b/examples/acme-demo/services/rights.py new file mode 100644 index 0000000..71aac85 --- /dev/null +++ b/examples/acme-demo/services/rights.py @@ -0,0 +1,3 @@ +def export_user_data(user): + """Data access endpoint, served at /me/export within the app.""" + return serialize(user) diff --git a/examples/acme-demo/tests/fixtures/users.json b/examples/acme-demo/tests/fixtures/users.json new file mode 100644 index 0000000..0dd3fd6 --- /dev/null +++ b/examples/acme-demo/tests/fixtures/users.json @@ -0,0 +1 @@ +[{"name":"Test Person","aadhaar":"999971658847","phone":"+910000000000","pan":"ABCPE1234F"}] diff --git a/examples/acme-demo/web/settings.html b/examples/acme-demo/web/settings.html new file mode 100644 index 0000000..ee24f1d --- /dev/null +++ b/examples/acme-demo/web/settings.html @@ -0,0 +1,4 @@ +
+

Privacy

+

To withdraw your consent, email support@acme.example and allow up to 30 days.

+
diff --git a/examples/acme-demo/web/signup.html b/examples/acme-demo/web/signup.html new file mode 100644 index 0000000..db3b73e --- /dev/null +++ b/examples/acme-demo/web/signup.html @@ -0,0 +1,8 @@ +
+ + + + + +
diff --git a/kpis/measurement/kpi-definitions.md b/kpis/measurement/kpi-definitions.md index 09609a4..1cfd2d0 100644 --- a/kpis/measurement/kpi-definitions.md +++ b/kpis/measurement/kpi-definitions.md @@ -11,8 +11,8 @@ Six KPIs track audit quality from cost to outcome. `npm run kpis` computes them ## 2. Coverage **What:** Share of applicable controls examined in the audit period. -**How:** Count findings (any status) per applicable framework control. Controls with zero findings in the period are uncovered. -**Formula:** `covered_controls / total_applicable_controls` +**How:** `coverage.examined` and `coverage.applicable` from each company's latest report. The assessor counts both; there is no fixed control list, so don't compare the figure across runs. +**Formula:** `examined / applicable` **Target:** 100% for critical controls; 80%+ overall. ## 3. Actionability diff --git a/package.json b/package.json index 54460b4..0080e43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "vigil", "version": "0.2.0", + "private": true, "description": "Open-source DPDP and GDPR compliance workspace agent: 20 Claude Code skills, JSON Schemas and write-guard hooks", "license": "Apache-2.0", "repository": {