Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3be1c2e
Add hunter-local-ingest real-client harness (self-check green)
SamGu-NRX Oct 11, 2026
04e654c
Unify sink protocol; label selfcheck fake FAKE_HUNTER; test runner ag…
SamGu-NRX Oct 11, 2026
22e3fe7
Merge alpha: bring #234's skills and PR template onto the harness branch
SamGu-NRX Oct 12, 2026
f666696
fix(packaging): ship frozen traces as package data for non-editable i…
SamGu-NRX Oct 12, 2026
13acf5e
test(packaging): prove the shipped artifact - wheel, venv, installed …
SamGu-NRX Oct 12, 2026
5cf0726
test(receipts): bind seed to fixture bytes, pin receipt contract; doc…
SamGu-NRX Oct 12, 2026
56800ed
Merge install child: ship traces as package data; prove installed CLIs
SamGu-NRX Oct 12, 2026
8bcdcb0
Add artifact-privacy seam: screen_artifact + redact_command
SamGu-NRX Oct 12, 2026
9a73d2c
Harden receipt privacy: recursive validation, post-summary screening,…
SamGu-NRX Oct 12, 2026
1df1f62
Merge privacy child: recursive observation validation + post-summary …
SamGu-NRX Oct 12, 2026
0d65ab6
fix: load the frozen trace package-relative; move it into the package
SamGu-NRX Oct 12, 2026
372d78b
feat: add truncated-response, expired-URL, not-found, retry_finalize …
SamGu-NRX Oct 12, 2026
ce34485
Merge privacy hardening tip 9a73d2c into the faults branch
SamGu-NRX Oct 12, 2026
3a81f6b
feat: decisive fault rounds and a real restart control in the selfcheck
SamGu-NRX Oct 12, 2026
75ab113
test: restart control, manifest fingerprint, and mutation controls
SamGu-NRX Oct 12, 2026
594af3c
docs(readme): receipt integrity is tamper detection, not immutability
SamGu-NRX Oct 12, 2026
71ed240
test(privacy): failing-first controls for the four reviewed counterex…
SamGu-NRX Oct 12, 2026
c2cca12
test(privacy): guard the binding fixture against the pre-CE1 module
SamGu-NRX Oct 12, 2026
3bd71c6
fix: client transport obeys the published capture-API contract
SamGu-NRX Oct 12, 2026
341db7c
fix: lifecycle scenarios obey the published capture contract
SamGu-NRX Oct 12, 2026
23d5fc9
fix(hunter-local-ingest): build receipts from typed schemas and match…
SamGu-NRX Oct 12, 2026
bf3bee9
test(hunter-local-ingest): add receipts from the installed wheel at 2…
SamGu-NRX Oct 12, 2026
9f0f83c
checkpoint(privacy): CE1 observation validation fixes; CE2-4 pending
SamGu-NRX Oct 12, 2026
688f1f8
fix(privacy): close the four reviewed counterexamples (CE2-CE4)
SamGu-NRX Oct 12, 2026
9feda16
fix: fixture plans match the published packet and create contract
SamGu-NRX Oct 12, 2026
63b06be
fix: control rounds create their own captures; malformed plan JSON ex…
SamGu-NRX Oct 12, 2026
acf7ebc
fix: runner diffs real-run receipts against the frozen trace
SamGu-NRX Oct 12, 2026
0f58b11
fix: name the unreachable https-proxy fault arm as BLOCKED
SamGu-NRX Oct 12, 2026
69a0d4d
fix(hunter-local-ingest): close five review findings at bf3bee9
SamGu-NRX Oct 12, 2026
2848ffe
Merge privacy CE tip 688f1f8d into the harness branch
SamGu-NRX Oct 12, 2026
92c0dee
Merge faults tip 75ab1130 into the harness branch
SamGu-NRX Oct 12, 2026
5f59edd
Merge install README tip 594af3cd into the harness branch
SamGu-NRX Oct 12, 2026
5c367ad
Merge protocol Phase-1 tip 0f58b116 into the harness branch
SamGu-NRX Oct 12, 2026
66763d6
fix: wire the runner to the merged tree and add the credential flag
SamGu-NRX Oct 12, 2026
f25f279
Merge protocol Phase-2 wiring tip 66763d6e into the harness branch
SamGu-NRX Oct 12, 2026
eeb22c4
fix(hunter-local-ingest): close four third-review findings on top of …
SamGu-NRX Oct 12, 2026
1511668
fix(hunter-local-ingest): token from a named env, blocked authenticat…
SamGu-NRX Oct 12, 2026
9f12581
test(hunter-local-ingest): add receipts from the installed wheel at 1…
SamGu-NRX Oct 12, 2026
84ed64a
fix(hunter-local-ingest): state the settled credential policy in new …
SamGu-NRX Oct 12, 2026
aa10463
fix(hunter-local-ingest): judge each commit batch, whole-token bearer…
SamGu-NRX Oct 12, 2026
2bafcb0
test(hunter-local-ingest): add focused receipts from the installed wh…
SamGu-NRX Oct 12, 2026
f326a6c
docs(hunter-local-ingest): state the one Swift Int compatibility limit
SamGu-NRX Oct 12, 2026
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
6 changes: 6 additions & 0 deletions tools/hunter-local-ingest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
runs/
build/
*.egg-info/
__pycache__/
*.pyc
.venv/
187 changes: 187 additions & 0 deletions tools/hunter-local-ingest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# hunter-local-ingest

