diff --git a/docs/safe-read.md b/docs/safe-read.md new file mode 100644 index 0000000..9f8ecb5 --- /dev/null +++ b/docs/safe-read.md @@ -0,0 +1,192 @@ +# safe-read — prove a collection read complete, or refuse + +`contracts read [options] -- [args...]` + +Exit codes: **0** proven complete · **2** REFUSED · **3** usage. The convention matches +`probe-guard`, so an agent does not have to hold two vocabularies. + +A text-mode refusal prints **nothing** to stdout. In `--json` form stdout carries a +structured refusal, but the `rows` key is *absent* rather than empty, so a consumer +that reaches for it gets `undefined` and fails instead of reading a plausible zero. + +## Why this lives in `@hasna/contracts` + +The shape of a collection read — `ok`, `total`, `total_pages`, `has_more`, +`next_cursor`, `store_exists` — is the interface contract between every Hasna CLI and +every consumer of it, and it is the one contract on the fleet with no owning domain. +`@hasna/contracts` exists for exactly that: "shared schemas and validators for Hasna +agent infrastructure contracts." + +Four reasons this package rather than another: + +1. **It is the only domain-less shared library.** Every other candidate owns a domain + — `guardrails` is request policy, `controls` is spend authorization, `shield` is + security scanning, `dispatch` is tmux delivery. A read-completeness contract is + none of those. +2. **It already ships both shapes this needs.** A library export for TypeScript + consumers, and a CLI binary for the population that actually hit this — agents + running commands in a shell, not programs importing a module. +3. **It avoids a 186th package**, a new release-age exclusion entry, and a new thing + to install on every machine. +4. **The nearest existing abstraction cannot be the home.** `probe-guard` is the + closest thing already deployed, and it has **no source repository anywhere on this + box** — a 112 KB script in `~/.local/bin` referenced by five skills. It is also a + different half of the problem: an assertion over bytes you already captured. + +### Relationship to `probe-guard` + +`probe-guard complete` asserts over a captured file. It is good and this does not +replace it. It cannot close mechanism 3, and it says so itself. Measured on the live +`repos repos --json` read, 50 rows of a population of 1793: + +``` +probe-guard complete --json r50.json + rc=0 + probe-guard complete: 50 row(s) under + no unfollowed pagination marker found (top level or nested) + WARNING: %d is a round number and a classic SILENT page cap... + PASS +``` + +`rc=0` — a pass, with an honest warning that no automation stops on. The same read +through this helper: + +``` +contracts read -- repos repos --json + rc=2 + contracts read: REFUSED [stderr_truncation_notice] + rows=50 under + No rows are printed. A refused read is not an empty set. +``` + +The difference is not cleverness. Establishing completeness requires a **second +observation** — another page, a wider bound, a sibling aggregate, or the other +stream — and a checker handed one captured file has only one. + +## The design principle + +> **Completeness must be PROVEN. It is never inferred from the absence of a flag.** + +A read is accepted only when it carries one of five named proofs: + +| proof | how it is established | +|---|---| +| `declared_total_satisfied` | rows equal a total the surface declared itself | +| `cursor_exhausted` | paged until the surface said there was no next page | +| `sibling_aggregate_agrees` | rows equal an aggregate on a sibling verb the caller named | +| `stable_under_widening` | a wider bound returned fewer rows than that bound *and* fewer than any clamp | +| `assumed_complete` | the caller waived proof; always recorded, never silent | + +## What this does NOT protect against + +This section is the most useful part of the document, because it says where the +guarantee stops. Each item is labelled **M** (live behaviour exercised) or **S/U** +(established from a source read or another agent's measurement, not exercised here). + +**1. A wrong predicate on a complete read. (M, by others)** +The read can be complete, the pagination followed and the total honest, while the +filter expression means something other than what its author intended. The measured +case is a regex flag whose letter survives a port and whose meaning does not: + +``` +jq test("^SECOND";"m") -> false "m" does NOT anchor per line +jq test("first.SECOND";"m") -> true "m" IS dotall +python re.search(r'^X', s, re.M) -> True same letter, opposite meaning +``` + +In Python and JavaScript, `m` is per-line anchors and `s` is dotall. In jq, `m` **is** +dotall and there is no per-line anchor flag at all. A pattern carried from a Python +script into a jq filter keeps its letter, silently changes meaning, and returns +`false` on text that plainly contains the match. A read helper cannot see this without +evaluating the caller's intent, and one that tried would become a regex linter. + +*This helper's own exposure to it:* **jq invocations = 0, anchored patterns = 0** +across all four source files, with a positive control confirming the probe finds a +token that is present (`RegExp` = 1) and not one that is absent (0). Both numbers are +reported because a zero alone cannot distinguish "clean" from "the check could not +have fired" — this is the second case, so the check is honestly **out of scope** here +rather than an all-clear. The two regex flags actually used are both `/i`. + +**2. A defaulted scope, beyond naming it. (M, by others)** +The read is complete, the predicate correct, pagination followed, and the declared +total honest — only the scope is defaulted, and nothing in the output is wrong: + +``` +knowledge list --limit 1 --json total=1526 +knowledge list --limit 1 --include-archived --json total=1567 41 items invisible +todos list --json --limit 9000 7301, converged, and a SUBSET +todos list --all --json --limit 40000 40000 +``` + +**The envelope's `total` is scope-relative**, so reconciling rows against it *confirms* +a defaulted scope with a clean three-way agreement rather than catching it. The +helper's strongest proof is structurally blind here. + +What it does instead: a small census (`SCOPE_WIDENERS`) refuses two known surfaces +unless the widening flag is passed or `--scope-ack` records the narrow scope as a +deliberate choice, and **every count carries the scope that produced it**. `1526 +(default)` and `1567 (--include-archived)` are different facts and neither is "the +population" alone. The census is deliberately tiny; auto-discovering every scope flag +on every CLI is a per-surface fact that rots, and guessing it is worse than declaring +the limit. + +**3. A probe that is structurally incapable of matching its subject. (M, by others)** +A caller can hand this helper a command whose own matcher cannot work. The measured +case: `basename(argv[0]) == "search"` returns zero for every Hasna CLI, because they +are all bun shims and the live `argv[0]` is `bun`. That is a method that cannot fire, +not a near-miss, and it was introduced *as a fix* for a self-matching full-cmdline +matcher — trading a false positive for a false negative, which is the one that gets +published. This helper guards the completeness of a read; it cannot see that the +command it was handed asks the wrong question. + +**4. An unknown hidden clamp. (M, here)** +`stable_under_widening` is the weakest proof and is unsound against a silent +server-side cap. `conversations read` returns 500 rows for any request above 500, so +`count < requestedBound` proves nothing when the bound was never honoured. Two-step +widening does not rescue it: a true population of 300 and a hidden clamp of 500 both +yield equal counts below both bounds at any number of steps. The helper refuses when +the widened count meets a censused cap or lands on a round number, which is +conservative in the safe direction — a real population of exactly 500 on an +uncensused surface is falsely refused, and the fix is one `--known-clamp` flag. +`KNOWN_CLAMPS` is an optimisation, not the safety property. + +**5. A moving population.** Two observations taken at different instants can +legitimately disagree. Growth between the first read and the widening probe is +reported and the wider read returned; a sibling aggregate that moves under a long +page walk produces a refusal that a re-run may clear. + +**6. Anything about whether the rows are CORRECT.** This establishes that you are +holding the whole set. It says nothing about whether the set is the one you wanted, +whether the fields mean what you think, or whether the values are true. + +## Evidence grades in the censuses + +`KNOWN_CLAMPS` and `SCOPE_WIDENERS` carry a per-row grade, copied from the discipline +of knowledge item `k_mso1r678_fhgm1o` (Conversations Readbounds Census Matrix, +measured 2026-08-11 against `@hasna/conversations` 0.5.43): + +- **M** — live behaviour exercised. +- **S/U** — established from installed client or bundled server source; live behaviour + not safely measurable. + +A source-read bound and an exercised bound are different claims, and merging them is +how a table starts asserting more than anyone measured. + +## Examples + +```bash +# Refuse a bounded read whose only notice is on stderr +contracts read -- repos repos --json + +# An envelope-less bare array, proven by a sibling aggregate +contracts read --limit-flag=--limit --limit 2000 \ + --sibling-arg mementos --sibling-arg stats --sibling-arg --json \ + --sibling-path by_scope.global \ + -- mementos list --scope global --json + +# Page a cursor surface to exhaustion +contracts read --cursor-flag --cursor -- conversations digest board --json + +# Catch a query verb that ignores its predicate +contracts read --probe-negative-arg zzz-no-such-token -- faketool search widget +``` diff --git a/package.json b/package.json index ced62b1..eb4cf61 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,14 @@ "types": "./dist/validators.d.ts", "import": "./dist/validators.js" }, + "./safe-read": { + "types": "./dist/safe-read.d.ts", + "import": "./dist/safe-read.js" + }, + "./safe-read/exec": { + "types": "./dist/safe-read-exec.d.ts", + "import": "./dist/safe-read-exec.js" + }, "./no-cloud": { "types": "./dist/no-cloud.d.ts", "import": "./dist/no-cloud.js" @@ -109,7 +117,7 @@ "LICENSE" ], "scripts": { - "build": "bun run deployment:check && bun run todos:check && rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/deployment.ts src/deployment/index.ts src/deployment-artifacts.ts src/no-cloud.ts src/server-backend.ts src/service-contract.ts src/secure-local-store.ts src/conformance.ts src/artifact-scan.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts src/client/transport.ts src/client/storage.ts --root src --outdir dist --target bun && bun build src/todos/index.ts --root src --outdir dist --target bun --packages external && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/cli/contracts-cli.ts dist/cli/contracts-cli.js && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", + "build": "bun run deployment:check && bun run todos:check && rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/safe-read.ts src/safe-read-exec.ts src/deployment.ts src/deployment/index.ts src/deployment-artifacts.ts src/no-cloud.ts src/server-backend.ts src/service-contract.ts src/secure-local-store.ts src/conformance.ts src/artifact-scan.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts src/client/transport.ts src/client/storage.ts --root src --outdir dist --target bun && bun build src/todos/index.ts --root src --outdir dist --target bun --packages external && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/cli/contracts-cli.ts dist/cli/contracts-cli.js && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", "typecheck": "tsc --noEmit", "test": "bun test --timeout 120000", "lint": "tsc --noEmit", diff --git a/src/cli/index.ts b/src/cli/index.ts index ec347fb..3fc7c6c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,6 +15,7 @@ import { secureLocalStorePolicy } from "../secure-local-store"; import { runVendorKit } from "./kit-runner"; import { runIssueKey } from "./issue-key"; import { formatArtifactScanReport, resolveAssetInventoryWaivers, scanPublishedArtifact } from "../artifact-scan"; +import { runSafeReadCli } from "./read"; function collectJsonFiles(root: string): string[] { const stat = statSync(root); @@ -67,7 +68,7 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } - if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store"].includes(command)) { + if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store", "read"].includes(command)) { return reportParserJsonError("commander.unknownCommand", `unknown command '${command}'`); } @@ -77,6 +78,14 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } + // `read` carries an entire foreign command line after `--`, including that + // command's own flags. Preflighting those against this program's option set + // would reject `contracts read -- todos list --limit 5` for an option that is + // not ours to validate. Commander handles it. + if (command === "read") { + return false; + } + const allowedOptionsByCommand: Record> = { schemas: new Set(["--json", "-j"]), validate: new Set(["--json", "-j", "--schema"]), @@ -476,6 +485,30 @@ export function createContractsProgram() { } }); + program + .command("read") + .description("Run a Hasna collection read and either prove it complete or REFUSE (exit 2)") + .argument("[command...]", "The command to run, after --. e.g. contracts read -- todos list --json") + .option("--rows-key ", "Key holding the row array (auto-detected when omitted)") + .option("--total-key ", "Key holding a self-declared population size") + .option("--limit-flag ", "Flag the target uses to bound rows (default --limit)") + .option("--limit ", "Bound to pass on the first read; enables the widening proof") + .option("--widen-to ", "Bound for the widening probe (default limit * 4)") + .option("--known-clamp ", "The server cap this surface really imposes, if you have established it") + .option("--cursor-flag ", "Flag the target uses to page, e.g. --cursor") + .option("--max-pages ", "Refuse rather than page beyond this many pages (default 200)") + .option("--sibling-arg ", "Repeatable: argv of a sibling verb carrying an aggregate", collectOption) + .option("--sibling-path ", "Dotted path to the aggregate, e.g. by_scope.global") + .option("--probe-positive-arg ", "Repeatable: tokens forming a query that MUST match something", collectOption) + .option("--probe-negative-arg ", "Repeatable: tokens forming a query that MUST match nothing", collectOption) + .option("--allow-empty", "An empty result is a legitimate answer here") + .option("--assume-complete", "Waive proof; recorded in the evidence and never silent") + .option("--scope-ack ", "Record the narrower default scope as a deliberate choice, and say why") + .option("-j, --json", "Output JSON") + .action((command: string[], options: Record) => { + process.exitCode = runSafeReadCli(command ?? [], options as never); + }); + program .command("issue-key") .description("Mint an API key (prefix hasna__): stores the hashed record and prints the secret ONCE") diff --git a/src/cli/read.ts b/src/cli/read.ts new file mode 100644 index 0000000..eea7582 --- /dev/null +++ b/src/cli/read.ts @@ -0,0 +1,109 @@ +/** + * `contracts read` — run a Hasna collection read and either prove it whole or refuse. + * + * Exit codes follow the convention probe-guard already established on this fleet, so + * an agent does not have to hold two vocabularies: + * 0 the read is proven complete; rows are on stdout + * 2 REFUSED — completeness could not be established (the interesting one) + * 3 usage error + * + * A refusal is never an empty result. Nothing is printed to stdout that a caller + * could mistake for a population. + */ + +import { safeRead, type SafeReadResult } from "../safe-read-exec"; + +export interface ReadCliOptions { + rowsKey?: string | undefined; + totalKey?: string | undefined; + limitFlag?: string | undefined; + limit?: string | undefined; + widenTo?: string | undefined; + knownClamp?: string | undefined; + cursorFlag?: string | undefined; + maxPages?: string | undefined; + siblingArg?: string[] | undefined; + siblingPath?: string | undefined; + probeNegativeArg?: string[] | undefined; + probePositiveArg?: string[] | undefined; + allowEmpty?: boolean | undefined; + assumeComplete?: boolean | undefined; + scopeAck?: string | undefined; + json?: boolean | undefined; +} + +function toInt(value: string | undefined, flag: string): number | undefined { + if (value === undefined) return undefined; + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`${flag} must be a positive integer, got '${value}'`); + } + return n; +} + +export function runSafeReadCli( + argv: string[], + options: ReadCliOptions, + io: { log: (s: string) => void; err: (s: string) => void } = { + log: (s) => console.log(s), + err: (s) => console.error(s) + } +): number { + if (argv.length === 0) { + io.err("contracts read: no command given. Usage: contracts read [options] -- [args...]"); + return 3; + } + + let result: SafeReadResult; + try { + result = safeRead({ + argv, + rowsKey: options.rowsKey, + totalKey: options.totalKey, + allowEmpty: options.allowEmpty, + limitFlag: options.limitFlag, + limit: toInt(options.limit, "--limit"), + widenTo: toInt(options.widenTo, "--widen-to"), + knownClamp: toInt(options.knownClamp, "--known-clamp"), + cursorFlag: options.cursorFlag, + maxPages: toInt(options.maxPages, "--max-pages"), + siblingArgv: options.siblingArg, + siblingPath: options.siblingPath, + probeNegativeArgs: options.probeNegativeArg, + probePositiveArgs: options.probePositiveArg, + assumeComplete: options.assumeComplete, + scopeAck: options.scopeAck + }); + } catch (error) { + io.err(`contracts read: ${error instanceof Error ? error.message : String(error)}`); + return 3; + } + + if (options.json) { + // On refusal `rows` is absent, not empty: a consumer that reaches for it must + // get undefined and fail, never a plausible zero. + io.log( + JSON.stringify( + result.ok + ? { ok: true, proofs: result.proofs, rowCount: result.rowCount, pages: result.pages, scope: result.scope, rows: result.rows, evidence: result.evidence } + : { ok: false, code: result.code, error: result.reason, pages: result.pages, scope: result.scope, evidence: result.evidence }, + null, + 2 + ) + ); + return result.ok ? 0 : 2; + } + + if (result.ok) { + io.err(`contracts read: PASS — ${result.reason}`); + for (const line of result.evidence) io.err(` ${line}`); + io.log(JSON.stringify(result.rows, null, 2)); + return 0; + } + + io.err(`contracts read: REFUSED [${result.code}]`); + io.err(` ${result.reason}`); + for (const line of result.evidence) io.err(` ${line}`); + io.err(" No rows are printed. A refused read is not an empty set."); + return 2; +} diff --git a/src/safe-read-exec.ts b/src/safe-read-exec.ts new file mode 100644 index 0000000..48be455 --- /dev/null +++ b/src/safe-read-exec.ts @@ -0,0 +1,613 @@ +/** + * safe-read-exec — run a Hasna collection read and either prove it complete or refuse. + * + * The pure contract lives in ./safe-read. This module is the half that can actually + * establish completeness, because establishing it requires a SECOND OBSERVATION: + * another page, a wider bound, or a sibling aggregate. No inspection of a single + * payload can do it, which is why a checker that only reads captured bytes passes + * the quietest member of this family. + * + * TWO PROPERTIES OF THE CAPTURE PATH, both deliberate: + * + * - The command is spawned from an ARGV ARRAY and never through a shell. That is + * not a style preference: composing a command into a shell string is how prose + * and identifiers get eaten by backticks and unbalanced quotes, and a helper + * that reintroduced it would be shipping one defect inside the fix for another. + * - stdout and stderr are captured SEPARATELY and the exit status is read from the + * process itself. Never a pipe, never 2>&1. A pipe takes its status from the last + * stage and can cut a large payload at one buffer; merging the streams corrupts + * the JSON with whatever diagnostics the tool emitted. + * + * WIDENING IS THE DEFAULT, NOT A FLAG. The lesson that shaped this API: a corrected + * count went 1 -> 3 by widening the same read and was STILL WRONG, then 3 -> 15 by + * changing shape. Widening agrees with itself until it happens to exceed the + * population, so the widened read is only accepted when it returns FEWER rows than + * its own bound — the one condition under which a bound cannot be what limited it. + */ + +import { spawnSync } from "node:child_process"; +import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + classifyRead, + locateRows, + readDottedPath, + type CapturedRead, + type CompletenessProof, + type ReadVerdict, + type RefusalCode +} from "./safe-read"; + +/** A runaway cursor loop is a hang; bound it and refuse rather than spin. */ +const DEFAULT_MAX_PAGES = 200; + +/** + * Server-side caps that a surface applies SILENTLY, replacing the bound you asked + * for without saying so. Keyed by " ". + * + * Source: knowledge k_mso1r678_fhgm1o, "Conversations Readbounds Census Matrix", + * measured 2026-08-11 against @hasna/conversations 0.5.43 and the hosted service. + * Evidence grade is carried per row exactly as that census carries it, because a + * source-read bound and an exercised bound are different claims and merging them is + * how a table starts asserting more than anyone measured: + * M = live behaviour exercised + * S/U = established from installed client or bundled server source; live + * behaviour not safely measurable + * + * This table is an OPTIMISATION, not the safety property. An absent entry falls back + * to the round-number heuristic below, which is deliberately conservative. + */ +export const KNOWN_CLAMPS: Record = { + "conversations read": { cap: 500, grade: "M", note: "requests above 500 silently return 500" }, + "conversations channel read": { cap: 500, grade: "M", note: "same path as read; the clamp is hidden above 500" }, + "conversations search": { cap: 500, grade: "M", note: "server max 500 plus a 48 KiB compact-JSON budget" }, + "conversations since": { cap: 500, grade: "S/U", note: "requests above 500 can hide the clamp" }, + "conversations pinned": { cap: 500, grade: "S/U", note: "bare JSON carries no truncation metadata" }, + "conversations notifications": { cap: 500, grade: "S/U", note: "neither surface signals the server clamp" }, + "conversations agents list": { + cap: 500, + grade: "M", + note: "server hard-caps 500 newest by last_seen_at and IGNORES the client limit entirely" + }, + "conversations blockers": { cap: 500, grade: "S/U", note: "JSON ignores --limit; the flag moves only the human surface" }, + "conversations project list": { cap: 1000, grade: "M", note: "exactly 1000 cannot prove more; the probe is itself clamped" }, + "knowledge list": { cap: 200, grade: "M", note: "--limit above 200 is REJECTED at rc=1 rather than clamped" } +}; + +/** + * Counts that are far more likely to be a cap than a population. + * + * Used only when the surface is absent from KNOWN_CLAMPS. Landing exactly on one of + * these after widening is refused rather than accepted: the cost of a false refusal + * is one extra flag from the caller, and the cost of a false pass is a page + * published as a population. + */ +const SUSPICIOUS_COUNTS = new Set([10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000]); + +/** + * Flags that widen a surface's SCOPE, as opposed to its row bound. + * + * MECHANISM 5, AND IT IS UNLIKE THE OTHERS. The read is complete, the predicate is + * correct, pagination is followed, and the declared total is honest — only the scope + * is defaulted, and nothing in the output is wrong. Measured 2026-08-11: + * + * knowledge list --limit 1 --json total=1526 + * knowledge list --limit 1 --include-archived --json total=1567 (41 invisible) + * todos list --json --limit 9000 7301, converged, a SUBSET + * todos list --all --json --limit 40000 40000 + * + * THE ENVELOPE'S `total` IS SCOPE-RELATIVE, so reconciling rows against it CONFIRMS + * a defaulted scope with a clean three-way agreement (1526 collected, 1526 distinct + * ids, 1526 declared — and 41 short). Total-reconciliation, which is otherwise this + * module's strongest proof, is structurally blind here. + * + * This census is deliberately TINY and is not an attempt to discover every scope + * flag on every CLI. That is a per-surface fact, it rots, and guessing it is worse + * than declaring the limitation. Naming the scope beside the number is the defence. + */ +export const SCOPE_WIDENERS: Record = { + "knowledge list": { flag: "--include-archived", grade: "M", note: "default hides archived items (1526 vs 1567)" }, + "todos list": { flag: "--all", grade: "M", note: "default is a subset; --all never reveals its own population" } +}; + +/** Longest matching " " prefix of the argv. */ +export function lookupScopeWidener(argv: string[]): { key: string; flag: string; grade: "M" | "S/U"; note: string } | null { + const words = argv.filter((token) => !token.startsWith("-")); + for (let take = Math.min(words.length, 4); take >= 1; take -= 1) { + const key = words.slice(0, take).join(" "); + const hit = SCOPE_WIDENERS[key]; + if (hit) return { key, ...hit }; + } + return null; +} + +/** Longest matching " " prefix of the argv. */ +export function lookupClamp(argv: string[]): { key: string; cap: number; grade: "M" | "S/U"; note: string } | null { + const words = argv.filter((token) => !token.startsWith("-")); + for (let take = Math.min(words.length, 4); take >= 1; take -= 1) { + const key = words.slice(0, take).join(" "); + const hit = KNOWN_CLAMPS[key]; + if (hit) return { key, ...hit }; + } + return null; +} + +export interface SafeReadRequest { + /** argv of the command under test. argv[0] is the binary. */ + argv: string[]; + rowsKey?: string | undefined; + totalKey?: string | undefined; + allowEmpty?: boolean | undefined; + /** Flag used to bound rows, e.g. "--limit". */ + limitFlag?: string | undefined; + /** Bound passed on the first read. Required for the widening proof. */ + limit?: number | undefined; + /** Bound used for the widening probe. Defaults to limit * 4. */ + widenTo?: number | undefined; + /** + * The server-side cap this surface really imposes, when the caller has + * established it. Overrides the census and disables the round-number heuristic. + */ + knownClamp?: number | undefined; + /** Flag used to page, e.g. "--cursor". */ + cursorFlag?: string | undefined; + maxPages?: number | undefined; + /** argv of a sibling verb carrying an aggregate, e.g. ["mementos","stats","--json"]. */ + siblingArgv?: string[] | undefined; + /** Dotted path to the aggregate in the sibling payload, e.g. "by_scope.global". */ + siblingPath?: string | undefined; + /** Tokens appended to argv forming a query that MUST match nothing. */ + probeNegativeArgs?: string[] | undefined; + /** Tokens appended to argv forming a query that MUST match something. */ + probePositiveArgs?: string[] | undefined; + /** Waive proof explicitly. Recorded in evidence; never silent. */ + assumeComplete?: boolean | undefined; + /** + * Acknowledge that the narrower default scope is the one you want, and say why. + * Turns a silent default into a recorded choice; it does not widen anything. + */ + scopeAck?: string | undefined; + /** Injectable for tests. */ + run?: ((argv: string[]) => CapturedRead) | undefined; +} + +export interface SafeReadResult { + ok: boolean; + code?: RefusalCode | "capture_overflow" | "spawn_failed" | undefined; + reason: string; + proofs: CompletenessProof[]; + rows: unknown[]; + rowCount: number; + pages: number; + /** + * The scope the count was taken under. A NUMBER WITHOUT ITS SCOPE IS NOT A + * POPULATION: `1526 (default)` and `1567 (--include-archived)` are different + * facts and neither is "the population" on its own. + */ + scope: string; + evidence: string[]; +} + +/** Spawn with both streams captured to memory, separately, with no shell. */ +export function runCaptured(argv: string[]): CapturedRead { + const [bin, ...args] = argv; + if (!bin) throw new Error("empty argv"); + + // Capture to FILES via file descriptors, which is the capture-path doctrine + // written out literally: `cmd > out 2> err`, then read the files. + // + // AN IN-MEMORY CAPTURE WAS TRIED FIRST AND TRUNCATED, WHICH IS WHY THIS IS NOT A + // STYLE CHOICE. Measured on bun 1.3.14, arm64, against a 1.23 MB payload + // (`mementos list --scope global --json --limit 2000`): + // + // shell redirect to a file 1227488 B parses, 684 rows + // spawnSync(encoding:"utf8", maxBuffer:256MiB) 1079970 B UNPARSEABLE, status 0, error undefined + // + // The 1079970 figure is exactly what an explicit 1 MiB maxBuffer produces, so the + // option did not reach the call — and the ENOBUFS that a 1 MiB run does surface + // was NOT raised, so the truncation arrived as a clean exit-0 short read. That is + // precisely the failure this module exists to refuse, inside its own capture path. + // File descriptors have no such ceiling. + const dir = mkdtempSync(join(tmpdir(), "hasna-safe-read-")); + const outPath = join(dir, "stdout.bin"); + const errPath = join(dir, "stderr.bin"); + let outFd: number | undefined; + let errFd: number | undefined; + try { + outFd = openSync(outPath, "w"); + errFd = openSync(errPath, "w"); + const proc = spawnSync(bin, args, { shell: false, stdio: ["ignore", outFd, errFd] }); + closeSync(outFd); + outFd = undefined; + closeSync(errFd); + errFd = undefined; + if (proc.error) { + const err = proc.error as NodeJS.ErrnoException; + return { stdout: "", stderr: `spawn failed: ${err.message}`, code: 252 }; + } + return { + stdout: readFileSync(outPath, "utf8"), + stderr: readFileSync(errPath, "utf8"), + code: proc.status ?? 253 + }; + } finally { + if (outFd !== undefined) closeSync(outFd); + if (errFd !== undefined) closeSync(errFd); + rmSync(dir, { recursive: true, force: true }); + } +} + +function withFlag(argv: string[], flag: string | undefined, value: string | number): string[] { + if (!flag) return argv; + const out = [...argv]; + const at = out.indexOf(flag); + if (at >= 0 && at + 1 < out.length) { + out[at + 1] = String(value); + return out; + } + out.push(flag, String(value)); + return out; +} + +/** + * Read a collection and prove it whole, or refuse. + * + * The order below is the order in which the four mechanisms can be ruled out + * cheaply, and each step is skipped only when an earlier one already produced a + * proof — so the expensive checks cost nothing on surfaces that declare a total. + */ +export function safeRead(request: SafeReadRequest): SafeReadResult { + const widener = lookupScopeWidener(request.argv); + const scopeLabel = + widener && request.argv.includes(widener.flag) + ? widener.flag + : request.scopeAck + ? `default (acknowledged: ${request.scopeAck})` + : "default"; + const result = safeReadInner(request); + // Stamp the scope onto every outcome, so no count leaves this module naked. + return { + ...result, + scope: result.scope && result.scope !== "default" ? result.scope : scopeLabel, + reason: result.ok ? `${result.reason} [scope: ${scopeLabel}]` : result.reason + }; +} + +function safeReadInner(request: SafeReadRequest): SafeReadResult { + const run = request.run ?? runCaptured; + const evidence: string[] = []; + const maxPages = request.maxPages ?? DEFAULT_MAX_PAGES; + + evidence.push(`argv: ${request.argv.join(" ")}`); + + // --- mechanism 5: DEFAULTED SCOPE --------------------------------------------- + // Checked first and cheaply. Every later proof is scope-relative, so a number + // established under a defaulted scope is a correct count of the wrong set. + const widener = lookupScopeWidener(request.argv); + const scope = widener && request.argv.includes(widener.flag) ? widener.flag : "default"; + if (widener && scope === "default" && !request.scopeAck) { + return { + ...fail( + "scope_defaulted", + `${widener.key} defaults to a NARROWER SCOPE than its full population (${widener.note}, evidence ${widener.grade}). ` + + `The read may be complete, the predicate correct and the declared total honest, and the number still be a subset — ` + + `a declared total is scope-relative, so reconciling against it CONFIRMS this rather than catching it. ` + + `Pass ${widener.flag} for the wider scope, or --scope-ack to record the narrower one as a deliberate choice.`, + evidence, + 0 + ), + scope + }; + } + if (widener) evidence.push(`scope: ${scope} (widener ${widener.flag} available, grade ${widener.grade})`); + else evidence.push(`scope: ${request.scopeAck ?? "unqualified"} (no scope widener in census for this surface)`); + + // --- mechanism 4: PREDICATE IGNORED ------------------------------------------ + // Run first. If the surface is not honouring the predicate, every count below is + // a count of the wrong population and no amount of paging repairs it. + if (request.probeNegativeArgs?.length || request.probePositiveArgs?.length) { + const probe = probePredicate(request, run, evidence); + if (probe) return probe; + } + + let argv = request.argv; + if (request.limit !== undefined) argv = withFlag(argv, request.limitFlag ?? "--limit", request.limit); + // Record the argv ACTUALLY RUN, not the one requested. An evidence line that + // prints the base argv while a bound was appended sends the next reader to + // reproduce a different command than the one that produced the verdict. + if (argv.join(" ") !== request.argv.join(" ")) evidence.push(`ran: ${argv.join(" ")}`); + + let captured = run(argv); + let verdict = classifyRead(captured, { + rowsKey: request.rowsKey, + totalKey: request.totalKey, + allowEmpty: request.allowEmpty, + limit: request.limit + }); + evidence.push(...verdict.evidence); + let pages = 1; + + // --- mechanism 1 -> 3: follow the cursor to exhaustion ------------------------ + if (!verdict.ok && verdict.code === "unfollowed_cursor" && request.cursorFlag) { + const accumulated: unknown[] = []; + let cursor = verdict.nextCursor; + if (typeof cursor !== "string" || cursor.trim().length === 0) { + return fail( + "unfollowed_cursor", + "the surface indicates another page but supplied no usable cursor; refusing rather than treating the first page as exhausted", + evidence, + pages + ); + } + // Re-locate rows from the first page: classifyRead withholds rows on refusal, + // deliberately, so a caller can never use a partial result by accident. + const first = locateRows(safeParse(captured.stdout), request.rowsKey); + if (first.ok) accumulated.push(...first.rows); + + while (cursor && pages < maxPages) { + const paged = withFlag(argv, request.cursorFlag, cursor); + captured = run(paged); + pages += 1; + verdict = classifyRead(captured, { + rowsKey: request.rowsKey, + totalKey: request.totalKey, + allowEmpty: true, + limit: request.limit + }); + const located = locateRows(safeParse(captured.stdout), request.rowsKey); + if (located.ok) accumulated.push(...located.rows); + if (verdict.ok) break; + if (verdict.code !== "unfollowed_cursor") { + return fail(verdict.code!, `paging stopped at page ${pages}: ${verdict.reason}`, evidence, pages); + } + cursor = verdict.nextCursor; + if (typeof cursor !== "string" || cursor.trim().length === 0) { + return fail( + "unfollowed_cursor", + `paging stopped at page ${pages}: the surface indicates another page but supplied no usable cursor`, + evidence, + pages + ); + } + } + if (cursor && pages >= maxPages) { + return fail("unfollowed_cursor", `still paging after ${maxPages} pages; refusing rather than reporting a partial set`, evidence, pages); + } + evidence.push(`paged to exhaustion over ${pages} page(s), ${accumulated.length} row(s)`); + return { + ok: true, + reason: `read proven complete by cursor_exhausted over ${pages} page(s)`, + proofs: ["cursor_exhausted"], + rows: accumulated, + rowCount: accumulated.length, + pages, + scope: "default", + evidence + }; + } + + if (verdict.ok) { + return { ok: true, reason: verdict.reason, proofs: verdict.proofs, rows: verdict.rows, rowCount: verdict.rowCount, pages, scope: "default", evidence }; + } + + // Only the "nothing proves this whole" refusals are recoverable by a second + // observation. A nonzero exit or an error object is a fact about the read and no + // amount of widening changes it. + const recoverable: RefusalCode[] = ["completeness_unproven", "page_cap_reached", "declared_total_mismatch", "stderr_truncation_notice"]; + if (!recoverable.includes(verdict.code!)) { + return fail(verdict.code!, verdict.reason, evidence, pages); + } + + // --- proof by SIBLING AGGREGATE ---------------------------------------------- + // The aggregate is named by the caller and never guessed. Measured reason: + // `mementos stats --json` carries `total`, and that field equals by_status.active + // with archived sitting OUTSIDE it — so an auto-detected "total" from a sibling + // would silently compare against the wrong population. + if (request.siblingArgv?.length && request.siblingPath) { + const sib = run(request.siblingArgv); + if (sib.code !== 0) { + evidence.push(`sibling aggregate unavailable (exit ${sib.code}); falling through`); + } else { + const aggregate = readDottedPath(safeParse(sib.stdout), request.siblingPath); + if (aggregate === undefined) { + evidence.push(`sibling path '${request.siblingPath}' absent or non-numeric; falling through`); + } else { + evidence.push(`sibling ${request.siblingPath}=${aggregate} vs rows=${verdict.rowCount}`); + if (aggregate === verdict.rowCount) { + const rows = locateRows(safeParse(captured.stdout), request.rowsKey); + return { + ok: true, + reason: `read proven complete by sibling_aggregate_agrees (${request.siblingPath}=${aggregate})`, + proofs: ["sibling_aggregate_agrees"], + rows: rows.ok ? rows.rows : [], + rowCount: verdict.rowCount, + pages, + scope: "default", + evidence + }; + } + return fail( + "declared_total_mismatch", + `sibling aggregate ${request.siblingPath}=${aggregate} disagrees with ${verdict.rowCount} row(s) read`, + evidence, + pages + ); + } + } + } + + // --- proof by WIDENING -------------------------------------------------------- + if (request.limitFlag !== undefined || request.limit !== undefined) { + const flag = request.limitFlag ?? "--limit"; + const base = request.limit ?? verdict.rowCount; + const wider = request.widenTo ?? Math.max(base * 4, base + 1); + const widened = run(withFlag(request.argv, flag, wider)); + pages += 1; + if (widened.code !== 0) { + return fail( + "completeness_unproven", + `widening probe to ${flag} ${wider} exited ${widened.code}, so completeness is still unproven. ` + + `stderr: ${widened.stderr.trim().slice(0, 200)}`, + evidence, + pages + ); + } + const wideVerdict = classifyRead(widened, { + rowsKey: request.rowsKey, + totalKey: request.totalKey, + allowEmpty: true, + limit: wider + }); + const wideRows = locateRows(safeParse(widened.stdout), request.rowsKey); + const wideCount = wideRows.ok ? wideRows.rows.length : -1; + evidence.push(`widened ${flag} ${base} -> ${wider}: ${verdict.rowCount} -> ${wideCount} row(s)`); + evidence.push(...wideVerdict.evidence.map((line) => `widened: ${line}`)); + + if (wideVerdict.ok) { + return { ok: true, reason: `widened read proven complete by ${wideVerdict.proofs.join(" + ")}`, proofs: wideVerdict.proofs, rows: wideVerdict.rows, rowCount: wideVerdict.rowCount, pages, scope: "default", evidence }; + } + if (wideVerdict.code !== "completeness_unproven" && wideVerdict.code !== "page_cap_reached") { + return fail( + wideVerdict.code!, + `widening probe failed: ${wideVerdict.reason}`, + evidence, + pages + ); + } + if (wideCount >= 0 && wideCount < wider) { + // `wideCount < wider` is necessary and NOT sufficient. A surface that silently + // clamps never honoured `wider` at all, so the comparison is against a bound + // that was never applied. Refuse whenever the count lands on a cap this + // surface is known to impose, or on a round number that is far more likely to + // be a cap than a population. + const clamp = request.knownClamp ?? lookupClamp(request.argv)?.cap; + const clampNote = lookupClamp(request.argv); + if (clamp !== undefined && wideCount >= clamp) { + return fail( + "hidden_clamp_suspected", + `widening to ${wider} returned ${wideCount} row(s), which meets this surface's known server cap of ${clamp}` + + (clampNote ? ` (${clampNote.key}, evidence ${clampNote.grade}: ${clampNote.note})` : "") + + `. The bound you asked for was never honoured, so a count below it proves nothing. ` + + `Page with a cursor, or cross-check against a sibling aggregate.`, + evidence, + pages + ); + } + if (clamp === undefined && SUSPICIOUS_COUNTS.has(wideCount)) { + return fail( + "hidden_clamp_suspected", + `widening to ${wider} returned exactly ${wideCount} row(s) — a round number, and this surface is not in the ` + + `clamp census, so a silent server cap cannot be ruled out. Widening cannot tell a population of ${wideCount} ` + + `from a hidden cap at ${wideCount}. Supply --known-clamp once you have established the real cap, or prove ` + + `completeness with a cursor or a sibling aggregate.`, + evidence, + pages + ); + } + if (wideCount > verdict.rowCount) { + evidence.push(`population grew between reads (${verdict.rowCount} -> ${wideCount}); returning the wider read`); + } + if (clampNote) evidence.push(`clamp census: ${clampNote.key} cap=${clampNote.cap} grade=${clampNote.grade}`); + return { + ok: true, + reason: `read proven complete by stable_under_widening (${wideCount} row(s), strictly below the bound ${wider}${ + clamp !== undefined ? ` and below the known cap ${clamp}` : " and not a round number" + })`, + proofs: ["stable_under_widening"], + rows: wideRows.ok ? wideRows.rows : [], + rowCount: wideCount, + pages, + scope: "default", + evidence + }; + } + return fail( + "page_cap_reached", + `widening to ${wider} returned ${wideCount} row(s), which still equals the bound. The population is at least ${wideCount} and the read is still a page.`, + evidence, + pages + ); + } + + if (request.assumeComplete) { + const rows = locateRows(safeParse(captured.stdout), request.rowsKey); + evidence.push("CALLER WAIVED PROOF (--assume-complete): this result is asserted, not established"); + return { + ok: true, + reason: "completeness ASSUMED by the caller, not proven", + proofs: ["assumed_complete"], + rows: rows.ok ? rows.rows : [], + rowCount: verdict.rowCount, + pages, + scope: "default", + evidence + }; + } + + return fail(verdict.code!, verdict.reason, evidence, pages); +} + +function probePredicate( + request: SafeReadRequest, + run: (argv: string[]) => CapturedRead, + evidence: string[] +): SafeReadResult | null { + const count = (argv: string[]): number | null => { + const cap = run(argv); + if (cap.code !== 0) return null; + const located = locateRows(safeParse(cap.stdout), request.rowsKey); + return located.ok ? located.rows.length : null; + }; + + if (request.probePositiveArgs?.length) { + const positive = count([...request.argv, ...request.probePositiveArgs]); + evidence.push(`predicate probe positive -> ${positive === null ? "unreadable" : positive} row(s)`); + if (positive === null || positive === 0) { + return fail( + "predicate_inert", + `a predicate known to match returned ${positive === null ? "an unreadable result" : "zero rows"}. ` + + `The query surface is not matching what it should, so a zero from the real query would mean nothing.`, + evidence, + 0 + ); + } + } + + if (request.probeNegativeArgs?.length) { + const negative = count([...request.argv, ...request.probeNegativeArgs]); + evidence.push(`predicate probe negative -> ${negative === null ? "unreadable" : negative} row(s)`); + if (negative === null) { + return fail("predicate_ignored", "the negative predicate probe produced an unreadable result", evidence, 0); + } + if (negative > 0) { + return fail( + "predicate_ignored", + `a predicate known to match NOTHING returned ${negative} row(s). The surface is ignoring the predicate, ` + + `so these rows are not the set you asked for. Use an exact-identity lookup instead of this query verb.`, + evidence, + 0 + ); + } + } + return null; +} + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function fail( + code: SafeReadResult["code"], + reason: string, + evidence: string[], + pages: number +): SafeReadResult { + return { ok: false, code, reason, proofs: [], rows: [], rowCount: 0, pages, scope: "default", evidence }; +} + +export type { ReadVerdict }; diff --git a/src/safe-read.ts b/src/safe-read.ts new file mode 100644 index 0000000..0fb0878 --- /dev/null +++ b/src/safe-read.ts @@ -0,0 +1,404 @@ +/** + * safe-read — the collection-read contract between a Hasna CLI and its consumer. + * + * WHY THIS EXISTS. On 2026-08-11 six published claims were corrected inside three + * hours across five tools, every one a bounded or failed read reported as the + * population. Every agent involved already held the rule; several had written parts + * of it. A correctness property that depends on the caller remembering is the rule + * that was already broken, so this is the mechanical form. + * + * THE DESIGN PRINCIPLE, and everything below follows from it: + * + * COMPLETENESS MUST BE PROVEN. IT IS NEVER INFERRED FROM THE ABSENCE OF A FLAG. + * + * The quietest failure in this family is a first page that is complete, well-formed, + * rc=0 and parses, with no artefact of failure anywhere in the output. Nothing in + * such a payload can be inspected to reveal it. A checker that looks for a + * truncation marker and finds none has learned nothing, because several Hasna + * collection verbs cap with no marker at all — measured: `repos repos --json` + * returns 50 rows at rc=0 with a clean body, and the notice "showing 50 of 1793" + * appears only on stderr. + * + * So this module enumerates the PROOFS OF COMPLETENESS (see CompletenessProof) and + * refuses any read that carries none of them. Absence of evidence is a refusal, not + * a pass. + * + * This module is PURE: it classifies bytes you already captured and spawns nothing. + * The executor that runs the command, pages it, and cross-checks it lives in + * ./safe-read-exec, so that importing the contract never pulls in a subprocess + * surface. + */ + +/** Reason a read was refused. One tag per distinct mechanism, so callers can branch. */ +export type RefusalCode = + // --- mechanism 2: FAILED-AS-EMPTY ------------------------------------------- + | "nonzero_exit" + | "unparseable_stdout" + | "error_object" + | "store_unavailable" + | "rows_key_missing" + // --- mechanism 1: CUT SHORT --------------------------------------------------- + | "unfollowed_cursor" + | "declared_total_mismatch" + | "stderr_truncation_notice" + // --- mechanism 3: UNPAGINATED ------------------------------------------------- + | "completeness_unproven" + | "page_cap_reached" + | "hidden_clamp_suspected" + | "population_moved" + // --- mechanism 4: PREDICATE IGNORED ------------------------------------------- + | "predicate_ignored" + | "predicate_inert" + // --- mechanism 5: DEFAULTED SCOPE --------------------------------------------- + | "scope_defaulted"; + +/** + * The accepted proofs that a read covered its whole population. + * + * A verdict of `complete` must name at least one of these. They are listed in + * descending order of strength; `assumed_complete` is a caller waiver and is + * recorded in the evidence so it can never be silent. + */ +export type CompletenessProof = + /** rowCount equals a total the surface declared itself (e.g. knowledge `total`). */ + | "declared_total_satisfied" + /** Paged until the surface said there was no next page. */ + | "cursor_exhausted" + /** rowCount equals an aggregate read from a sibling verb the caller named. */ + | "sibling_aggregate_agrees" + /** + * Re-read at a strictly larger bound returned fewer rows than that bound AND + * fewer than any clamp that bound might have been silently replaced by. + * + * THIS IS THE WEAKEST PROOF AND IT IS UNSOUND AGAINST AN UNKNOWN HIDDEN CLAMP. + * Measured (knowledge k_mso1r678_fhgm1o, 2026-08-11): `conversations read` + * silently returns 500 for any request above 500, so `count < requestedBound` + * says nothing when the surface never honoured the bound. Two-step widening does + * not rescue it either — a true population of 300 and a hidden clamp of 500 both + * yield equal counts below both bounds, so the two cases are indistinguishable by + * widening at any number of steps. The executor therefore refuses whenever the + * widened count lands on a known or suspected clamp, and only accepts this proof + * strictly below it. + */ + | "stable_under_widening" + /** The caller explicitly waived proof. Always recorded, never inferred. */ + | "assumed_complete"; + +export interface CapturedRead { + /** Bytes the command wrote to stdout. Captured to a file, never through a pipe. */ + stdout: string; + /** Bytes the command wrote to stderr. Truncation notices hide here. */ + stderr: string; + /** The command's own exit status — never a pipeline's last stage. */ + code: number; +} + +export interface ClassifyOptions { + /** Key holding the row array. Auto-detected when omitted. */ + rowsKey?: string | undefined; + /** Key holding a self-declared population size. Auto-detected when omitted. */ + totalKey?: string | undefined; + /** An empty result is a legitimate answer rather than a suspicious one. */ + allowEmpty?: boolean | undefined; + /** The row bound that produced this read, if one was passed. */ + limit?: number | undefined; +} + +export interface ReadVerdict { + ok: boolean; + /** Present only when ok. */ + proofs: CompletenessProof[]; + /** Present only when !ok. */ + code?: RefusalCode | undefined; + /** One-line human explanation. Always present. */ + reason: string; + /** Rows, only when ok. Never a degraded [] standing in for a failure. */ + rows: unknown[]; + rowCount: number; + /** The population the surface declared, when it declared one. */ + declaredTotal?: number | undefined; + /** Opaque cursor for the next page, when the surface offered one. */ + nextCursor?: string | null | undefined; + hasMore?: boolean | undefined; + /** Everything the classifier observed, for pasting into a record. */ + evidence: string[]; +} + +const ROWS_KEY_CANDIDATES = [ + "rows", + "entries", + "items", + "results", + "records", + "tasks", + "messages", + "data" +] as const; + +/** + * Keys that mean "the size of the whole population". + * + * `count` is DELIBERATELY ABSENT. Measured on `todos show --json`, `comments_page.count` + * is the size of the page in hand, not of the population, so auto-detecting it would + * manufacture `declared_total_satisfied` on exactly the reads that are truncated. + */ +const TOTAL_KEY_CANDIDATES = ["total", "total_count", "totalCount", "totalItems"] as const; + +/** + * Truncation notices that Hasna CLIs write to STDERR while exiting 0. + * + * Anchors are deliberately avoided. In JavaScript and Python `m` makes `^`/`$` match + * per line and `s` makes `.` match a newline; in jq `m` IS dotall and there is no + * per-line anchor flag at all. A pattern that leans on anchors therefore changes + * meaning when it is carried between those engines, silently and in the direction of + * a false negative. These patterns match substrings and need no anchor. + */ +const STDERR_TRUNCATION_PATTERNS: Array<{ re: RegExp; note: string }> = [ + { re: /showing\s+(\d+)\s+of\s+(\d+)/i, note: "surface reported a bounded read" }, + { re: /\btruncated\b/i, note: "surface used the word truncated" }, + { re: /to see the rest/i, note: "surface offered a route to the remainder" }, + { re: /page with --cursor/i, note: "surface offered a cursor" } +]; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Locate the row array without ever letting a miss become an empty list. */ +export function locateRows( + parsed: unknown, + rowsKey?: string +): { ok: true; rows: unknown[]; key: string } | { ok: false; error: string } { + if (Array.isArray(parsed)) { + return { ok: true, rows: parsed, key: "" }; + } + if (!isPlainObject(parsed)) { + return { ok: false, error: `payload is ${parsed === null ? "null" : typeof parsed}, not a list or an envelope` }; + } + if (rowsKey) { + const explicit = parsed[rowsKey]; + if (!Array.isArray(explicit)) { + return { + ok: false, + error: `--rows-key '${rowsKey}' is ${explicit === undefined ? "absent" : `a ${typeof explicit}`}, not an array` + }; + } + return { ok: true, rows: explicit, key: rowsKey }; + } + for (const candidate of ROWS_KEY_CANDIDATES) { + if (Array.isArray(parsed[candidate])) { + return { ok: true, rows: parsed[candidate] as unknown[], key: candidate }; + } + } + const arrayKeys = Object.keys(parsed).filter((k) => Array.isArray(parsed[k])); + if (arrayKeys.length === 1) { + return { ok: true, rows: parsed[arrayKeys[0]!] as unknown[], key: arrayKeys[0]! }; + } + return { + ok: false, + error: + arrayKeys.length === 0 + ? `no array-valued key in envelope {${Object.keys(parsed).join(",")}}` + : `ambiguous rows key: ${arrayKeys.join(", ")} — pass --rows-key` + }; +} + +/** Read a dotted path such as `by_scope.global` out of a parsed payload. */ +export function readDottedPath(parsed: unknown, path: string): number | undefined { + let cursor: unknown = parsed; + for (const segment of path.split(".")) { + if (!isPlainObject(cursor)) return undefined; + cursor = cursor[segment]; + } + return typeof cursor === "number" ? cursor : undefined; +} + +/** + * Classify one captured read. + * + * Returns `ok: false` with a RefusalCode whenever completeness cannot be + * established from this payload ALONE. That includes the ordinary, healthy-looking + * case of a bare array with no envelope: this function does not know whether such a + * read is complete, and says so rather than guessing. The executor answers that + * question by widening or by a sibling aggregate; see ./safe-read-exec. + */ +export function classifyRead(captured: CapturedRead, options: ClassifyOptions = {}): ReadVerdict { + const evidence: string[] = []; + const refuse = (code: RefusalCode, reason: string): ReadVerdict => ({ + ok: false, + code, + reason, + proofs: [], + rows: [], + rowCount: 0, + evidence + }); + + evidence.push(`exit=${captured.code}`); + evidence.push(`stdout=${captured.stdout.length}B stderr=${captured.stderr.length}B`); + + // --- mechanism 2: FAILED-AS-EMPTY ----------------------------------------- + // rc is checked before the body is even parsed. A CLI that writes + // {"ok":false,...} to stdout at rc=1 is the exact shape that ran the fleet + // knowledge monitor blind for ~11.6h: a reader degraded the dict to [] and a + // guard skipped the branch. + if (captured.code !== 0) { + return refuse( + "nonzero_exit", + `the command exited ${captured.code}. A failed read is not an empty set. stderr: ${ + captured.stderr.trim().slice(0, 300) || "(empty)" + }` + ); + } + + if (captured.stdout.trim() === "") { + return refuse("unparseable_stdout", "stdout was empty; there is no payload to judge"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(captured.stdout); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return refuse( + "unparseable_stdout", + `stdout is not JSON (${message}). A parse failure is a refusal, never an empty list.` + ); + } + + if (isPlainObject(parsed)) { + if (parsed.ok === false) { + const err = typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed).slice(0, 200); + return refuse("error_object", `the surface returned an error object at exit 0: ${err}`); + } + if (typeof parsed.error === "string" && parsed.error.length > 0) { + return refuse("error_object", `the surface returned an error object at exit 0: ${parsed.error}`); + } + if (parsed.store_exists === false) { + return refuse( + "store_unavailable", + "the surface reported store_exists=false; zero rows here means the store was unreachable, not empty" + ); + } + } + + const located = locateRows(parsed, options.rowsKey); + if (!located.ok) { + return refuse( + "rows_key_missing", + `${located.error}. Refusing rather than reporting zero rows: a reader that cannot find the rows has measured nothing.` + ); + } + const rows = located.rows; + const rowCount = rows.length; + evidence.push(`rows=${rowCount} under ${located.key}`); + + // --- mechanism 1: CUT SHORT ------------------------------------------------- + // stderr first, because it is the stream a caller who redirected only stdout + // never sees, and it carries the most explicit notice the fleet emits. + for (const { re, note } of STDERR_TRUNCATION_PATTERNS) { + const hit = re.exec(captured.stderr); + if (hit) { + return refuse( + "stderr_truncation_notice", + `stderr carries a truncation notice (${note}): "${hit[0]}". The body parsed cleanly at exit 0; only stderr says the read was bounded.` + ); + } + } + + let declaredTotal: number | undefined; + let hasMore: boolean | undefined; + let nextCursor: string | null | undefined; + + if (isPlainObject(parsed)) { + const totalKeys = options.totalKey ? [options.totalKey] : TOTAL_KEY_CANDIDATES; + for (const key of totalKeys) { + if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue; + const value = parsed[key]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return { + ...refuse( + "declared_total_mismatch", + `declared ${key} must be a non-negative safe integer, got ${JSON.stringify(value)}` + ), + rowCount + }; + } + declaredTotal = value; + evidence.push(`declared ${key}=${value}`); + break; + } + if (typeof parsed.has_more === "boolean") hasMore = parsed.has_more; + else if (typeof parsed.hasMore === "boolean") hasMore = parsed.hasMore; + const cursor = parsed.next_cursor ?? parsed.nextCursor; + if (typeof cursor === "string" || cursor === null) nextCursor = cursor as string | null; + } + + const usableCursor = typeof nextCursor === "string" && nextCursor.trim().length > 0; + if (hasMore === true || usableCursor) { + // This refusal MUST carry the cursor. It is the one refusal a caller can act on + // automatically, and an executor that cannot see next_cursor here silently + // stops after page one — measured: the paging fixture accumulated 2 rows of 5 + // because an earlier version of this branch dropped the field. + return { + ...refuse( + "unfollowed_cursor", + `the surface indicates another page (has_more=${String(hasMore)}, cursor ${usableCursor ? nextCursor : "unset"}). ` + + `You are holding a page and about to call it a population.` + ), + rowCount, + declaredTotal, + nextCursor, + hasMore + }; + } + + if (declaredTotal !== undefined && rowCount !== declaredTotal) { + return refuse( + "declared_total_mismatch", + `holding ${rowCount} row(s) against a declared total of ${declaredTotal}. ` + + `The surface's count and payload disagree, so completeness is not established.` + ); + } + + // --- proofs ------------------------------------------------------------------ + const proofs: CompletenessProof[] = []; + if (declaredTotal !== undefined && rowCount === declaredTotal) { + proofs.push("declared_total_satisfied"); + evidence.push(`rowCount ${rowCount} equals declared total ${declaredTotal}`); + } + if (hasMore === false || nextCursor === null) { + proofs.push("cursor_exhausted"); + evidence.push(`surface declared no next page (has_more=${hasMore}, next_cursor=${String(nextCursor)})`); + } + + if (proofs.length === 0) { + // The quiet case. Nothing in this payload is wrong; nothing in it proves + // completeness either. Whether the read is whole is a question this payload + // cannot answer, and the honest verdict is that it is unproven. + const cap = + options.limit !== undefined && rowCount === options.limit + ? ` rowCount equals the requested limit ${options.limit}, which is what a silent cap looks like.` + : ""; + return { + ok: false, + code: rowCount === options.limit ? "page_cap_reached" : "completeness_unproven", + reason: + `the payload carries no total, no cursor and no truncation notice, so nothing in it proves the read is whole.${cap} ` + + `Establish completeness by widening the bound, or by a sibling aggregate.`, + proofs: [], + rows: [], + rowCount, + declaredTotal, + nextCursor, + hasMore, + evidence + }; + } + + if (rowCount === 0 && !options.allowEmpty) { + evidence.push("empty result accepted only because a proof of completeness was present"); + } + + return { ok: true, proofs, reason: `read proven complete by ${proofs.join(" + ")}`, rows, rowCount, declaredTotal, nextCursor, hasMore, evidence }; +} diff --git a/tests/safe-read.test.ts b/tests/safe-read.test.ts new file mode 100644 index 0000000..3124bb4 --- /dev/null +++ b/tests/safe-read.test.ts @@ -0,0 +1,607 @@ +/** + * Two-sided fixtures for safe-read. + * + * FOUR MECHANISMS, EACH WITH BOTH ARMS. A one-sided test passes whichever way round + * the condition is installed, which is the failure this whole module exists to + * refuse — so a fixture set that only exercised the happy path would be this defect + * inside its own fix. + * + * mechanism 1 CUT SHORT truncated -> REFUSE | untruncated -> PASS + * mechanism 2 FAILED-AS-EMPTY error -> REFUSE | genuine empty -> PASS + * mechanism 3 UNPAGINATED capped -> REFUSE | provably whole -> PASS + * mechanism 4 PREDICATE IGNORED inert -> REFUSE | discriminating -> PASS + * + * FIXTURE HYGIENE. Every fake surface here is a pure in-memory function. Nothing + * spawns a process and nothing writes to a path, so a copied fixture cannot land + * output in a real log directory; the one test that does touch the filesystem uses + * the OS temp dir via mkdtemp and removes it. See "fixtures write nowhere" below, + * which asserts that property rather than leaving it to inspection. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { classifyRead, locateRows, readDottedPath, type CapturedRead } from "../src/safe-read"; +import { KNOWN_CLAMPS, lookupClamp, runCaptured, safeRead } from "../src/safe-read-exec"; +import { runSafeReadCli } from "../src/cli/read"; + +const ok = (stdout: string, stderr = ""): CapturedRead => ({ stdout, stderr, code: 0 }); + +/** A fake surface: maps an argv to a captured read. Spawns nothing, writes nothing. */ +function surface(handler: (argv: string[]) => CapturedRead) { + const calls: string[][] = []; + return { + calls, + run: (argv: string[]) => { + calls.push([...argv]); + return handler(argv); + } + }; +} + +function flagValue(argv: string[], flag: string): number | undefined { + const at = argv.indexOf(flag); + return at >= 0 && at + 1 < argv.length ? Number(argv[at + 1]) : undefined; +} + +// --------------------------------------------------------------------------- +// MECHANISM 1 — CUT SHORT +// --------------------------------------------------------------------------- +describe("mechanism 1: cut short", () => { + test("KNOWN-BAD: a stderr-only truncation notice is refused (the repos shape)", () => { + // Measured live 2026-08-11: `repos repos --json` returns 50 rows at rc=0 with a + // clean body; the notice appears only on stderr. A checker reading stdout alone + // passes this, which is why the helper captures both streams. + const verdict = classifyRead( + ok( + JSON.stringify(Array.from({ length: 50 }, (_, i) => ({ id: i }))), + "warning: showing 50 of 1793 repo(s) (offset 0, limit 50). Pass -n 1793 or page with --cursor 50 to see the rest.\n" + ) + ); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("stderr_truncation_notice"); + expect(verdict.rows).toEqual([]); + }); + + test("KNOWN-GOOD: the same body with a clean stderr and a satisfied total passes", () => { + const verdict = classifyRead( + ok(JSON.stringify({ total: 50, rows: Array.from({ length: 50 }, (_, i) => ({ id: i })) }), "") + ); + expect(verdict.ok).toBe(true); + expect(verdict.proofs).toContain("declared_total_satisfied"); + expect(verdict.rowCount).toBe(50); + }); + + test("KNOWN-BAD: rows short of a declared total is refused", () => { + const verdict = classifyRead(ok(JSON.stringify({ total: 1524, rows: [{ id: 1 }, { id: 2 }] }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("declared_total_mismatch"); + }); + + test("KNOWN-BAD: rows above, negative, fractional, or non-numeric declared totals are refused", () => { + for (const payload of [ + { total: 1, rows: [1, 2] }, + { total: -1, rows: [] }, + { total: 1.5, rows: [1, 2] }, + { total: "2", rows: [1, 2] } + ]) { + const verdict = classifyRead(ok(JSON.stringify(payload))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("declared_total_mismatch"); + } + }); + + test("KNOWN-BAD: has_more=true is refused; KNOWN-GOOD: has_more=false passes", () => { + const bad = classifyRead(ok(JSON.stringify({ rows: [1, 2], has_more: true, next_cursor: "c1" }))); + expect(bad.ok).toBe(false); + expect(bad.code).toBe("unfollowed_cursor"); + + const good = classifyRead(ok(JSON.stringify({ rows: [1, 2], has_more: false, next_cursor: null }))); + expect(good.ok).toBe(true); + expect(good.proofs).toContain("cursor_exhausted"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 2 — FAILED-AS-EMPTY +// --------------------------------------------------------------------------- +describe("mechanism 2: failed-as-empty", () => { + test("KNOWN-BAD: {ok:false} on stdout at rc=1 is refused, not degraded to []", () => { + // The shape that ran the fleet knowledge monitor blind for ~11.6h. + const verdict = classifyRead({ stdout: JSON.stringify({ ok: false, error: "store unreachable" }), stderr: "", code: 1 }); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("nonzero_exit"); + expect(verdict.rows).toEqual([]); + expect(verdict.rowCount).toBe(0); + }); + + test("KNOWN-BAD: an error object at rc=0 is still refused", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: false, error: 'Project not found: "iproj-ads-ops"' }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("error_object"); + expect(verdict.reason).toContain("iproj-ads-ops"); + }); + + test("KNOWN-BAD: store_exists=false is refused even with a well-formed empty list", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: true, store_exists: false, total: 0, rows: [] }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("store_unavailable"); + }); + + test("KNOWN-BAD: unparseable stdout is refused rather than read as zero rows", () => { + const verdict = classifyRead(ok('{"rows":[1,2')); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("unparseable_stdout"); + }); + + test("KNOWN-BAD: a missing rows key refuses instead of reporting zero", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: true, total: 3, payload: { nested: true } }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("rows_key_missing"); + }); + + test("KNOWN-GOOD: a genuinely empty set with a declared total of 0 passes", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: true, store_exists: true, total: 0, rows: [] })), { + allowEmpty: true + }); + expect(verdict.ok).toBe(true); + expect(verdict.rowCount).toBe(0); + expect(verdict.proofs).toContain("declared_total_satisfied"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 3 — UNPAGINATED +// --------------------------------------------------------------------------- +describe("mechanism 3: unpaginated", () => { + test("KNOWN-BAD: a complete-looking first page with no marker is refused", () => { + // rc=0, valid JSON, clean stderr, no has_more, no total. Nothing in this payload + // is wrong and nothing in it proves the read is whole. + const verdict = classifyRead(ok(JSON.stringify(Array.from({ length: 20 }, (_, i) => i))), { limit: 20 }); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("page_cap_reached"); + }); + + test("KNOWN-BAD: probe-guard's blind spot — 50 rows, no marker, is not a pass here", () => { + const verdict = classifyRead(ok(JSON.stringify(Array.from({ length: 50 }, (_, i) => i)))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("completeness_unproven"); + }); + + test("KNOWN-GOOD: widening below the bound proves completeness", () => { + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + return ok(JSON.stringify(Array.from({ length: Math.min(37, limit) }, (_, i) => i))); + }); + const result = safeRead({ argv: ["fake", "list", "--json"], limit: 20, limitFlag: "--limit", run: s.run }); + expect(result.ok).toBe(true); + expect(result.proofs).toContain("stable_under_widening"); + expect(result.rowCount).toBe(37); + }); + + test("KNOWN-BAD: a HIDDEN CLAMP defeats widening and must be refused", () => { + // knowledge k_mso1r678_fhgm1o: `conversations read` silently returns 500 for any + // request above 500. Widening 200 -> 800 yields 500 rows, which is below 800 — + // so `count < bound` would wrongly read as complete. Two-step widening cannot + // rescue it either: a true population of 500 and a cap of 500 are identical + // under every bound. The clamp census is what makes this refusable. + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + const served = Math.min(limit, 500); // the silent clamp + return ok(JSON.stringify(Array.from({ length: served }, (_, i) => i))); + }); + const result = safeRead({ + argv: ["conversations", "read", "--channel", "board", "--json"], + limit: 200, + limitFlag: "--limit", + widenTo: 800, + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("hidden_clamp_suspected"); + expect(result.reason).toContain("500"); + }); + + test("KNOWN-BAD: an uncensused surface landing on a round number is refused", () => { + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + return ok(JSON.stringify(Array.from({ length: Math.min(limit, 100) }, (_, i) => i))); + }); + const result = safeRead({ argv: ["unknowntool", "list"], limit: 50, limitFlag: "--limit", widenTo: 400, run: s.run }); + expect(result.ok).toBe(false); + expect(result.code).toBe("hidden_clamp_suspected"); + }); + + test("KNOWN-GOOD: --known-clamp lets a real population above a round number pass", () => { + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + return ok(JSON.stringify(Array.from({ length: Math.min(limit, 100) }, (_, i) => i))); + }); + const result = safeRead({ + argv: ["unknowntool", "list"], + limit: 50, + limitFlag: "--limit", + widenTo: 400, + knownClamp: 5000, + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.proofs).toContain("stable_under_widening"); + expect(result.rowCount).toBe(100); + }); + + test("KNOWN-GOOD: a cursor is followed to exhaustion and rows accumulate", () => { + const pages: Record = { + "": { rows: [1, 2], next: "p2" }, + p2: { rows: [3, 4], next: "p3" }, + p3: { rows: [5], next: null } + }; + const s = surface((argv) => { + const at = argv.indexOf("--cursor"); + const key = at >= 0 ? argv[at + 1]! : ""; + const page = pages[key]!; + return ok(JSON.stringify({ rows: page.rows, has_more: page.next !== null, next_cursor: page.next })); + }); + const result = safeRead({ argv: ["fake", "digest"], cursorFlag: "--cursor", run: s.run }); + expect(result.ok).toBe(true); + expect(result.proofs).toContain("cursor_exhausted"); + expect(result.rows).toEqual([1, 2, 3, 4, 5]); + expect(result.pages).toBe(3); + }); + + test("KNOWN-BAD: has_more without a usable cursor is refused, never treated as exhausted", () => { + for (const nextCursor of [undefined, null, ""]) { + const s = surface(() => ok(JSON.stringify({ rows: [1, 2], has_more: true, next_cursor: nextCursor }))); + const result = safeRead({ argv: ["fake", "digest"], cursorFlag: "--cursor", run: s.run }); + expect(result.ok).toBe(false); + expect(result.code).toBe("unfollowed_cursor"); + } + }); + + test("KNOWN-BAD: a non-empty next cursor is followed even when has_more contradicts it", () => { + const s = surface((argv) => + argv.includes("p2") + ? ok(JSON.stringify({ rows: [3], has_more: false, next_cursor: null })) + : ok(JSON.stringify({ rows: [1, 2], has_more: false, next_cursor: "p2" })) + ); + const result = safeRead({ argv: ["fake", "digest"], cursorFlag: "--cursor", run: s.run }); + expect(result.ok).toBe(true); + expect(result.rows).toEqual([1, 2, 3]); + expect(result.pages).toBe(2); + }); + + test("KNOWN-BAD: widening cannot turn a failed second read into a successful empty population", () => { + let calls = 0; + const s = surface(() => + ++calls === 1 + ? ok(JSON.stringify(Array.from({ length: 20 }, (_, i) => i))) + : ok(JSON.stringify({ ok: true, store_exists: false, rows: [] })) + ); + const result = safeRead({ + argv: ["fake", "list"], + limit: 20, + limitFlag: "--limit", + widenTo: 80, + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("store_unavailable"); + expect(result.rows).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// THE ARM THAT MUST NOT FAIL CLOSED — the envelope-less mementos shape +// --------------------------------------------------------------------------- +describe("envelope-less surfaces are not fail-closed", () => { + test("KNOWN-GOOD: a bare array with NO envelope is proven by a sibling aggregate", () => { + // `mementos list --json` returns a bare array: no total, no has_more, no + // store_exists. A guard that fail-closed whenever a total is absent would refuse + // this surface forever, which is a broken helper rather than a safe one. The + // aggregate lives on the SIBLING verb `mementos stats --json`. + const s = surface((argv) => { + if (argv.includes("stats")) { + // Note `total` here is by_status.active and EXCLUDES archived — which is why + // the caller names the path and the helper never guesses one. + return ok(JSON.stringify({ total: 640, by_status: { active: 640, archived: 41 }, by_scope: { global: 681, shared: 337 } })); + } + return ok(JSON.stringify(Array.from({ length: 681 }, (_, i) => ({ key: `m${i}` })))); + }); + const result = safeRead({ + argv: ["mementos", "list", "--scope", "global", "--json"], + siblingArgv: ["mementos", "stats", "--json"], + siblingPath: "by_scope.global", + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.proofs).toEqual(["sibling_aggregate_agrees"]); + expect(result.rowCount).toBe(681); + }); + + test("KNOWN-BAD: the same bare-array surface is refused when the sibling disagrees", () => { + const s = surface((argv) => + argv.includes("stats") + ? ok(JSON.stringify({ by_scope: { global: 681 } })) + : ok(JSON.stringify(Array.from({ length: 400 }, (_, i) => ({ key: `m${i}` })))) + ); + const result = safeRead({ + argv: ["mementos", "list", "--scope", "global", "--json"], + siblingArgv: ["mementos", "stats", "--json"], + siblingPath: "by_scope.global", + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("declared_total_mismatch"); + }); + + test("a bare array is refused with a NAMED, actionable code rather than a generic failure", () => { + const verdict = classifyRead(ok(JSON.stringify([{ key: "a" }, { key: "b" }]))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("completeness_unproven"); + expect(verdict.reason).toContain("sibling aggregate"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 4 — PREDICATE IGNORED +// --------------------------------------------------------------------------- +describe("mechanism 4: predicate silently ignored", () => { + test("KNOWN-BAD: a query that returns rows for a predicate matching NOTHING is refused", () => { + // The `gh search issues` shape: identical rows for two different queries, and + // `org:` ignored outright. Every count taken from such a surface is a count of + // the wrong population, and no amount of paging repairs it. + const s = surface(() => ok(JSON.stringify(Array.from({ length: 30 }, (_, i) => ({ id: i }))))); + const result = safeRead({ + argv: ["faketool", "search", "widget"], + probeNegativeArgs: ["zzz-no-such-token-zzz"], + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("predicate_ignored"); + }); + + test("KNOWN-GOOD: a discriminating query passes both probes", () => { + const s = surface((argv) => { + if (argv.includes("zzz-no-such-token-zzz")) return ok("[]"); + if (argv.includes("known-present")) return ok(JSON.stringify([{ id: 1 }, { id: 2 }])); + return ok(JSON.stringify({ total: 3, rows: [{ id: 1 }, { id: 2 }, { id: 3 }] })); + }); + const result = safeRead({ + argv: ["faketool", "search", "widget"], + probeNegativeArgs: ["zzz-no-such-token-zzz"], + probePositiveArgs: ["known-present"], + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.rowCount).toBe(3); + }); + + test("KNOWN-BAD: a predicate known to match returning zero is refused as inert", () => { + const s = surface(() => ok("[]")); + const result = safeRead({ + argv: ["faketool", "search", "widget"], + probePositiveArgs: ["known-present"], + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("predicate_inert"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 5 — DEFAULTED SCOPE +// --------------------------------------------------------------------------- +describe("mechanism 5: defaulted scope", () => { + test("KNOWN-BAD: a scope-defaulting surface is refused even when every other check is clean", () => { + // The read is complete, the total is honest, the rows reconcile against it + // three ways — and it is 41 items short. This is the arm that proves + // total-reconciliation cannot see a defaulted scope. + const s = surface(() => ok(JSON.stringify({ total: 1526, rows: Array.from({ length: 1526 }, (_, i) => i) }))); + const result = safeRead({ argv: ["knowledge", "list", "--json"], run: s.run }); + expect(result.ok).toBe(false); + expect(result.code).toBe("scope_defaulted"); + expect(result.reason).toContain("--include-archived"); + // The surface was never even called: the refusal is cheap and pre-read. + expect(s.calls.length).toBe(0); + }); + + test("KNOWN-GOOD: passing the widening flag proves the wider scope and stamps it on the result", () => { + const s = surface(() => ok(JSON.stringify({ total: 1567, rows: Array.from({ length: 1567 }, (_, i) => i) }))); + const result = safeRead({ argv: ["knowledge", "list", "--include-archived", "--json"], run: s.run }); + expect(result.ok).toBe(true); + expect(result.rowCount).toBe(1567); + expect(result.scope).toBe("--include-archived"); + expect(result.reason).toContain("scope: --include-archived"); + }); + + test("KNOWN-GOOD: the narrow scope is allowed only as an explicit, recorded choice", () => { + const s = surface(() => ok(JSON.stringify({ total: 1526, rows: Array.from({ length: 1526 }, (_, i) => i) }))); + const result = safeRead({ + argv: ["knowledge", "list", "--json"], + scopeAck: "active items only, archived deliberately excluded", + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.scope).toContain("acknowledged"); + expect(result.reason).toContain("archived deliberately excluded"); + }); + + test("todos --all is censused the same way", () => { + const s = surface(() => ok(JSON.stringify({ total: 7301, rows: Array.from({ length: 7301 }, (_, i) => i) }))); + const narrow = safeRead({ argv: ["todos", "list", "--json"], run: s.run }); + expect(narrow.ok).toBe(false); + expect(narrow.code).toBe("scope_defaulted"); + expect(narrow.reason).toContain("--all"); + }); + + test("every count carries its scope, including on surfaces with no known widener", () => { + const s = surface(() => ok(JSON.stringify({ total: 3, rows: [1, 2, 3] }))); + const result = safeRead({ argv: ["faketool", "list"], run: s.run }); + expect(result.ok).toBe(true); + expect(typeof result.scope).toBe("string"); + expect(result.scope.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// The helper's own capture path and CLI surface +// --------------------------------------------------------------------------- +describe("capture path and CLI", () => { + test("the executor spawns from argv and never through a shell", () => { + // If this went through a shell, the metacharacters would be interpreted and the + // argument would not survive intact. It is echoed back verbatim instead. + const payload = '`whoami` $(id -u) ; rm -rf /tmp/nope'; + const captured = runCaptured(["printf", "%s", payload]); + expect(captured.code).toBe(0); + expect(captured.stdout).toBe(payload); + }); + + test("REGRESSION: a payload well past 1 MiB survives the capture path intact", () => { + // The first implementation used spawnSync(encoding:"utf8", maxBuffer:256MiB) and + // truncated a 1.23 MB read to 1079970 bytes at status 0 with error undefined — + // the exact 1 MiB-default figure, so the option never reached the call, and the + // ENOBUFS a 1 MiB run does raise was not surfaced either. A clean exit-0 short + // read, produced by the tool that exists to refuse clean exit-0 short reads. + // File descriptors have no such ceiling; this asserts the payload round-trips. + const rows = Array.from({ length: 40000 }, (_, i) => ({ id: i, pad: "0123456789abcdef" })); + const payload = JSON.stringify(rows); + expect(payload.length).toBeGreaterThan(1024 * 1024); + // The payload goes through a temp FILE rather than an argv element: Linux caps a + // single argument at MAX_ARG_STRLEN (128 KiB), so passing it inline would fail + // the spawn for a reason unrelated to what is under test. + const dir = mkdtempSync(join(tmpdir(), "safe-read-big-")); + try { + const file = join(dir, "big.json"); + writeFileSync(file, payload); + const captured = runCaptured(["cat", file]); + expect(captured.code).toBe(0); + expect(captured.stdout.length).toBe(payload.length); + const verdict = classifyRead(captured); + // Parses cleanly — the point is that it is not cut mid-string. + expect(verdict.code).not.toBe("unparseable_stdout"); + expect(verdict.rowCount).toBe(40000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("stdout and stderr are captured separately and never merged", () => { + // A merged capture corrupts the JSON with whatever diagnostics the tool emitted, + // which is how a valid payload becomes an unparseable one. + const captured = runCaptured(["sh", "-c", 'printf "{\\"rows\\":[]}"; printf "warning: showing 5 of 900" >&2']); + expect(captured.stdout).toBe('{"rows":[]}'); + expect(captured.stderr).toContain("showing 5 of 900"); + // And the stderr notice is what decides the verdict, not the clean body. + const verdict = classifyRead(captured); + expect(verdict.code).toBe("stderr_truncation_notice"); + }); + + test("a nonzero exit from the real spawner is surfaced, not swallowed", () => { + const captured = runCaptured(["false"]); + expect(captured.code).toBe(1); + const verdict = classifyRead(captured); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("nonzero_exit"); + }); + + test("CLI returns 2 on refusal and prints NOTHING to stdout", () => { + const out: string[] = []; + const err: string[] = []; + const code = runSafeReadCli(["false"], { json: false }, { log: (s) => out.push(s), err: (s) => err.push(s) }); + expect(code).toBe(2); + expect(out).toEqual([]); + expect(err.join("\n")).toContain("REFUSED"); + }); + + test("CLI --json omits `rows` entirely on refusal so a consumer cannot read a plausible zero", () => { + const out: string[] = []; + const code = runSafeReadCli(["false"], { json: true }, { log: (s) => out.push(s), err: () => {} }); + expect(code).toBe(2); + const parsed = JSON.parse(out.join("\n")); + expect(parsed.ok).toBe(false); + expect(parsed.rows).toBeUndefined(); + expect(parsed.scope).toBe("default"); + }); + + test("CLI --json carries scope on success", () => { + const out: string[] = []; + const code = runSafeReadCli( + ["bun", "-e", "console.log(JSON.stringify({total:1,rows:[{id:1}]}))"], + { json: true }, + { log: (s) => out.push(s), err: () => {} } + ); + expect(code).toBe(0); + const parsed = JSON.parse(out.join("\n")); + expect(parsed.ok).toBe(true); + expect(parsed.scope).toBe("default"); + }); + + test("CLI returns 3 for usage, distinct from 2 for a fired guard", () => { + const code = runSafeReadCli([], { json: false }, { log: () => {}, err: () => {} }); + expect(code).toBe(3); + }); + + test("--assume-complete records the waiver in the evidence and is never silent", () => { + const s = surface(() => ok(JSON.stringify([1, 2, 3]))); + const result = safeRead({ argv: ["fake", "list"], assumeComplete: true, run: s.run }); + expect(result.ok).toBe(true); + expect(result.proofs).toEqual(["assumed_complete"]); + expect(result.evidence.join(" ")).toContain("WAIVED"); + }); +}); + +// --------------------------------------------------------------------------- +// Supporting units and fixture hygiene +// --------------------------------------------------------------------------- +describe("supporting units", () => { + test("locateRows never turns a miss into an empty list", () => { + expect(locateRows({ nope: 1 }).ok).toBe(false); + expect(locateRows({ rows: [1] }).ok).toBe(true); + expect(locateRows([1, 2]).ok).toBe(true); + }); + + test("`count` is not auto-detected as a total (it is a page size on todos)", () => { + const verdict = classifyRead(ok(JSON.stringify({ count: 100, rows: Array.from({ length: 100 }, (_, i) => i) })), { + limit: 100 + }); + expect(verdict.ok).toBe(false); + expect(verdict.declaredTotal).toBeUndefined(); + }); + + test("readDottedPath reaches a nested aggregate and returns undefined for a miss", () => { + expect(readDottedPath({ by_scope: { global: 681 } }, "by_scope.global")).toBe(681); + expect(readDottedPath({ by_scope: { global: 681 } }, "by_scope.shared")).toBeUndefined(); + }); + + test("the clamp census resolves the longest matching verb prefix", () => { + expect(lookupClamp(["conversations", "agents", "list", "--json"])?.cap).toBe(500); + expect(lookupClamp(["conversations", "read", "--channel", "x"])?.cap).toBe(500); + expect(lookupClamp(["totally", "unknown"])).toBeNull(); + for (const [key, entry] of Object.entries(KNOWN_CLAMPS)) { + expect(entry.grade === "M" || entry.grade === "S/U").toBe(true); + expect(key.length).toBeGreaterThan(0); + } + }); + + test("fixtures write nowhere: the only filesystem use is an OS temp dir", () => { + // Guards the trap that a copied fixture writes into a real log directory. Every + // other test above is pure in-memory; this one proves the single filesystem + // interaction is confined to mkdtemp and is cleaned up. + const dir = mkdtempSync(join(tmpdir(), "safe-read-fixture-")); + try { + const file = join(dir, "capture.json"); + writeFileSync(file, JSON.stringify({ total: 1, rows: [{ id: 1 }] })); + const verdict = classifyRead(ok(readFileSync(file, "utf8"))); + expect(verdict.ok).toBe(true); + expect(dir.startsWith(tmpdir())).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +afterAll(() => { + // Nothing global to tear down; asserted here so a future fixture that acquires + // state has an obvious place to release it. +});