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
21 changes: 21 additions & 0 deletions .changeset/wild-pugs-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@cosyte/transform": patch
---

No runtime impact: the repository's own PHI commit-gate read 31 of its 102 tracked files, and it now reads 101 of its 103 and refuses when it cannot account for the rest.

Both of the gate's enumerating routes covered `test/fixtures/` and `src/` only. Seventy-one tracked files were read by neither of them, twenty-seven of those under `test/`, and eight of those carried inline HL7 v2 patient-identification segments with names, dates of birth and medical record numbers in them. The sharper half is that `test/fixtures/` has never existed in this repository, on any commit: the walk's existence check returned on its first line for that root on every run the gate has ever made, and every one of those runs printed a clean result and exited zero. An unopened root and a clean one are indistinguishable from the outside.

Neither a file count nor an existence check detects that, and both were considered and rejected: a count counts the roots that did exist, and refusing a missing root leaves an emptied one reporting clean. So the walk now covers every tracked directory plus the files at the repository root, and reconciles what it actually opened against the list of files version control actually carries. A tracked path the walk did not open now refuses, naming each one. A declared root that is a symbolic link, dangling or not, refuses too: the existence check follows a link, so a dangling root read as absent and the whole corpus went unscanned while the run reported success.

Enumerating more files buys the cross-cutting social security number and email checks and nothing else, and measured on this repository those two find nothing at all in the eight fixture files: they carry no dashed social security number and no email address. What they carry is names, dates of birth, record numbers, one undashed social security number, a street address and two telephone numbers. So a structured pass ships alongside the existing one, never in place of it, reading HL7 v2 patient, next-of-kin, guarantor and insurance segments field by field and component by component. It finds segments inline rather than assuming a file is a message, because this package ships no standalone message file at all: every fixture is a string literal inside TypeScript.

Every value the structured pass reports is checked against the reviewed synthetic-fixture declaration list, which gains entries for this repository's placeholders. Each was read by hand first and each is named in that file rather than removed, because removing them would destroy the evidence the audit happened. Seventy files are newly read, sixty-nine of which already existed and were read by hand; nothing patient-identifying was found in any of them. The two that stay unread are the vendored compressed archives, whose stored bytes are not the text they carry, and each is declared by its exact path.

One detection is subtracted and it is the only one. The package manifest carries the publisher's own contact address, which the email check cannot tell from a patient's, so scanning the manifest at all required declaring that one address. It is declared with a path as well as an address, so the same address in any other file still reports and any other address in the manifest still reports, and the cost is that naming the manifest directly no longer reports it. Every other outcome is unchanged or newly caught: a hundred and sixty-eight before-and-after cases, fourteen path shapes by four payload shapes by all three ways the gate can be invoked, with thirty-seven that reported before still reporting, seventy-four newly caught and fifty-six unchanged and quiet.

Two further blind spots were found while grading this change and were closed rather than written down, because both reported a clean result over content a reader would expect to be caught: a person's name spelled with any character outside the plain English alphabet, and a whole message pasted into a single string with its separators written as escapes. A wrong field position was corrected too, where an insurance segment's relationship code was reported as a telephone number.

The way the gate's own limits are written changed with them, and that is the more useful half. Grading three times showed that a list of what a scanner does not catch cannot be kept true, because every clause of every segment of the standard would have to appear on it, and both versions of that list were measured incomplete in the direction that flatters the gate. The scanner now states the opposite way round: exactly which fields it reads, with anything not named there not checked. That claim can be checked against the code, and the suite now checks it from both sides in a single run, so a field added to the reader without being added to the statement, or dropped from the reader while the statement keeps promising it, both turn the build red. It also records which of those field numbers are corroborated by something in this repository and which are not, because that is where the remaining risk sits.

Several limits stay disclosed rather than quietly closed, including that the reconciliation compares path names and not the bytes stored at them, and the complete list of what the gate still cannot see is written at the top of the scanner itself.
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ jobs:
# Universal gates from the shared pipeline (typecheck, lint, format, test, coverage, build, attw,
# dual ESM/CJS smoke, actionlint) on the standard Node matrix. Repo-specific extra jobs (e.g. an
# examples smoke, a starter-kit build) are added on top of this caller, never instead of it.
# The template ships a PHI scanner (scripts/phi-scan.ts), so the shared pipeline's optional
# PHI-scan step is switched on. NOTE: the scaffolded scanner is a STARTER: it detects only
# cross-cutting SSN/email shapes until you add structured, field-level detection for this
# standard's PHI (see the STARTER banner in scripts/phi-scan.ts).
# The shared pipeline's optional PHI-scan step is switched on, and what it runs is the all-mode
# walk over this repo's whole TRACKED corpus, reconciled against `git ls-files` so a root that is
# missing, emptied or replaced refuses rather than reporting clean. It runs the cross-cutting
# SSN/email floor AND an HL7 v2 structured pass over a NAMED SET of PID/NK1/GT1/IN1 fields. The
# banner at the top of scripts/phi-scan.ts enumerates exactly which fields those are and states
# that anything not named is not checked; read it as the whole of the coverage, because two
# refuter passes measured the opposite shape (a list of what is NOT covered) incomplete.
ci:
uses: cosyte/.github/.github/workflows/ci.yml@main
with:
Expand Down
63 changes: 32 additions & 31 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,38 +172,39 @@ Full narrative, every measurement: `documentation/agent-notes.md#the-attw-guardr

### The PHI scanner

Full narrative, every measurement and both refuter passes:
`documentation/agent-notes.md#the-phi-scanner-guardrail-in-full`.
Measurements, the grid, the refuters, and the `--staged` ARGV traps (`--diff-filter` keeps `T`/`U`,
`--no-renames`, STATUS not mode, re-measure the stride):
`documentation/agent-notes.md#the-phi-scanner-guardrail-in-full`. **No counts.**