Runs the published HouseScanKit upload lifecycle against one capture-API
endpoint and records what happened. It creates a capture, registers and
uploads files, commits them, finalizes with a packet, follows events to a
terminal status and reads the result, then runs controls that tamper with
uploads, replay a create and read a capture that does not exist. Every check
ends PASS, FAIL, BLOCKED or NOT_RUN and names how it ran.

The request bodies, response types, retry rules and header handling come from
`ios/HouseScanKit/Sources/HouseScanKit` at alpha `263827ef`
(`Upload/CaptureAPI.swift`, `Upload/CaptureUploader.swift`,
`Upload/ScopedCaptureHTTP.swift`, `CaptureResult/CaptureResult.swift`,
`Processing/FixtureCaptureHTTP.swift`). No backend code, schema or test is
copied here.

## Install and run

```bash
python -m venv .venv && .venv/bin/pip install -e '.[dev]' # development
python -m venv .venv && .venv/bin/pip install . # what a user gets

hunter-gen-plan --seed 1 --out plan.json # or: python fixtures/gen_synthetic_packet.py
hunter-local-ingest --endpoint http://127.0.0.1:8000/v1 --plan-file plan.json \
--backend-mode REAL_API --backend-ref <sha> --out runs --with-faults
hunter-verify-run runs/<run-dir> # re-check a finished run on its own
hunter-selfcheck # everything above against the in-process fake
pytest -q
```

`--restart-argv-json '["cmd", "arg"]'` adds a restart-recovery check: the
harness runs the command without a shell, waits for the endpoint, and reads
the baseline capture again. Without it that check is NOT_RUN.

Exit codes: 0 every executed check passed; 1 a check failed; 2 usage error;
3 the plan was refused; 4 nothing failed but something was blocked; 5 the
receipts themselves failed (schema, privacy screen, missing file, integrity),
which outranks the others.

## What a label means

- `REAL_API`: the client exchanged HTTP with an endpoint the operator declared
real. The harness cannot see whether the backend ran its CPU stages, which
models it used or which ref it is; the summary always records
`real_mirror_cpu: NOT_ESTABLISHED` and `backend_verified: false`.
- `FAKE_HUNTER`: the in-process fake (`fakeapi.py`) or the fault proxy
(`faultproxy.py`). It shows the client reacts to a condition and is never
evidence about a backend.
- `GPU_STUB`: the `objects`, `vlm` and `dense` arms, named and always NOT_RUN.

BLOCKED means the check got no answer or depends on a step that did not
complete, and NOT_RUN means it was deliberately skipped. Neither counts as a
pass, and the frozen-trace check reports such rows as not comparable rather
than agreeing. A request the client refuses to send (see below) is FAIL.

## What the client refuses

