From 9c2287d3bb879850009ec307d4a3cdfa2a79995e Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Wed, 11 Nov 2026 20:27:52 +0530 Subject: [PATCH 1/2] Fail closed on bad hook input and settle the Morvali Creek calls Carries Vigil's 11 November guardrail review over to Aranya and answers the calls the Morvali Creek run had to make on its own. - The write-guard blocks an event it cannot read, a file tool with no path, and NotebookEdit outside the layout. The post-write validator reports an unreadable event instead of skipping it. - A finding that names a numbered section, para, rule or schedule on an instrument-level catalog_id fails validation (Morvali run bug 3). - AGENTS.md gains the fallbacks and settles the run's open calls. - matter.json gains other_forums, model and aranya_version; flags accept kind "case"; matters/*/inputs/*.json is schema-checked. Guard, schema and settings edits were applied through the shell under maintainer direction, since the write-guard blocks agent edits there. Co-Authored-By: Claude Opus 5.5 --- .claude/schemas/flag.schema.json | 4 +-- .claude/schemas/layout.json | 1 + .claude/schemas/matter.schema.json | 11 ++++++-- .claude/scripts/lib.mjs | 9 +++++++ .claude/scripts/tests/guardrails.test.mjs | 30 +++++++++++++++++++++- .claude/scripts/validate-on-write.mjs | 5 +++- .claude/scripts/write-guard.mjs | 29 +++++++++++---------- .claude/settings.json | 2 +- .claude/skills/env-case-analysis/SKILL.md | 4 +-- .claude/skills/env-catalog/SKILL.md | 2 +- .claude/skills/hea-rea-calculator/SKILL.md | 2 +- AGENTS.md | 23 +++++++++++++++++ CHANGELOG.md | 19 ++++++++++++++ README.md | 2 +- SECURITY.md | 4 ++- 15 files changed, 121 insertions(+), 26 deletions(-) diff --git a/.claude/schemas/flag.schema.json b/.claude/schemas/flag.schema.json index ffb8d64..fc075ee 100644 --- a/.claude/schemas/flag.schema.json +++ b/.claude/schemas/flag.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Flagged section", - "description": "A user- or agent-raised flag against a specific section: a statutory provision, an EC/EIA condition, a judgment paragraph, or a report clause.", + "description": "A user- or agent-raised flag against a specific section: a statutory provision (kind provision), a judgment or its paragraph (kind case), or an EC/EIA condition or report clause (kind document).", "type": "object", "required": ["flag_id", "matter_id", "target", "concern", "raised_by", "status", "created"], "properties": { @@ -11,7 +11,7 @@ "type": "object", "required": ["kind"], "properties": { - "kind": { "type": "string", "enum": ["provision", "document"] }, + "kind": { "type": "string", "enum": ["provision", "case", "document"] }, "instrument": { "type": "string" }, "section": { "type": "string" }, "catalog_id": { "type": "string" }, diff --git a/.claude/schemas/layout.json b/.claude/schemas/layout.json index 78a8263..88ef600 100644 --- a/.claude/schemas/layout.json +++ b/.claude/schemas/layout.json @@ -7,6 +7,7 @@ { "pattern": "matters/*/flags.jsonl", "schema": "flag.schema.json", "append_only": true, "description": "Append-only log of flagged sections (provisions, EC conditions, report clauses)" }, { "pattern": "matters/*/findings/*.json", "schema": "finding.schema.json", "description": "Analysis findings, each citing a clause" }, { "pattern": "matters/*/calc/*.json", "schema": "calc-result.schema.json", "description": "REA/HEA calculator runs" }, + { "pattern": "matters/*/inputs/*.json", "schema": "injury-params.schema.json", "description": "Alternative calculator inputs, one file per labelled run" }, { "pattern": "matters/*/**", "description": "Chronology, analysis notes and argument drafts" }, { "pattern": "matters/.gitkeep", "description": "Keeps the empty directory in git" }, { "pattern": "catalog/**", "description": "Catalog source extracts and notes" }, diff --git a/.claude/schemas/matter.schema.json b/.claude/schemas/matter.schema.json index 5ce87bb..d70ed53 100644 --- a/.claude/schemas/matter.schema.json +++ b/.claude/schemas/matter.schema.json @@ -6,10 +6,17 @@ "properties": { "matter_id": { "type": "string", "minLength": 1 }, "title": { "type": "string", "minLength": 1 }, - "forum": { "type": "string", "enum": ["ngt", "high_court_writ", "supreme_court", "criminal", "appellate_authority", "scheduled_act_authority", "other", "undetermined"] }, + "forum": { "type": "string", "description": "Where the main relief lies", "enum": ["ngt", "high_court_writ", "supreme_court", "criminal", "appellate_authority", "scheduled_act_authority", "other", "undetermined"] }, + "other_forums": { + "type": "array", + "description": "Other forums the matter reaches, such as a writ for a point outside NGT Schedule I", + "items": { "type": "string", "enum": ["ngt", "high_court_writ", "supreme_court", "criminal", "appellate_authority", "scheduled_act_authority", "other"] } + }, "description": { "type": "string" }, "created": { "type": "string", "format": "date-time" }, - "law_as_at": { "type": "string", "format": "date" } + "law_as_at": { "type": "string", "format": "date" }, + "model": { "type": "string", "minLength": 1, "description": "The model that ran the analysis" }, + "aranya_version": { "type": "string", "minLength": 1, "description": "The version in package.json when the matter was opened" } }, "additionalProperties": true } diff --git a/.claude/scripts/lib.mjs b/.claude/scripts/lib.mjs index 79e1707..ffc3b99 100644 --- a/.claude/scripts/lib.mjs +++ b/.claude/scripts/lib.mjs @@ -173,12 +173,21 @@ export function checkFile(relPath, root = process.cwd(), layout = loadLayout(roo }); } +// A section, para, rule or schedule followed by its number: "s.15(3)", "para 7", "Schedule I", "Appendix IV". +// Up to three digits, so a year ("Rules 2016") is not read as a rule number. +// shortcut: a part named without a number, such as "General Condition", is not caught; widen the list if runs show it. +const NAMES_A_PART = /\b(?:ss?\.|(?:sections?|paras?|paragraphs?|rules?|regulations?|clauses?|schedules?|appendix|appendices|articles?|arts?|items?)\.?)\s*(?:\d{1,3}(?!\d)|[IVXL]+\b|\([a-z0-9]+\))/i; + // A finding must point at a catalog entry and repeat its source and state, so neither can be made up. function findingSourceErrors(finding, at, root) { const catalog = JSON.parse(readFileSync(join(root, 'catalog/provisions.json'), 'utf8')); const entry = Object.values(catalog).filter(Array.isArray).flat().find(e => e.id === finding.catalog_id); if (!entry) return [`${at}.catalog_id: "${finding.catalog_id}" is not in catalog/provisions.json`]; const errors = []; + // An instrument entry vouches for the instrument as a whole, not for a section nobody has catalogued. + if (catalog.instruments?.includes(entry) && NAMES_A_PART.test(finding.provision)) { + errors.push(`${at}.provision: "${finding.provision}" names part of ${entry.id}, which the catalog holds only as a whole instrument. Cite that part's own catalog entry, or raise a flag and ask a human to add one.`); + } if (finding.source_url !== entry.source_url) errors.push(`${at}.source_url: must match the catalog entry (${entry.source_url})`); if (finding.verification !== entry.verification) errors.push(`${at}.verification: must match the catalog entry (${entry.verification})`); return errors; diff --git a/.claude/scripts/tests/guardrails.test.mjs b/.claude/scripts/tests/guardrails.test.mjs index b7754f9..fa26795 100644 --- a/.claude/scripts/tests/guardrails.test.mjs +++ b/.claude/scripts/tests/guardrails.test.mjs @@ -12,7 +12,7 @@ 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', }); } @@ -44,6 +44,7 @@ test('layout matches data paths and rejects strays', () => { assert.equal(matchRule(layout, 'matters/m1/findings/F-0001.json').schema, 'finding.schema.json'); assert.equal(matchRule(layout, 'matters/m1/flags.jsonl').append_only, true); assert.equal(matchRule(layout, 'catalog/provisions.json').readonly, true); + assert.equal(matchRule(layout, 'matters/m1/inputs/run-2.json').schema, 'injury-params.schema.json'); assert.equal(matchRule(layout, 'src/app.js'), undefined); }); @@ -121,6 +122,22 @@ 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: 'catalog/provisions.json', new_source: '{}' } }), 2); + assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'matters/m1/flags.jsonl', new_source: '{"c":3}' } }), 2); + assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'docs/x.ipynb', new_source: 'x' } }), 0); + const { hooks } = JSON.parse(readFileSync(join(repo, '.claude/settings.json'), 'utf8')); + assert.match(hooks.PreToolUse[0].matcher, /\bNotebookEdit\b/); + 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'); @@ -144,6 +161,16 @@ test('a finding must point at a real catalog entry', () => { assert.match(check(noEntry), /missing required "catalog_id"/); assert.match(check({ ...realFinding(), source_url: 'https://indiankanoon.org/doc/1/' }), /source_url: must match/); assert.match(check({ ...realFinding(), verification: 'unverified' }), /verification: must match/); + + const catalog = JSON.parse(readFileSync(join(repo, 'catalog/provisions.json'), 'utf8')); + const water = catalog.instruments.find(e => e.id === 'water-1974'); + const onWater = provision => check({ ...realFinding(), catalog_id: water.id, source_url: water.source_url, verification: water.verification, provision }); + for (const p of ['s.999', 'Section 33A', 'para 7', 'Schedule I', 'Appendix IV', 'Rule 5(1)', 'clause (k)']) { + assert.match(onWater(p), /holds only as a whole instrument/, p); + } + for (const p of ['The Act as a whole', 'Water (Prevention and Control of Pollution) Act, 1974', 'Hazardous Waste Rules 2016', "the Act's 2010 amendment"]) { + assert.equal(onWater(p), '', p); + } rmSync(dir, { recursive: true }); }); @@ -166,6 +193,7 @@ test('validate-on-write exits 2 so the agent sees errors', () => { const r = run('validate-on-write.mjs', { cwd: dir, input: { tool_input: { file_path: 'matters/m1/findings/F-1.json' } } }); assert.equal(r.status, 2); assert.match(r.stderr, /not in catalog/); + 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 0fe2cde..7d3fe73 100644 --- a/.claude/scripts/write-guard.mjs +++ b/.claude/scripts/write-guard.mjs @@ -8,8 +8,9 @@ * 4. An agent recording a calculator result as expert_validated * 5. Content containing secret-shaped strings (API keys, passwords, tokens) * - * 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'; @@ -39,9 +40,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); @@ -56,9 +60,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)); @@ -80,7 +87,7 @@ 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'); // The file as it would be saved, parsed, so an Edit that swaps only the value or a \u escape is still caught. let status; try { status = JSON.parse(contentAfter(existsSync(abs) ? readFileSync(abs, 'utf8') : '', event.tool_name, input)).validation_status; } @@ -97,12 +104,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 7af2498..5096687 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/env-case-analysis/SKILL.md b/.claude/skills/env-case-analysis/SKILL.md index c9973c6..d92f314 100644 --- a/.claude/skills/env-case-analysis/SKILL.md +++ b/.claude/skills/env-case-analysis/SKILL.md @@ -20,10 +20,10 @@ Open and orient a matter. Decide the forum before anything else, map the applica ## Capabilities -1. **Create the matter** — write `matters/{id}/matter.json` (parties, `forum`, dates, `law_as_at`). +1. **Create the matter** — write `matters/{id}/matter.json` (parties, `forum`, any `other_forums`, dates, the catalog's `law_as_at`, `model`, `aranya_version`). 2. **Jurisdiction first** — ask `env-catalog` whether the harm arises under a Schedule I enactment. If not (e.g. pure Wild Life Act 1972 harm), say the NGT may lack jurisdiction and name the alternative forum. 3. **Issues and reliefs** — list the issues; map available reliefs (relief/compensation under `ngt-s15`, restitution of the environment, interim directions). -4. **Findings** — write each point to `matters/{id}/findings/*.json`, citing a provision and carrying its verification state. +4. **Findings** — write each point to `matters/{id}/findings/*.json`, citing a provision and carrying its verification state. A section with no catalog entry of its own gets a flag, even when its Act has one. 5. **Flagging** — append a user- or agent-raised flag to `matters/{id}/flags.jsonl` (append-only); link resolved flags to findings. ## Guardrails diff --git a/.claude/skills/env-catalog/SKILL.md b/.claude/skills/env-catalog/SKILL.md index 70d379d..3874028 100644 --- a/.claude/skills/env-catalog/SKILL.md +++ b/.claude/skills/env-catalog/SKILL.md @@ -20,7 +20,7 @@ Everything traces to an entry in `catalog/provisions.json`, each carrying a `sou ## Capabilities 1. **Lookup** — return a provision/doctrine/case by `id`, instrument, or section, with its summary, citation, `source_url` and `verification` state. -2. **Resolve a flag** — given a flag `target` of `kind: "provision"`, return the catalog node it points at. +2. **Resolve a flag** — given a flag `target` of `kind: "provision"` or `kind: "case"`, return the catalog node it points at, or say the catalog has none. 3. **Jurisdiction check** — a matter founds an NGT case only if a substantial environmental question arises out of a Schedule I enactment (`ngt-schedule-1`). The Wild Life (Protection) Act 1972 is not in Schedule I. 4. **Currency** — surface commencement and amendment flags (e.g. the 2023 amendments to the Forest and Biodiversity Acts; the EIA draft 2020 is not law). diff --git a/.claude/skills/hea-rea-calculator/SKILL.md b/.claude/skills/hea-rea-calculator/SKILL.md index 621c2a5..dacb8f3 100644 --- a/.claude/skills/hea-rea-calculator/SKILL.md +++ b/.claude/skills/hea-rea-calculator/SKILL.md @@ -21,7 +21,7 @@ Size compensatory restoration by matching the discounted ecological-service loss ## Capabilities 1. **Gather inputs** per `injury-params.schema.json`: `method` (`HEA` or `REA`), `injured_area` in hectares, `baseline_service` and `injured_service` (0..1), `injury_years`, `recovery_years`, `restoration_gain` (0..1), `restoration_years` and `discount_rate` as a fraction (0.03 means 3%). Optional: `restoration_lag` (years from the start of the injury until restoration delivers service), `restoration_unit_cost` per hectare, `currency`. If any required input is missing, ask for it. Never assume one. -2. **Run** `npm run calc -- --out matters/{id}/calc/{run}.json`. The calculator validates the inputs and refuses bad ones; show the user its message rather than adjusting the numbers yourself. +2. **Run** `npm run calc -- --out matters/{id}/calc/{run}.json`. The calculator validates the inputs and refuses bad ones; show the user its message rather than adjusting the numbers yourself. When the record contradicts the inputs you were given, run them as given first, then each labelled alternative from its own file in `matters/{id}/inputs/`. 3. **Read the output** — debit and credit in discounted service-hectare-years, the restoration area needed, the scaling ratio, and an optional cost when an expert unit cost is supplied. 4. **Sensitivity**: always present `range` first (area, and cost if there is one). It moves each expert input by `sensitivity_pct` one at a time, so it is not a worst case; say which rows move the result most. diff --git a/AGENTS.md b/AGENTS.md index c3d2bda..aa199cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,29 @@ REA/HEA has no documented NGT or Indian precedent. Any calculator or argument ou Read each catalog entry's `note` as well as its `verification` state: several list points still to confirm, such as case pinpoints. Confirm against primary sources (Gazette, indiacode.nic.in, greentribunal.gov.in, the SCC reports) before relying on them. +## Recording a matter + +- `matter.json` copies the catalog's `law_as_at`, and records the `model` that ran and the `aranya_version` from `package.json`, so a reader can trace the run. +- `forum` is where the main relief lies. Name any other forum in `other_forums`, such as a High Court writ for a Wild Life Act point the NGT cannot hear, and explain the split in `description`. +- A finding names its main flag in `flag_id`. Any other flag lists the finding in its `linked_findings`. +- A flag on a judgment uses `target.kind: "case"`, with the case's `catalog_id` if it has one and the paragraph in `locator`. +- Name each risk in a matter and what decides it. Give no view on the outcome. + +## When something is missing or blocked + +A run should end with a partial result and a list of what it skipped. It should not stall, and it should not guess. + +- **Nobody can answer a question** (headless run, side session, no reply): take the answer from the record 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 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 under `matters/{id}/`, and report the block. +- **A skill has no mode for the step**: use its legal-basis section as the checklist and cite only catalog entries. +- **The only evidence is an absence**: if a document or step is merely missing from the record you were given, write "not found in the record supplied" and rate it medium at most. Go higher when a document itself shows the gap, such as a Form 1 answer that the surveys contradict. +- **The catalog holds only the instrument**: a section, para, rule or schedule without its own entry gets a flag, not a finding. The validator rejects a finding that names such a part on an instrument-level `catalog_id`. A note may discuss the point if it names the flag and marks the law as unconfirmed. A court-facing document may not rely on it. +- **The record contradicts the calculator inputs you were given**: run the inputs as given first. Then run labelled alternatives, each from its own file in `matters/{id}/inputs/`, and name the record document behind each change. `calc/` holds results only. +- **A filing date comes before expert validation**: plead the restoration scale as a figure to be established by expert evidence. Never mark a result validated to meet a date. +- **Your instructions conflict with this file**: this file wins. State each refusal in the output. +- **Another installed skill assumes Word output or a house style**: follow its workflow inside the Markdown note. + ## Disclaimer AI can make mistakes. Aranya is tooling, not legal advice. Have a licensed attorney check everything before you rely on it, and a qualified ecological expert check any damage figure. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc1712..53771fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +Changes carried over from Vigil's 11 November guardrail review, and answers to the calls the Morvali Creek run had to make on its own. + +### 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 anywhere before, the read-only catalog included. +- The post-write validator reports a hook event it cannot read instead of skipping it. +- A finding that names a section, para, rule or schedule on an instrument-level `catalog_id` fails validation. Water Act "s.999" on `water-1974` passed before. The Morvali Creek run reported this as bug 3. +- Earlier, from #8: `package.json` left the writable layout, both hooks resolve a path as the disk spells it, the expert gate parses the file as it would be saved, and the calculator's `--out` writes only a new calc file. + +### Operating rules +- `AGENTS.md` says what to do when nobody can answer, a schema has no value, a hook blocks a write, a skill has no mode for the step, or the only evidence is an absence. It also settles the Morvali Creek run's open calls: an uncatalogued section gets a flag, alternative calculator inputs go in `matters/{id}/inputs/`, a filing date never forces expert validation, and `AGENTS.md` wins over conflicting instructions. + +### Schemas +- `matter.json` gains `other_forums`, `model` and `aranya_version`. `forum` is where the main relief lies. +- A flag's `target.kind` accepts `case`. +- `matters/*/inputs/*.json` is validated against `injury-params.schema.json`. + ## 0.3.2 (2026-11-09) - The README says where Aranya comes from: the book *Evolution of Environmental Jurisprudence in India: The National Green Tribunal* (Cambridge University Press, 2025), and the aim of making REA/HEA compensation analysis free and open to anyone. diff --git a/README.md b/README.md index 1cbdd23..8b83914 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Skills are Markdown that tell Claude what the law provides and how to apply it. | Novel methodology | `calc-result.schema.json` | `methodology_status` can only be `novel_persuasive`; never `established`. This covers results, not Markdown drafts. | | Expert gate | `write-guard.mjs`, `npm run validate` | An agent recording `expert_validated`, or anyone doing so without `validated_by` and `validated_at`. Nothing confirms the name is genuine, so review the diff. | | Catalog-only citations | `write-guard.mjs`, `npm run validate` | A finding whose `catalog_id` is not in the catalog or whose source or state differs from it, and any agent edit to the catalog. It can't check that a catalog entry is itself correct, or what a Markdown draft cites. | -| Tests | `npm test`, GitHub Actions | Regressions in all of the above. 28 tests, Node's built-in runner. | +| Tests | `npm test`, GitHub Actions | Regressions in all of the above. 30 tests, Node's built-in runner. | ## Methodology diff --git a/SECURITY.md b/SECURITY.md index 77e71dd..82eba7d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,10 +14,12 @@ 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. The calculator's `--out` checks itself: it writes only a new `matters//calc/.json` and never overwrites one. Keep Bash permissions narrow (`.claude/settings.json` allows only the validate, test and calculator commands by default). +- Hooks guard Claude Code's Write, Edit, MultiEdit and NotebookEdit tools. A shell command run through the Bash tool is not intercepted. The calculator's `--out` checks itself: it writes only a new `matters//calc/.json` and never overwrites one. Keep Bash permissions narrow (`.claude/settings.json` allows only the validate, test and calculator commands by default). - `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. +- The guard blocks a hook 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. +- A finding on an instrument-level catalog entry fails when its `provision` names a numbered section, para, rule, schedule or appendix ("s.33A", "Appendix IV"). A part named without a number, such as "General Condition", still passes, and so does a finding on a section's own entry that also names a neighbouring section. - Validation checks that a finding matches a catalog entry. It can't check that the entry is correct, that a `validated_by` name is genuine, or what a Markdown draft claims. A person has to review those. - The secret patterns catch common key formats. They are a tripwire, not a scanner. From 918a2e677c9804e9e68df211999dc59aec521297 Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Thu, 12 Nov 2026 01:06:20 +0530 Subject: [PATCH 2/2] Let an instrument entry vouch for the clauses its summary quotes The instrument-level check rejected any numbered part cited on an instrument entry. cpcb-ec-2019 is catalogued as an instrument, but its summary quotes clauses 1.3.2, 1.5.1 and the rest, so a correct finding citing "clause 1.5.1" failed validation. The check now passes a part the entry's own summary names as a whole reference, and still rejects "s.999" on water-1974, which has no summary. Known limit, in the code and SECURITY.md: a number the summary uses for something else (the scale factor 1.5) passes as a clause. Applied through the shell under maintainer direction, since the write-guard blocks agent edits to .claude/scripts. Co-Authored-By: Claude Opus 5.5 --- .claude/scripts/lib.mjs | 21 +++++++++++++++------ .claude/scripts/tests/guardrails.test.mjs | 6 ++++++ AGENTS.md | 2 +- CHANGELOG.md | 2 +- SECURITY.md | 2 +- 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.claude/scripts/lib.mjs b/.claude/scripts/lib.mjs index ffc3b99..cdb4cef 100644 --- a/.claude/scripts/lib.mjs +++ b/.claude/scripts/lib.mjs @@ -173,10 +173,18 @@ export function checkFile(relPath, root = process.cwd(), layout = loadLayout(roo }); } -// A section, para, rule or schedule followed by its number: "s.15(3)", "para 7", "Schedule I", "Appendix IV". -// Up to three digits, so a year ("Rules 2016") is not read as a rule number. -// shortcut: a part named without a number, such as "General Condition", is not caught; widen the list if runs show it. -const NAMES_A_PART = /\b(?:ss?\.|(?:sections?|paras?|paragraphs?|rules?|regulations?|clauses?|schedules?|appendix|appendices|articles?|arts?|items?)\.?)\s*(?:\d{1,3}(?!\d)|[IVXL]+\b|\([a-z0-9]+\))/i; +// A section, para, rule or schedule followed by its number: "s.15(3)", "para 7", "Schedule I", "clause 1.5.1". +// Up to three digits, so a year ("Rules 2016") is not read as a rule number. The number is captured. +// shortcut: a part named without a number, such as "General Condition", is not caught, and a number the entry's summary +// uses for something else (the CPCB scale factor 1.5) passes as a clause; widen the list or tighten the match if runs show it. +const NAMES_A_PART = /\b(?:ss?\.|(?:sections?|paras?|paragraphs?|rules?|regulations?|clauses?|schedules?|appendix|appendices|articles?|arts?|items?)\.?)\s*(\d{1,3}(?:\.\d{1,3})*(?!\d)|[IVXL]+\b|\([a-z0-9]+\))/i; +// True when `text` names `ref` as a whole reference: "1.5.1" in "(1.5.1)", not inside "11.5.1" or "1.5.12". +const quotes = (text = '', ref) => { + for (let i = text.indexOf(ref); i !== -1; i = text.indexOf(ref, i + 1)) { + if (!/[\w.]/.test(text[i - 1] ?? ' ') && !/^(\w|\.\d)/.test(text.slice(i + ref.length, i + ref.length + 2))) return true; + } + return false; +}; // A finding must point at a catalog entry and repeat its source and state, so neither can be made up. function findingSourceErrors(finding, at, root) { @@ -184,8 +192,9 @@ function findingSourceErrors(finding, at, root) { const entry = Object.values(catalog).filter(Array.isArray).flat().find(e => e.id === finding.catalog_id); if (!entry) return [`${at}.catalog_id: "${finding.catalog_id}" is not in catalog/provisions.json`]; const errors = []; - // An instrument entry vouches for the instrument as a whole, not for a section nobody has catalogued. - if (catalog.instruments?.includes(entry) && NAMES_A_PART.test(finding.provision)) { + // An instrument entry vouches for the instrument as a whole and for the parts its own summary quotes, nothing more. + const part = catalog.instruments?.includes(entry) && finding.provision.match(NAMES_A_PART); + if (part && !quotes(entry.summary, part[1])) { errors.push(`${at}.provision: "${finding.provision}" names part of ${entry.id}, which the catalog holds only as a whole instrument. Cite that part's own catalog entry, or raise a flag and ask a human to add one.`); } if (finding.source_url !== entry.source_url) errors.push(`${at}.source_url: must match the catalog entry (${entry.source_url})`); diff --git a/.claude/scripts/tests/guardrails.test.mjs b/.claude/scripts/tests/guardrails.test.mjs index fa26795..d019bd6 100644 --- a/.claude/scripts/tests/guardrails.test.mjs +++ b/.claude/scripts/tests/guardrails.test.mjs @@ -171,6 +171,12 @@ test('a finding must point at a real catalog entry', () => { for (const p of ['The Act as a whole', 'Water (Prevention and Control of Pollution) Act, 1974', 'Hazardous Waste Rules 2016', "the Act's 2010 amendment"]) { assert.equal(onWater(p), '', p); } + + // An instrument entry that quotes its own clauses vouches for those clauses only. + const cpcb = catalog.instruments.find(e => e.id === 'cpcb-ec-2019'); + const onCpcb = provision => check({ ...realFinding(), catalog_id: cpcb.id, source_url: cpcb.source_url, verification: cpcb.verification, provision }); + for (const p of ['clause 1.5.1', 'clauses 1.3.2 and 1.5.2', 'Chapter I, instance (f)', 'item (f)']) assert.equal(onCpcb(p), '', p); + for (const p of ['clause 9.9.9', 'para 2.7', 'clause 1.5.13']) assert.match(onCpcb(p), /holds only as a whole instrument/, p); rmSync(dir, { recursive: true }); }); diff --git a/AGENTS.md b/AGENTS.md index aa199cc..df8a46c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ A run should end with a partial result and a list of what it skipped. It should - **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 under `matters/{id}/`, and report the block. - **A skill has no mode for the step**: use its legal-basis section as the checklist and cite only catalog entries. - **The only evidence is an absence**: if a document or step is merely missing from the record you were given, write "not found in the record supplied" and rate it medium at most. Go higher when a document itself shows the gap, such as a Form 1 answer that the surveys contradict. -- **The catalog holds only the instrument**: a section, para, rule or schedule without its own entry gets a flag, not a finding. The validator rejects a finding that names such a part on an instrument-level `catalog_id`. A note may discuss the point if it names the flag and marks the law as unconfirmed. A court-facing document may not rely on it. +- **The catalog holds only the instrument**: a section, para, rule or schedule without its own entry gets a flag, not a finding. The validator rejects a finding that names such a part on an instrument-level `catalog_id`, unless the instrument's entry quotes that part itself, as `cpcb-ec-2019` does for its clauses. A note may discuss the point if it names the flag and marks the law as unconfirmed. A court-facing document may not rely on it. - **The record contradicts the calculator inputs you were given**: run the inputs as given first. Then run labelled alternatives, each from its own file in `matters/{id}/inputs/`, and name the record document behind each change. `calc/` holds results only. - **A filing date comes before expert validation**: plead the restoration scale as a figure to be established by expert evidence. Never mark a result validated to meet a date. - **Your instructions conflict with this file**: this file wins. State each refusal in the output. diff --git a/CHANGELOG.md b/CHANGELOG.md index 53771fe..f5cec4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Changes carried over from Vigil's 11 November guardrail review, and answers to t - 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 anywhere before, the read-only catalog included. - The post-write validator reports a hook event it cannot read instead of skipping it. -- A finding that names a section, para, rule or schedule on an instrument-level `catalog_id` fails validation. Water Act "s.999" on `water-1974` passed before. The Morvali Creek run reported this as bug 3. +- A finding that names a section, para, rule or schedule on an instrument-level `catalog_id` fails validation. Water Act "s.999" on `water-1974` passed before. The Morvali Creek run reported this as bug 3. An instrument entry still vouches for the parts its own summary quotes, such as the CPCB methodology's clauses. - Earlier, from #8: `package.json` left the writable layout, both hooks resolve a path as the disk spells it, the expert gate parses the file as it would be saved, and the calculator's `--out` writes only a new calc file. ### Operating rules diff --git a/SECURITY.md b/SECURITY.md index 82eba7d..5fb8375 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ Include what you found, how to reproduce it, and the impact. - 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. - The guard blocks a hook 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. -- A finding on an instrument-level catalog entry fails when its `provision` names a numbered section, para, rule, schedule or appendix ("s.33A", "Appendix IV"). A part named without a number, such as "General Condition", still passes, and so does a finding on a section's own entry that also names a neighbouring section. +- A finding on an instrument-level catalog entry fails when its `provision` names a numbered section, para, rule, schedule or appendix ("s.33A", "Appendix IV") that the entry's own summary does not quote. A part named without a number, such as "General Condition", still passes. So does a number the summary uses for something else (the CPCB scale factor 1.5 reads as a clause 1.5), and a finding on a section's own entry that also names a neighbouring section. - Validation checks that a finding matches a catalog entry. It can't check that the entry is correct, that a `validated_by` name is genuine, or what a Markdown draft claims. A person has to review those. - The secret patterns catch common key formats. They are a tripwire, not a scanner.