Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/safe-read-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
18 changes: 8 additions & 10 deletions src/safe-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
);
}

Expand All @@ -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(
Expand Down Expand Up @@ -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."
);
}
}
Expand Down
99 changes: 98 additions & 1 deletion tests/safe-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down
Loading