Every API request's final URL passes the published scope rule
(`ScopedCaptureHTTP`) before any header, token or connection: same scheme,
host and port as the base, under its path, with no dot segment and no
encoded `/`, `\` or `.`. A server-minted capture id such as `../outside`
therefore becomes a refused request (`auth_destination_refused`), and
nothing is sent. Storage PUTs use their own rule: https, or http to an exact
loopback host, with exactly the signed headers and never `Authorization`.

Register and commit go in batches of `min(10, upload.maxBatch)`, the
published uploader's limit, in the lifecycle and in the tamper controls
alike. Each commit answer is judged as it arrives, by the scenario's own
rule (the lifecycle needs every path committed; a tamper control needs its
victim kept out and every intact file committed), and the first answer that
breaks it ends the round as FAIL before another batch is sent. When register reports a file already committed (a replayed or resumed
capture), the client neither uploads nor commits it again, and both checks
record that committed-at-register set explicitly.

Published Swift `Int` fields (event `seq` and `next`, `etaS`, upload limits)
decode as 64-bit integers by value, as Foundation's JSONDecoder does: `5`,
`5.0` and `1e3` are all integers, and a non-integral value such as `5.1` is
refused. The response parser keeps decimal literals exact, so
`4611686018427387905.0` decodes to exactly that integer, never to a rounded
float. A decimal spelling is range-checked through a Double, as Foundation
does, so `9223372036854775807.0` is refused while the integer
`9223372036854775807` is accepted. A value too large for a receipt to hold as
an exact JSON number (2^53 and up) is recorded as `large-int:<digest>`, never
clamped and never rejected. `Double` fields accept any JSON number that
converts to a finite double.

One known compatibility limit: this rule is a deliberately conservative
oracle, not exact Swift Codable parity. Foundation decodes
`1.0000000000000000001` as the Int `1`, because it rounds the literal first;
the harness refuses it, because the value is not an integer. A response the
app would accept can therefore FAIL here in that one kind of case: a
fractional literal that Foundation rounds to an integer. Run locally,
Foundation's JSONDecoder agrees with the harness on 20 of 21 recorded
literals; that is the 21st.

## The API credential

`--api-token-env` makes the runner read a bearer token from the
`HUNTER_API_TOKEN` environment variable. Without the flag the variable is not
read. There is no flag that takes the token itself: `--api-token` is refused
in every spelling, without echoing its value, because a command-line value
shows up in process listings and shell history.

Supply the variable through a secret manager that sets it in the runner's
process environment, not a literal assignment typed into a shell, which
history keeps. The environment is still readable by the same user and by
root; nothing here protects against them.

The whole token is checked (RFC 6750 b64token, so a trailing newline, a
carriage return, a space or any control character is refused) before any run
directory or request exists. It goes only on API requests to `--endpoint` whose final URL passes
the scope rule, never on a storage PUT and never to the fault proxy. Restart
commands and git run without it in their environment. Receipts record only
the flag name.

With a token, `--with-faults` does not run the fault round: every fault check
is BLOCKED with the reason `auth_fault_proxy_not_authorized`, and the run
exits 4 unless something failed. The proxy is never given the credential,
and its requests sent without one would only read as endpoint failures. So a
token-protected API gets no fault coverage from this harness; that gap is
open, not passed. Without a token the fault round runs as before.

## The synthetic plan

`fixturegen.py` writes seeded noise files, two seeded CSV streams, the
published `CreateRequest`, and a Packet04 packet whose `source.kind` is
`synthetic` with no keyframes. A different seed changes every file's bytes.
The runner's loader (`plan.py`) refuses any plan whose digests do not
recompute from the bytes it read, whose paths are not the generator's, or
whose create body or packet differ from the published shapes. Each control
and fault check uses its own derived packet id carrying this run's id, so
none of them can land on the baseline capture, and a rerun against the same
server opens fresh control captures while the baseline is a true replay.

## Receipts

Each run gets a new directory (exclusive `mkdir`, random run id) holding
exactly five files:

| File | Contents |
| --- | --- |
| `run-manifest.json` | declared backend mode and ref; digest of the package source that ran, plus git HEAD and a digest of the uncommitted diff when run from a checkout; fixture digests; the command with every value rendered by type; runtime; settings |
| `checks.jsonl` | one line per check: id, transport, status, the oracle and expectation (package constants looked up by id), and the observed record |
| `summary.json` | verdict, counts, failed/blocked/not-run ids, digests of the manifest and checks |
| `privacy-screen.json` | the pattern screen's findings over the three files above, run after the summary exists |
| `privacy-chain.jsonl` | a SHA-256 chain over every artifact's final bytes |

Values reach these files only through typed schemas (`export.py`). A string
must be a published enum word, a package constant, a digest, or a path or
packet id the plan declared. Server identifiers become `id:<12 hex>`, unknown
words `unrecognized:<12 hex>`, and unexpected paths and keys become digests,
so a disagreement stays visible without its content. Homeowner messages,
prompts, signed URLs and raw bodies are never recorded. Recorded commands
keep this program's flags typed (an endpoint as `host:port` only when it is
loopback, paths as `<path>`) and turn every other value into `<value>`, or
`***` after a credential-named flag. A value outside its schema stops the run
with exit 5 before it is written.

The pattern screen (`sanitize.py`) is the second layer. It cannot recognize a
bare word-shaped secret, which is why the schemas come first.

What the integrity check proves: the chain, the read-only modes and
`hunter-verify-run` detect an edit made after the run, such as a changed
byte, a broken link, a writable, extra or missing file. They do not stop the
directory's owner from rewriting a file, recomputing the chain and sealing
it again. The chain head is printed when the run ends; recording it
somewhere the run cannot write is the only anchor. A file `hunter-verify-run`
cannot read, decode or parse, or one of the wrong JSON shape, is reported by
file name and category with exit 5; its content is never printed. Screen
findings never delete evidence: failed artifacts survive, sealed, for
inspection. Sealing and a clean screen make receipts tamper-evident, not
publishable — the guarantees above govern when a receipt may leave the
machine.

## Layout

| Module | Role |
| --- | --- |
| `netpolicy.py` | the one rule for which hosts are loopback and which URLs may be used |
| `contract.py` | published vocabularies and one strict decoder per response |
| `http_client.py` | the client: transient retries, API-only credential, exact signed PUT headers |
| `scenarios/` | lifecycle, controls, fault round; the only place PASS/FAIL is decided |
| `scenarios/oracles.py` | every check id with its oracle and expectation |
| `traces/` | frozen expectations; v3 is current (v2 plus the committed-at-register branch); v1 and v2 are kept unedited |
| `export.py`, `receipts.py`, `verify.py` | schemas, writing and sealing, independent re-check |
| `fakeapi.py`, `faultproxy.py` | FAKE_HUNTER plumbing with knobs to misbehave |
| `provenance.py` | source digest, git state, runtime |
31 changes: 31 additions & 0 deletions tools/hunter-local-ingest/evidence/1511668/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Receipts from the installed package at 1511668

These seven run directories come from the wheel built out of commit
`1511668e7e1df5d87228862b9d502708a10dee54`
(sha256 `029257adc3597d7f8364531bd86d71d16896fcb7709153ebaf7e255054932121`),
installed non-editable into a scratch venv and run as console scripts from a
directory outside the source tree. The installed package's source digest,
`843fd20eb408e430…`, equals the digest of the committed
`src/hunter_local_ingest` (full values in `index.json`). The plan is
`hunter-gen-plan --seed 1`; the seed reproduces it, so it is not committed.
The older `evidence/23d5fc9/` receipts describe that earlier source and are
unchanged.

| Run | Endpoint | Exit | Verdict | What it shows |
| --- | --- | --- | --- | --- |
| `selfcheck/` | in-process fake, fault round, no-op restart | 0 | PASS | 26 PASS rows labeled FAKE_HUNTER; GPU arms NOT_RUN |
| `real-api-unreachable/` | declared `REAL_API`, nothing listening | 4 | BLOCKED | 13 BLOCKED rows; an absent backend never passes |
| `mutation-commits-tampered/` | fake that commits tampered bytes | 1 | FAIL | both tamper controls and the trace check fail |
| `canary-responses/` | fake that plants a marker in every response | 0 | PASS | the marker occurs 0 times in the run's files |
| `rerun-committed-capture/` | second run of the same plan on one server | 0 | PASS | the committed baseline is replayed; the rerun's 10 PUTs all belong to fresh control captures |
| `max-batch-2/` | fake that enforces `upload.maxBatch` = 2 | 0 | PASS | lifecycle and tamper controls stay within two files per call |
| `auth-fault-arm-blocked/` | token-protected fake, `--with-faults`, token from `HUNTER_API_TOKEN` | 4 | BLOCKED | authenticated baseline passes; all 12 fault checks BLOCKED with `auth_fault_proxy_not_authorized`; 0 unauthenticated API requests; the synthetic token occurs 0 times in output or receipts |

None of these runs touched a real backend, a real credential, a GPU or a
phone. The token in the last run was a fresh synthetic value set only in
that subprocess's environment.

`index.json` lists every artifact's sha256, each chain head, the exit codes
and the result of `hunter-verify-run` on the sealed originals (all OK). Git
checks files out writable, so on these copies `hunter-verify-run` also
reports `not sealed (writable)`; run `chmod -R a-w <run-dir>` first.
Loading