From 26aa60691dccb48f0ff46faefb3d7307e8b2c0f4 Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Wed, 11 Nov 2026 20:05:07 +0530 Subject: [PATCH] Tighten guardrails and document their limits after review A review of 731a3f9 on synthetic data found four gaps. This closes two and states the rest plainly. - The write-guard blocks a hook event it cannot read: invalid JSON, no tool_name or tool_input, or a file tool with no path. All of these were allowed before. NotebookEdit now goes through the guard too. - A finding's clause must name a DPDP section (1-44) or rule (1-23), or a GDPR article (1-99), in its own framework. "Section 999 of the Imaginary Act" passed before. Sub-clauses and cross_reference are still unchecked. - The post-write validator reports an event it cannot read instead of skipping it. - SECURITY.md lists what the guards do not cover: shell and MCP writes, identifiers beyond Aadhaar, PAN and +91 mobiles, clause meaning and applicability, and sector rules. - A sector-only duty goes in the assessment summary, not a DPDP finding standing in for it. AGENTS.md, /audit and the sector guides say so. - The schema-validation test runs on a copy of the tracked demo, so a local audit can't turn npm test red. Co-Authored-By: Claude Opus 5.5 --- .claude/schemas/finding.schema.json | 4 +- .claude/scripts/lib.mjs | 37 +++++++++++++++- .claude/scripts/tests/guardrails.test.mjs | 43 +++++++++++++++++-- .claude/scripts/validate-on-write.mjs | 5 ++- .claude/scripts/write-guard.mjs | 31 +++++++------ .claude/settings.json | 2 +- .claude/skills/audit/SKILL.md | 2 +- AGENTS.md | 4 +- CHANGELOG.md | 15 +++++++ README.md | 17 ++++---- SECURITY.md | 37 +++++++++++++++- examples/sector-guides/edtech-education.md | 2 + examples/sector-guides/fintech-banking.md | 2 + .../sector-guides/healthtech-healthcare.md | 2 + 14 files changed, 169 insertions(+), 34 deletions(-) diff --git a/.claude/schemas/finding.schema.json b/.claude/schemas/finding.schema.json index 906c5f2..db4f8c6 100644 --- a/.claude/schemas/finding.schema.json +++ b/.claude/schemas/finding.schema.json @@ -7,8 +7,8 @@ "id": { "type": "string", "pattern": "^F-[0-9]{4,}$" }, "severity": { "type": "string", "enum": ["critical", "high", "medium", "low"] }, "framework": { "type": "string", "enum": ["DPDP", "GDPR", "UK-GDPR"] }, - "clause": { "type": "string", "minLength": 1, "description": "Regulatory reference, e.g. 'Section 8(5)', 'Rule 7(2)' or 'Art. 32(1)(a)'" }, - "cross_reference": { "type": "string", "description": "The equivalent clause in the other framework when a finding applies to both, e.g. 'GDPR Art. 33(1)'" }, + "clause": { "type": "string", "minLength": 1, "description": "References in this finding's framework, separated by commas, e.g. 's.8(5), Rule 7(2)' or 'Art. 32(1)(a)'. Validation checks that each section, rule or article number exists, not sub-clauses or whether it applies." }, + "cross_reference": { "type": "string", "description": "The equivalent clause in the other framework, e.g. 'GDPR Art. 33(1)', and any sector instrument, e.g. 'CERT-In Directions 2022'. Free text; nothing validates it." }, "issue": { "type": "string", "minLength": 1 }, "recommendation": { "type": "string", "minLength": 1 }, "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." }, diff --git a/.claude/scripts/lib.mjs b/.claude/scripts/lib.mjs index 295c257..308fe67 100644 --- a/.claude/scripts/lib.mjs +++ b/.claude/scripts/lib.mjs @@ -142,6 +142,38 @@ export function validate(schema, value, at = '$', root = process.cwd(), errors = return errors; } +// --------------------------------------------------------------------------- +// Clause references +// shortcut: checks that each cited section, rule or article number exists in the finding's +// own framework. It does not check sub-clauses, cross_reference or whether the clause applies. + +const ACT = [44, 'the DPDP Act has sections 1-44']; +const RULES = [23, 'the DPDP Rules have rules 1-23']; +const GDPR = [99, 'the GDPR has articles 1-99']; +const CLAUSE_FORMS = { + DPDP: [{ 's.': ACT, 'ss.': ACT, Section: ACT, Sections: ACT, Rule: RULES, Rules: RULES }, '"s.8(5)" or "Rule 6(1)(a)"'], + GDPR: [{ 'Art.': GDPR, 'Arts.': GDPR, Article: GDPR, Articles: GDPR }, '"Art. 32(1)(a)"'], +}; +CLAUSE_FORMS['UK-GDPR'] = CLAUSE_FORMS.GDPR; +const SUB = String.raw`(?:\((?:\d+[A-Z]?|[a-z]{1,4})\))*`; +const REF = new RegExp(String.raw`^([A-Za-z.]+) ?(\d+)([A-Z]?)${SUB}(?: ?[-–] ?(\d+)([A-Z]?)${SUB})?$`); + +/** Problems with a finding's `clause`: every comma- or semicolon-separated reference must exist in its framework. */ +export function clauseErrors(finding, at = '$') { + const [forms, example] = CLAUSE_FORMS[finding?.framework] ?? []; + if (!forms || typeof finding.clause !== 'string') return []; // the schema reports these + const refs = finding.clause.split(/[,;]/).map(s => s.trim()).filter(Boolean); + if (!refs.length) return [`${at}.clause: names no reference`]; + return refs.flatMap(ref => { + const [, prefix, from, letter, to, toLetter] = ref.match(REF) ?? []; + const form = forms[prefix]; + if (!form) return [`${at}.clause: "${ref}" is not a ${finding.framework} reference like ${example}. Cite other instruments in cross_reference.`]; + if ([from, to].some(n => n !== undefined && (n < 1 || n > form[0]))) return [`${at}.clause: "${ref}" does not exist: ${form[1]}`]; + if ((letter || toLetter) && finding.framework !== 'UK-GDPR') return [`${at}.clause: "${ref}" does not exist: only UK GDPR has lettered articles`]; + return []; + }); +} + /** Parses a .json/.jsonl file and validates it against the schema its layout rule names. */ export function checkFile(relPath, root = process.cwd(), layout = loadLayout(root)) { const ext = extname(relPath); @@ -164,7 +196,10 @@ export function checkFile(relPath, root = process.cwd(), layout = loadLayout(roo const rule = matchRule(layout, relPath); if (!rule?.schema) return []; const schema = loadSchema(rule.schema, root); - return records.flatMap(([at, value]) => validate(schema, value, at, root)); + return records.flatMap(([at, value]) => [ + ...validate(schema, value, at, root), + ...(rule.schema === 'report.schema.json' && Array.isArray(value?.findings) ? value.findings.flatMap((f, i) => clauseErrors(f, `${at}.findings[${i}]`)) : []), + ]); } // --------------------------------------------------------------------------- diff --git a/.claude/scripts/tests/guardrails.test.mjs b/.claude/scripts/tests/guardrails.test.mjs index 0f2699b..a12eba0 100644 --- a/.claude/scripts/tests/guardrails.test.mjs +++ b/.claude/scripts/tests/guardrails.test.mjs @@ -5,14 +5,14 @@ import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSy import { tmpdir } from 'node:os'; import { join, dirname, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadLayout, matchRule, checkFile, validate, loadFindings, workspaceRoot, frontmatterErrors } from '../lib.mjs'; +import { loadLayout, matchRule, checkFile, validate, clauseErrors, loadFindings, workspaceRoot, frontmatterErrors } from '../lib.mjs'; const scripts = join(dirname(fileURLToPath(import.meta.url)), '..'); const repo = join(scripts, '..', '..'); function run(script, { cwd = repo, input, args = [] } = {}) { return spawnSync(process.execPath, [join(scripts, script), ...args], { - cwd, input: input && JSON.stringify(input), encoding: 'utf8', + cwd, input: typeof input === 'string' ? input : input && JSON.stringify(input), encoding: 'utf8', }); } @@ -54,8 +54,11 @@ function secondAudit(dir) { } test('sample workspace passes schema validation', () => { - const r = run('validate.mjs', { args: ['--strict'] }); + const dir = dataCopy(); + for (const d of ['.claude/schemas', '.claude/skills']) cpSync(join(repo, d), join(dir, d), { recursive: true }); + const r = run('validate.mjs', { cwd: dir, args: ['--strict'] }); assert.equal(r.status, 0, r.stderr); + rmSync(dir, { recursive: true }); }); test('layout matches data paths and rejects strays', () => { @@ -91,6 +94,21 @@ test('finding file paths must be relative to the audited codebase', () => { } }); +test('a clause must name a section, rule or article its framework has', () => { + const errors = (framework, clause) => clauseErrors({ framework, clause }); + for (const clause of ['s.8(5), Rule 6(1)(a)', 'Section 10(2)(c)(i); Rule 13(1)', 'ss.11-14', 'Rule 3(c)(i)']) assert.deepEqual(errors('DPDP', clause), [], clause); + for (const clause of ['Art. 32(1)(a), Art. 5(1)(f)', 'Art. 6(1)(ea)', 'Art. 44-49']) assert.deepEqual(errors('GDPR', clause), [], clause); + assert.deepEqual(errors('UK-GDPR', 'Art. 22A-22D'), []); + assert.match(errors('DPDP', 'Section 999 of the Imaginary Act').join(), /not a DPDP reference/); + assert.match(errors('DPDP', 'Section 999').join(), /sections 1-44/); + assert.match(errors('DPDP', 'Rule 24').join(), /rules 1-23/); + assert.match(errors('GDPR', 'Art. 100').join(), /articles 1-99/); + assert.match(errors('GDPR', 'Art. 22A').join(), /only UK GDPR/); + assert.match(errors('GDPR', 's.8(5)').join(), /not a GDPR reference/); + assert.match(errors('DPDP', 'CERT-In Directions 2022').join(), /cross_reference/, 'a sector instrument is not a DPDP clause'); + assert.match(errors('DPDP', ' ').join(), /names no reference/); +}); + test('report $ref validates nested findings', () => { const dir = workspace(); mkdirSync(join(dir, 'assessments/a1'), { recursive: true }); @@ -99,6 +117,11 @@ test('report $ref validates nested findings', () => { findings: [{ id: 'F-0001', severity: 'high', framework: 'DPDP', issue: 'x', recommendation: 'y' }], })); assert.match(checkFile('assessments/a1/report.json', dir).join(), /findings\[0\]: missing required "clause"/); + writeFileSync(join(dir, 'assessments/a1/report.json'), JSON.stringify({ + assessment_id: 'a1', company_id: 'acme', scope: ['DPDP'], started_at: '2026-10-19T10:00:00Z', + findings: [{ id: 'F-0001', severity: 'high', framework: 'DPDP', clause: 'Section 999 of the Imaginary Act', issue: 'x', recommendation: 'y' }], + })); + assert.match(checkFile('assessments/a1/report.json', dir).join(), /findings\[0\]\.clause: "Section 999 of the Imaginary Act" is not a DPDP reference/); rmSync(dir, { recursive: true }); }); @@ -180,6 +203,19 @@ test('hooks find the workspace from a subdirectory and block with exit 2', () => rmSync(dir, { recursive: true }); }); +test('write-guard fails closed on events it cannot read', () => { + const dir = workspace(); + const guard = input => run('write-guard.mjs', { cwd: dir, input }).status; + for (const input of ['', 'not json', 'null', '{}', { tool_name: 'Write' }, { tool_name: 'Write', tool_input: {} }, { tool_name: 'Edit', tool_input: { file_path: '' } }]) { + assert.equal(guard(input), 2, JSON.stringify(input)); + } + assert.equal(guard({ tool_name: 'TodoWrite', tool_input: { todos: [] } }), 0, 'a tool that writes no file, caught by a loose matcher'); + assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'src/x.ipynb', new_source: 'x' } }), 2); + assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'company-profile/acme/breach-log.jsonl', new_source: '{"c":3}' } }), 2); + assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'docs/x.ipynb', new_source: 'x' } }), 0); + 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'); @@ -194,6 +230,7 @@ test('validate-on-write exits 2 so the agent sees schema errors', () => { const r = run('validate-on-write.mjs', { cwd: dir, input: { tool_input: { file_path: 'company-profile/acme/breach-log.jsonl' } } }); assert.equal(r.status, 2); assert.match(r.stderr, /missing required "event"/); + assert.equal(run('validate-on-write.mjs', { cwd: dir, input: 'not json' }).status, 2, 'an unreadable event must not pass silently'); rmSync(dir, { recursive: true }); }); diff --git a/.claude/scripts/validate-on-write.mjs b/.claude/scripts/validate-on-write.mjs index ae65656..2832e85 100644 --- a/.claude/scripts/validate-on-write.mjs +++ b/.claude/scripts/validate-on-write.mjs @@ -15,7 +15,10 @@ process.stdin.on('data', chunk => { raw += chunk; }); process.stdin.on('end', () => { let event; try { event = JSON.parse(raw); } - catch { process.exit(0); } + catch { + console.error('Validation skipped: could not read the hook event. Run npm run validate.'); + process.exit(2); + } const filePath = event.tool_input?.file_path; if (!filePath) process.exit(0); diff --git a/.claude/scripts/write-guard.mjs b/.claude/scripts/write-guard.mjs index 6e0109b..1c3ab69 100644 --- a/.claude/scripts/write-guard.mjs +++ b/.claude/scripts/write-guard.mjs @@ -7,8 +7,9 @@ * 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. + * Used as a PreToolUse hook on Write, Edit, MultiEdit and NotebookEdit. + * Reads the event from stdin, exits 2 to block with a message. An event it + * cannot read blocks too. Shell commands never reach it: see SECURITY.md. */ import { readFileSync, existsSync } from 'node:fs'; @@ -53,9 +54,12 @@ function editsOf(input) { return []; } +const FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']); + // The file content after the tool runs, so append-only checks see the real result. +// Anything that is not an edit counts as a full replacement, so an unfamiliar tool can't slip past the append-only check. function contentAfter(before, toolName, input) { - if (toolName === 'Write') return input.content ?? ''; + if (toolName !== 'Edit' && toolName !== 'MultiEdit') return input.content ?? input.new_source ?? ''; let text = before; for (const { old_string: from, new_string: to, replace_all } of editsOf(input)) { if (replace_all) text = text.split(from).join(to); @@ -70,9 +74,12 @@ function contentAfter(before, toolName, input) { const lf = text => text.replace(/\r\n/g, '\n'); function guard(event) { - const input = event.tool_input ?? {}; - const filePath = input.file_path; - if (!filePath) return; + const input = event?.tool_input; + if (typeof event?.tool_name !== 'string' || !input || typeof input !== 'object') block('malformed hook event: no tool_name or tool_input'); + const filePath = input.file_path ?? input.notebook_path; + // A matcher like "Write" may also catch a tool that writes no file, such as TodoWrite. + if (filePath === undefined && !FILE_TOOLS.has(event.tool_name)) return; + if (typeof filePath !== 'string' || !filePath) block(`${event.tool_name} event names no file path`); // 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)); @@ -93,10 +100,12 @@ function guard(event) { } } - const content = [input.content, ...editsOf(input).map(e => e.new_string)].filter(Boolean).join('\n'); + const content = [input.content, input.new_source, ...editsOf(input).map(e => e.new_string)].filter(Boolean).join('\n'); 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.'); } + // shortcut: three Indian formats with low false-positive rates. Emails and dates look like legitimate profile + // data and pass; card and ABHA numbers have their own shape but are not covered yet. See SECURITY.md. 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.'); } @@ -106,12 +115,8 @@ 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); } + // Only exit 2 blocks, so unreadable input or an unexpected error must not let the write through. + try { guard(JSON.parse(raw)); } catch (e) { block(`guard error: ${e.message}`); } process.exit(0); }); diff --git a/.claude/settings.json b/.claude/settings.json index 72986e9..d3bdef3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,7 +14,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Write|Edit|MultiEdit", + "matcher": "Write|Edit|MultiEdit|NotebookEdit", "hooks": [ { "type": "command", diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md index 9fe14b2..e5d1868 100644 --- a/.claude/skills/audit/SKILL.md +++ b/.claude/skills/audit/SKILL.md @@ -26,7 +26,7 @@ Run a complete privacy compliance audit covering both DPDP and GDPR. Orchestrate 4. **Rights mechanisms** (`/dpdp-rights audit`): verify grievance and rights request mechanisms. 5. **DPIA check** (`/dpdp-dpia screen`): determine if a DPIA is needed. If it is, write a `draft` with `/dpdp-dpia full`. 6. **Breach readiness** (`/dpdp-breach readiness`): compare the runbook with the required steps. -7. **Sector check**: if `details.json` lists `RBI`, `SEBI` or `IRDAI`, or the company is financial and its regulated status is unknown, check the matching rows of `/dpdp-catalog` under "Related Indian instruments". File a finding under the closest DPDP clause, name the instrument in `cross_reference`, and say whether it applies or only might. +7. **Sector check**: if `details.json` lists `RBI`, `SEBI` or `IRDAI`, or the company is financial and its regulated status is unknown, check the matching rows of `/dpdp-catalog` under "Related Indian instruments". File a finding under the DPDP clause that carries the same duty, name the instrument in `cross_reference`, and say whether it applies or only might. When no DPDP clause carries it (CERT-In's 6-hour report runs on its own clock), list it under "Sector obligations" in `summary.md` instead. The validator rejects a `clause` that is not a DPDP or GDPR reference. ### Phase 3: GDPR assessment diff --git a/AGENTS.md b/AGENTS.md index 04c5299..715a2fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Only write files matching the paths defined in `.claude/schemas/layout.json`. A Every compliance finding must: - Follow `.claude/schemas/finding.schema.json` -- Cite the specific regulatory clause (DPDP section number or GDPR article) +- Cite the specific regulatory clause in `clause`: DPDP sections and rules or GDPR articles, separated by commas, such as `s.8(5), Rule 6(1)(a)` or `Art. 32(1)(a)`. The validator rejects a number the framework doesn't have. Other instruments (CERT-In, RBI, SEBI) go in `cross_reference`, which nothing checks. - Have a severity level (critical / high / medium / low) - Include a concrete recommendation @@ -40,7 +40,7 @@ When a finding applies to both frameworks, note both clause references. A run should end with a partial result and a list of what it skipped. It should not stall. - **Nobody can answer a question** (headless run, side session, no reply): take the answer from the material you have and carry on. Never write `false`, `0` or a guess for something you don't know. Leave an optional field out and name it in the output. -- **A schema has no value for what you found**: use the closest allowed value, say in a text field that it is a stand-in, and report the gap. Do not edit the schema. +- **A schema has no value for what you found**: use the closest allowed value, say in a text field that it is a stand-in, and report the gap. Do not edit the schema. Findings are the exception: a duty that only CERT-In, RBI or another sector rule imposes goes in the assessment summary, not `report.json`. A DPDP finding standing in for it reads as a DPDP gap it isn't. - **A hook blocks a write**: do not route around it through the shell or another tool. Put the content at the nearest path the layout allows, usually `assessments/{id}/`, and report the block. - **A skill has no mode for the step**: use its legal-basis table as the checklist and cite the clauses it lists. - **The only evidence is an absence**: if a document or control is merely missing from the material you were given, write "not found in the reviewed material" and rate it medium at most. Go higher when the code or a document itself shows the gap. diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ecb66..c83866b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +Changes from a review of commit 731a3f9 that used synthetic data only. + +### Guardrails +- The write-guard blocks a hook event it cannot read: invalid JSON, no `tool_name` or `tool_input`, or a file tool with no path. It allowed all of these before. +- NotebookEdit goes through the write-guard. It could write a notebook anywhere before. +- Every reference in a finding's `clause` must name a DPDP section (1-44) or rule (1-23), or a GDPR article (1-99), in the finding's own framework. "Section 999 of the Imaginary Act" passed before. Sub-clauses and `cross_reference` are still unchecked. +- The post-write validator reports a hook event it cannot read instead of skipping it. +- The schema-validation test runs on a copy of the tracked demo, so a local audit can't turn `npm test` red. + +### Docs +- `SECURITY.md` lists what the guards do not cover: shell and MCP writes, identifiers other than Aadhaar, PAN and +91 mobiles, what a clause means or whether it applies, and sector rules. +- A sector-only duty, such as CERT-In's 6-hour report, goes in the assessment summary instead of a DPDP finding standing in for it. `AGENTS.md`, `/audit` and the three sector guides say so, and the guides say sector counsel has not reviewed them. + ## 0.3.1 (9 November 2026) - The README and disclaimer say it once and plainly: AI can make mistakes, and a licensed attorney should check every finding before you rely on it. diff --git a/README.md b/README.md index 5affb85..2b85c29 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ AI can make mistakes. Have a licensed attorney check every finding before you re | | Spreadsheet checklist | Hosted privacy platform | **Vigil** | |---|---|---|---| | Looks at your code, schemas and logs | No | Usually through integrations | **Yes. It runs in your repo.** | -| Cites the DPDP section or rule per finding | If someone typed it in | Varies | **Every finding. The schema rejects one without a clause.** | +| Cites the DPDP section or rule per finding | If someone typed it in | Varies | **Every finding. Validation rejects a missing clause, or a section the law doesn't have.** | | Knows what is in force on which date | No | Varies | **Phased commencement table, checked before a gap is called a breach** | | Where your data goes | Wherever the sheet lives | The vendor's cloud | **Your machine and your model provider. No Vigil server.** | | Output | Cells | Dashboards | **JSON, Markdown, SARIF, OSCAL, OCSF** | @@ -190,20 +190,21 @@ flowchart LR exp --> ocsf["OCSF: SIEM"] ``` -Skills are Markdown that tell Claude what the law requires and how to check for it. Everything Claude writes passes through two hooks, and everything it writes must match a JSON Schema. The agent can be wrong about the law. It cannot quietly write a finding without a clause, rewrite a breach log, or drop a file outside the workspace. +Skills are Markdown that tell Claude what the law requires and how to check for it. Everything Claude writes with its file tools passes through two hooks and must match a JSON Schema. The agent can be wrong about the law. Through those tools it cannot write a finding that cites a section the law doesn't have, rewrite a breach log, or drop a file outside the workspace. A shell command skips the hooks, which is why the shell stays behind a permission prompt. ## Guardrails | 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, path traversal out of the repo, and edits to the hooks, schemas and settings themselves | +| 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, edits to the hooks, schemas and settings themselves, and hook events it cannot read | | 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. | +| Clause numbers | The same two | A `clause` naming a section, rule or article its framework doesn't have, or another instrument entirely. Sub-clauses and `cross_reference` are not checked. | | 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. 24 tests, Node's built-in runner. | +| 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. Emails, dates of birth, card and account numbers and health data pass the hook and rest on the skill rules. | +| Tests | `npm test`, GitHub Actions | Regressions in all of the above. 27 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). +These guards catch an agent's mistakes in Claude Code's file tools. They are not a security boundary. A shell command through Bash is not intercepted, so `.claude/settings.json` pre-approves only the validate and test commands. The personal-data filter knows three formats, and a clause that passes is well formed, not legally checked. [SECURITY.md](SECURITY.md) lists every limit. 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. @@ -240,9 +241,9 @@ Last verified **2 November 2026**. Dates and clauses trace to the Gazette of Ind | DPDP Rules 2025 | All 23 rules, with their phased commencement dates (13 Nov 2025, 13 Nov 2026, 13 May 2027) | | EU GDPR | Principles, lawful bases, rights, controller and processor duties, breaches, DPIAs, transfers, Art. 83 penalties, recent CJEU and EDPB developments | | UK GDPR + DUAA 2025 | Recognised legitimate interests, Art. 22A-22D, the transfer "data protection test", complaints handling, PECR changes | -| Indian sector rules | CERT-In Directions 2022, RBI Cybersecurity Directions 2026, SEBI CSCRF, IRDAI guidelines, CCPA Dark Patterns Guidelines 2023 | +| Indian sector rules | CERT-In Directions 2022, RBI Cybersecurity Directions 2026, SEBI CSCRF, IRDAI guidelines, CCPA Dark Patterns Guidelines 2023, as context only. They appear in `cross_reference` and the sector guides, and nothing validates them. | -Plus 7 consent notice templates (including Hindi) in [`examples/consent-notices`](examples/consent-notices) and sector guides for [fintech](examples/sector-guides/fintech-banking.md), [healthtech](examples/sector-guides/healthtech-healthcare.md) and [edtech](examples/sector-guides/edtech-education.md). +Plus 7 consent notice templates (including Hindi) in [`examples/consent-notices`](examples/consent-notices) and sector guides for [fintech](examples/sector-guides/fintech-banking.md), [healthtech](examples/sector-guides/healthtech-healthcare.md) and [edtech](examples/sector-guides/edtech-education.md). Sector counsel has not reviewed the guides, and no test yet measures how well an audit finds sector-specific gaps. ## What's in the repo diff --git a/SECURITY.md b/SECURITY.md index d3c994f..e692b5f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,8 +15,41 @@ 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 and personal-data patterns catch common formats. They are a tripwire, not a scanner. +The hooks and the validator catch mistakes an agent makes through Claude Code's file tools. They are not a security boundary, and a file that passes them is well formed, not legally checked. + +### Write paths the guard never sees + +The write-guard runs on Write, Edit, MultiEdit and NotebookEdit. It does not see: + +- shell commands through the Bash or PowerShell tool (`>`, `sed -i`, `node -e`, `git checkout`) +- MCP tools that write files +- another harness, such as `run-headless.mjs --harness opencode` +- a person with an editor + +Claude Code's permission system is what stands in front of the shell. `.claude/settings.json` pre-approves only the validate and test commands, and `run-headless.mjs` runs with `--permission-mode acceptEdits`, which approves file edits but not shell commands. A session in auto mode, or started with `--dangerously-skip-permissions`, can write anything the shell can. For unattended runs, use a container, or make `.claude/scripts`, `.claude/schemas`, `.claude/settings.json` and both logs read-only at the OS level. + +`npm run validate -- --strict` in CI re-checks schemas and clause numbers however a file was written. It cannot tell that an old log line changed, so read log diffs in review. + +### Hook input + +The guard blocks an event it cannot parse, one with no `tool_name` or `tool_input`, and a file tool with no path. An event from a tool that writes no file, such as TodoWrite caught by a loose matcher, passes. The post-write validator reports an event it cannot parse instead of skipping it. + +### Personal data + +The guard blocks three formats in `company-profile/` and `assessments/`: Aadhaar numbers that pass the Verhoeff check, PAN, and +91 mobile numbers. Everything else gets through, including email addresses, dates of birth, card and bank account numbers, ABHA numbers and medical text. Some of those look exactly like data a profile or report is meant to hold, such as a DPO's email or a due date. Card and ABHA numbers are simply not covered yet. Treat the filter as a tripwire for the commonest Indian identifiers. It is not data-loss prevention. + +It also has no say over what the model reads. Files Claude Code opens go to your model provider. + +### Clause references + +The validator checks that each reference in a finding's `clause` names a section (1-44) or rule (1-23) for DPDP, or an article (1-99) for GDPR and UK GDPR, in the finding's own framework. "Section 999 of the Imaginary Act" fails. It does not check that a sub-clause such as Art. 32(9) exists, that the clause says what the finding claims, or that it applies to the organisation, and it never reads `cross_reference`. A licensed attorney still checks every finding. + +### Sector rules + +The finding schema knows three frameworks: DPDP, GDPR and UK GDPR. CERT-In, RBI, SEBI, IRDAI, health and education rules appear only as text, in `cross_reference`, the [sector guides](examples/sector-guides) and skill prose. Nothing validates them, sector counsel has not reviewed the guides, and no test measures whether an audit finds sector-specific gaps. A sector duty with no DPDP or GDPR clause behind it goes in the assessment summary, not `report.json`. + +### Platform + - `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. diff --git a/examples/sector-guides/edtech-education.md b/examples/sector-guides/edtech-education.md index df996dc..a7e0f29 100644 --- a/examples/sector-guides/edtech-education.md +++ b/examples/sector-guides/edtech-education.md @@ -1,5 +1,7 @@ # Sector Guide: Edtech and Education +> Background for an audit. Sector counsel has not reviewed this guide, and nothing in Vigil validates the sector instruments it names. A finding cites the DPDP or GDPR clause that carries the duty and names the sector rule in `cross_reference`. A duty with no such clause goes in the assessment summary. + ## Applicable frameworks | Framework | Why it applies | diff --git a/examples/sector-guides/fintech-banking.md b/examples/sector-guides/fintech-banking.md index f5f364d..add36e4 100644 --- a/examples/sector-guides/fintech-banking.md +++ b/examples/sector-guides/fintech-banking.md @@ -1,5 +1,7 @@ # Sector Guide: Fintech and Banking +> Background for an audit. Sector counsel has not reviewed this guide, and nothing in Vigil validates the sector instruments it names. A finding cites the DPDP or GDPR clause that carries the duty and names the sector rule in `cross_reference`. A duty with no such clause goes in the assessment summary. + ## Applicable frameworks | Framework | Why it applies | diff --git a/examples/sector-guides/healthtech-healthcare.md b/examples/sector-guides/healthtech-healthcare.md index 92cf857..9683cd4 100644 --- a/examples/sector-guides/healthtech-healthcare.md +++ b/examples/sector-guides/healthtech-healthcare.md @@ -1,5 +1,7 @@ # Sector Guide: Healthtech and Healthcare +> Background for an audit. Sector counsel has not reviewed this guide, and nothing in Vigil validates the sector instruments it names. A finding cites the DPDP or GDPR clause that carries the duty and names the sector rule in `cross_reference`. A duty with no such clause goes in the assessment summary. + ## Applicable frameworks | Framework | Why it applies |