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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/schemas/finding.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
37 changes: 36 additions & 1 deletion .claude/scripts/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}]`)) : []),
]);
}

// ---------------------------------------------------------------------------
Expand Down
43 changes: 40 additions & 3 deletions .claude/scripts/tests/guardrails.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
}

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
});

Expand Down Expand Up @@ -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');
Expand All @@ -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 });
});

Expand Down
5 changes: 4 additions & 1 deletion .claude/scripts/validate-on-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
31 changes: 18 additions & 13 deletions .claude/scripts/write-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand All @@ -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.');
}
Expand All @@ -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);
});
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading