diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5e9af..dd19988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to `@hasna/contracts` are documented here. pagination across numeric cursors. - Adds the metadata-only `verify-write` command for checking stored writes without rendering capability-bearing content. +- Keeps `safe-read` refusals metadata-only so child stderr and error-object + payloads cannot be reproduced in terminal output or durable transcripts. PATCH, not minor. This ships additive operational verification helpers without changing existing contract shapes. diff --git a/src/safe-read-exec.ts b/src/safe-read-exec.ts index e7df6db..a8b5ba6 100644 --- a/src/safe-read-exec.ts +++ b/src/safe-read-exec.ts @@ -505,7 +505,7 @@ function safeReadInner(request: SafeReadRequest): SafeReadResult { return fail( "completeness_unproven", `widening probe to ${flag} ${wider} exited ${widened.code}, so completeness is still unproven. ` + - `stderr: ${widened.stderr.trim().slice(0, 200)}`, + `Captured stderr was ${widened.stderr.length} byte(s); content was not rendered.`, evidence, pages ); diff --git a/src/safe-read.ts b/src/safe-read.ts index 5843ad6..71e8c5b 100644 --- a/src/safe-read.ts +++ b/src/safe-read.ts @@ -253,9 +253,8 @@ export function classifyRead(captured: CapturedRead, options: ClassifyOptions = 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)" - }` + `the command exited ${captured.code}. A failed read is not an empty set. ` + + `Captured stderr was ${captured.stderr.length} byte(s); content was not rendered.` ); } @@ -266,21 +265,19 @@ export function classifyRead(captured: CapturedRead, options: ClassifyOptions = let parsed: unknown; try { parsed = JSON.parse(captured.stdout); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + } catch { return refuse( "unparseable_stdout", - `stdout is not JSON (${message}). A parse failure is a refusal, never an empty list.` + "stdout was not valid JSON; parse details were not rendered. 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}`); + return refuse("error_object", "the surface returned an error object at exit 0; payload content was not rendered"); } if (typeof parsed.error === "string" && parsed.error.length > 0) { - return refuse("error_object", `the surface returned an error object at exit 0: ${parsed.error}`); + return refuse("error_object", "the surface returned an error object at exit 0; payload content was not rendered"); } if (parsed.store_exists === false) { return refuse( @@ -309,7 +306,8 @@ export function classifyRead(captured: CapturedRead, options: ClassifyOptions = 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.` + `stderr carries a truncation notice (${note}); content was not rendered. ` + + "The body parsed cleanly at exit 0; only stderr says the read was bounded." ); } } diff --git a/tests/safe-read.test.ts b/tests/safe-read.test.ts index c005052..a47adcf 100644 --- a/tests/safe-read.test.ts +++ b/tests/safe-read.test.ts @@ -27,6 +27,7 @@ import { KNOWN_CLAMPS, lookupClamp, runCaptured, safeRead } from "../src/safe-re import { runSafeReadCli } from "../src/cli/read"; const ok = (stdout: string, stderr = ""): CapturedRead => ({ stdout, stderr, code: 0 }); +const SENSITIVE_OUTPUT_SENTINEL = "FAKE-SENSITIVE-STDERR-SENTINEL"; /** A fake surface: maps an argv to a captured read. Spawns nothing, writes nothing. */ function surface(handler: (argv: string[]) => CapturedRead) { @@ -155,11 +156,27 @@ describe("mechanism 2: failed-as-empty", () => { expect(verdict.rowCount).toBe(0); }); + test("REGRESSION: a nonzero child exit reports metadata without reproducing stderr", () => { + const verdict = classifyRead({ stdout: "", stderr: SENSITIVE_OUTPUT_SENTINEL, code: 1 }); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("nonzero_exit"); + expect(verdict.reason).toContain("exited 1"); + expect(verdict.reason).toContain(`Captured stderr was ${SENSITIVE_OUTPUT_SENTINEL.length} byte(s)`); + expect(`${verdict.reason}\n${verdict.evidence.join("\n")}`).not.toContain(SENSITIVE_OUTPUT_SENTINEL); + }); + 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"); + expect(verdict.reason).not.toContain("iproj-ads-ops"); + }); + + test("REGRESSION: an error-object payload is never copied into refusal output", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: false, error: SENSITIVE_OUTPUT_SENTINEL }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("error_object"); + expect(`${verdict.reason}\n${verdict.evidence.join("\n")}`).not.toContain(SENSITIVE_OUTPUT_SENTINEL); }); test("KNOWN-BAD: store_exists=false is refused even with a well-formed empty list", () => { @@ -366,6 +383,27 @@ describe("mechanism 3: unpaginated", () => { expect(result.code).toBe("store_unavailable"); expect(result.rows).toEqual([]); }); + + test("REGRESSION: a failed widening probe reports metadata without reproducing stderr", () => { + let calls = 0; + const s = surface(() => + ++calls === 1 + ? ok(JSON.stringify(Array.from({ length: 20 }, (_, i) => i))) + : { stdout: "", stderr: SENSITIVE_OUTPUT_SENTINEL, code: 1 } + ); + const result = safeRead({ + argv: ["fake", "list"], + limit: 20, + limitFlag: "--limit", + widenTo: 80, + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("completeness_unproven"); + expect(result.reason).toContain("exited 1"); + expect(result.reason).toContain(`Captured stderr was ${SENSITIVE_OUTPUT_SENTINEL.length} byte(s)`); + expect(`${result.reason}\n${result.evidence.join("\n")}`).not.toContain(SENSITIVE_OUTPUT_SENTINEL); + }); }); // --------------------------------------------------------------------------- @@ -639,6 +677,65 @@ describe("capture path and CLI", () => { expect(parsed.scope).toBe("default"); }); + test("REGRESSION: human and JSON refusal output never reproduces child stderr or error payloads", () => { + const dir = mkdtempSync(join(tmpdir(), "safe-read-redaction-")); + const stderrFixture = join(dir, "stderr-fixture.js"); + const errorObjectFixture = join(dir, "error-object-fixture.js"); + const wideningFixture = join(dir, "widening-fixture.js"); + writeFileSync(stderrFixture, `console.error(${JSON.stringify(SENSITIVE_OUTPUT_SENTINEL)}); process.exit(1);\n`); + writeFileSync( + errorObjectFixture, + `process.stdout.write(JSON.stringify({ok:false,error:${JSON.stringify(SENSITIVE_OUTPUT_SENTINEL)}}));\n` + ); + writeFileSync( + wideningFixture, + `const limitAt = process.argv.indexOf("--limit"); const limit = limitAt >= 0 ? process.argv[limitAt + 1] : undefined;\n` + + `if (limit === "80") { console.error(${JSON.stringify(SENSITIVE_OUTPUT_SENTINEL)}); process.exit(7); }\n` + + `process.stdout.write(JSON.stringify(Array.from({length:20},(_,i)=>i)));\n` + ); + const commands = [ + [process.execPath, stderrFixture], + [process.execPath, errorObjectFixture] + ]; + + try { + for (const json of [false, true]) { + for (const command of commands) { + const out: string[] = []; + const err: string[] = []; + const code = runSafeReadCli(command, { json }, { log: (s) => out.push(s), err: (s) => err.push(s) }); + const rendered = `${out.join("\n")}\n${err.join("\n")}`; + expect(code).toBe(2); + expect(rendered).not.toContain(SENSITIVE_OUTPUT_SENTINEL); + if (command === commands[0]) { + expect(rendered).toContain("nonzero_exit"); + expect(rendered).toContain("exited 1"); + expect(rendered).toContain(`Captured stderr was ${SENSITIVE_OUTPUT_SENTINEL.length + 1} byte(s)`); + } else { + expect(rendered).toContain("error_object"); + expect(rendered).toContain("payload content was not rendered"); + } + } + + const wideningOut: string[] = []; + const wideningErr: string[] = []; + const wideningCode = runSafeReadCli( + [process.execPath, wideningFixture], + { json, limit: "20", limitFlag: "--limit", widenTo: "80" }, + { log: (s) => wideningOut.push(s), err: (s) => wideningErr.push(s) } + ); + const wideningRendered = `${wideningOut.join("\n")}\n${wideningErr.join("\n")}`; + expect(wideningCode).toBe(2); + expect(wideningRendered).not.toContain(SENSITIVE_OUTPUT_SENTINEL); + expect(wideningRendered).toContain("completeness_unproven"); + expect(wideningRendered).toContain("widening probe to --limit 80 exited 7"); + expect(wideningRendered).toContain(`Captured stderr was ${SENSITIVE_OUTPUT_SENTINEL.length + 1} byte(s)`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("CLI --json carries scope on success", () => { const out: string[] = []; const code = runSafeReadCli(