- **▶ THE CLAIM IS EXACTLY: the scanner refuses (exit 2) every entry it ENUMERATES, and every path
NAMED DIRECTLY, that is not a regular file.** "It follows nothing" is the looser wording and **two
separate refuter passes measured it FALSE.** Do not tighten the sentence back up.
- **Do not "fix" the link blindness by following the link**: it reads bytes the enumeration does not
control (outside the repo, a loop, a device, a blocking FIFO), and git does not carry them anyway.
- **`lstat` answers for the FINAL COMPONENT ONLY.** A named path with a **symlinked ancestor**, or a
plain absolute/`../` argument, is still followed. **Pre-existing, disclosed, deliberately NOT
closed.** If you touch `buildTargetsForPaths`, **re-measure the sentence, do not re-assert it**.
- **▶ THE ONE-LETTER TRAP: `--diff-filter` MUST KEEP `T`.** Replacing a tracked file with a link is
neither an add nor a modify: under `AM` the record dies before any mode is read and a mode-`120000`
blob passes **green**. Keep `U` too (refused: no stage-0 entry), and keep `--no-renames`, which is
what makes a staged rename arrive as a single-path `A`. Key on the **STATUS, not the mode**.
- **Adding any argument to that `git diff --cached` ARGV means re-measuring the record stride**:
`--find-copies-harder` re-enables two-path records even placed _before_ `--no-renames`.
- **A refusal never echoes the link target**: that is working-tree text and can itself carry PHI.
Name the repo-relative path plus a token from the closed `entryKind`/`gitModeKind` sets, nothing
else. **This applies to the prose too**: write the dangerous target as a _shape_, never an example.
- **The walk has NO extension scope of its own** (it skips regular `*.md` and takes everything else);
`src/**.ts` is the **`--staged`** route's boundary. **Do not describe the two as one rule.**
- **Exit `2` means every failure to complete; exit `1` means HITS FOUND.** An uncaught throw used to
land on node's `1`, so a caller read a gate that never ran as one that ran and fired.
- **Three residuals are disclosed, NOT closed**: (1) an empty enumeration reports clean; (2) the
ancestor-component / absolute / `../` reads above; (3) a scan **root's own path** staged as a
non-regular entry is outside the `--staged` route's prefix scope. **Do not silently re-close any,
and do not let a future edit read as though they were.**
- **Test against throwaway git repos under `os.tmpdir()`**: the scanner roots everything at
`process.cwd()`, so **never write a link or a violator into this corpus** to test it.
- **The enumerate-then-read race is deliberately still open**, unreachable by scope today, _until a
walk root widens_, which reintroduces it verbatim.
- **▶ THE CLAIM IS EXACTLY: it refuses (exit 2) every entry it ENUMERATES, and every path NAMED
DIRECTLY, that is not a regular file.** "Follows nothing" is looser and **two refuter passes
measured it FALSE**; do not tighten it back, and never close it by following. **`lstat` answers
for the FINAL COMPONENT ONLY**: touch `buildTargetsForPaths` and **re-measure, never re-assert**.
**A refusal never echoes the link target**, and that binds the prose: a _shape_, never an example.
- **▶ THERE ARE THREE ROUTES, NOT TWO** (`all`, `--staged`, `<path>`), all running the content
passes. **Enumerate all three before calling anything additive.**
- **▶ SCOPE IS THE TRACKED CORPUS, RECONCILED AGAINST `git ls-files` EVERY RUN**, because
**the `fixtures` root HAD NEVER EXISTED ON ANY COMMIT** and went unopened on every run ever made while
the run printed clean. **A count cannot detect that**, nor can an existence check: an EMPTIED root
opens nothing. **Roots stay DISJOINT** or nested files report twice. The `*.md` walk skip is
gone (additive); `src/**.ts` was the **`--staged`** bound, now widened. **Not one rule.**
- **▶ WIDEN BY UNION AND PROVE THE GRID: every base `1` still `1`.** One cell is not:
**`phi-scan package.json` on the npm publisher mailbox**, declared with `EMAIL` (a **path AND an
address**). **Every allow-list entry is ROUTE-BLIND** and clears on `--staged`; every tag but
`EMAIL` is FILE-blind. **Named, never scrubbed.**
- **An exemption is a LITERAL PATH, never a predicate, and reaches the ALL route only**: the vendored
gzip tarballs are the whole list, and `<path>` still reads them.
- **Exit `2` is every failure to complete; `1` is HITS FOUND. A regular-file root is `2` HERE,
derived from this contract; siblings differ, never port one.** A non-directory root refuses first,
because **`existsSync` FOLLOWS**: a dangling one printed clean over an off-disk corpus. An
**absent** root is fine.
- **Enumerating buys the SSN/email floor and NOTHING else**, so the HL7 v2 pass ships **in addition
to** it, never instead: the floor finds **zero** in this repo's `PID|` fixtures. **No standalone
`.hl7` ships: every message is a `.ts` literal.**
- **Four residuals are disclosed, NOT closed**, named in the notes; the reconciliation is **path
sets, not bytes**; **the enumerate-then-read race precondition HAPPENED.**
- **Throwaway repos under `os.tmpdir()`; never write a violator here. The scanner's own test file is
IN the corpus**: payloads assemble at runtime, never as literals.

### The agent-instruction contract gate

Expand Down
Loading
Loading