fix(0.3.2 slice 01 BC): HG-6 CLI-side validation — --max-files + --exit (no silent coercion)

Addresses guard BLOCKING-CONCERN verdict on a28f9b71 (guard qitem
qitem-20260516095128-843fbff5; 2 findings).

BLOCK 1 — rig workspace validate --max-files silently coerced garbage.
  `--max-files 12abc` was passed through Number.parseInt which returns
  12, and `--max-files abc` returned NaN that JSON-serialized to null.
  Either way the daemon was called with bogus input.

  Fix: exported parseMaxFilesStrict() in workspace.ts. Pattern
  /^[1-9][0-9]*$/ rejects `12abc` / `abc` / `0` / `-1` / `""` and
  throws an Error carrying fact/consequence/action fields. The
  validate action checks BEFORE withClient runs; on failure emits a
  3-part error (JSON-shape `{ ok: false, error: { fact, consequence,
  action } }` in --json mode; 3-line stderr in human mode), sets
  exitCode = 1, and returns WITHOUT calling the daemon.

  Discriminators (8 new tests in workspace-max-files-validation.test.ts):
    - parseMaxFilesStrict: accepts 1/12/10000; rejects 12abc/abc/""/0/-1;
      the 3-part error carries fact + consequence + action fields
    - end-to-end: --max-files 12abc --json → exit 1, parsed JSON has
      ok=false + 3-part error.fact/consequence/action, client.post()
      never called
    - parameterized check across abc/0/-1
    - human-mode emits 3-part to stderr (Error: ... did not run ... Pass
      a positive integer)

BLOCK 2 — rig workflow project --exit silently forwarded any string.
  TypeScript's union type "handoff" | "waiting" | "done" | "failed"
  is erased at runtime; commander didn't validate. `--exit banana`
  forwarded to /api/workflow/project with body.exit = "banana".

  Fix: exported isProjectExitKind() + PROJECT_EXIT_KINDS in
  workflow.ts. The project action validates opts.exit BEFORE
  withClient runs; on failure emits a 3-part error and returns
  without calling the daemon. Same JSON / human shape as BLOCK 1
  via a shared emit3PartError helper inside workflow.ts (parallel
  to the one in workspace.ts — the two surfaces have separate
  command modules and don't share a deps tree, so duplicating the
  10-line emitter keeps the change minimal).

  Discriminators (6 new tests in workflow-exit-validation.test.ts):
    - isProjectExitKind: accepts every kind in PROJECT_EXIT_KINDS;
      rejects banana/""/HANDOFF (case-sensitive); rejects non-strings
    - end-to-end: --exit banana --json → exit 1, parsed JSON has
      3-part error.fact matching the full enum + got value,
      client.post() never called
    - human-mode emits 3-part to stderr
    - parameterized across ""/HANDOFF/shipped (the latter is a likely
      operator-confusion case from queue closures)

Verification:
- packages/cli npx tsc --noEmit: clean
- 28/28 PASS across workspace-max-files-validation (8) +
  workflow-exit-validation (6) + workflow-ux (14)

Both fixes are minimal additive guards; no existing behavior changed
for valid input. Positive paths (--max-files 12, --exit handoff)
still call the daemon with the parsed value unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-16 02:54:16 -07:00
1 parent a28f9b710e
commit 2b4772c785
4 files changed
+347 -3

No files matched your search

+37 -2
View File
@@ -52,6 +52,27 @@ export interface OutcomeSummary {
next: string;
}
// release-0.3.2 slice 01 BC repair — runtime validator for the
// project --exit enum. TypeScript types don't enforce at runtime;
// without this guard `--exit banana` would forward to the daemon
// transactional-scribe surface. Match the same 3-part error shape
// used elsewhere in the slice.
export const PROJECT_EXIT_KINDS = ["handoff", "waiting", "done", "failed"] as const;
export type ProjectExitKind = (typeof PROJECT_EXIT_KINDS)[number];
export function isProjectExitKind(value: unknown): value is ProjectExitKind {
return typeof value === "string" && (PROJECT_EXIT_KINDS as readonly string[]).includes(value);
}
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
if (json) {
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
} else {
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
}
process.exitCode = 1;
}
export function printOutcomeSummary(json: boolean, status: number, summary: OutcomeSummary | null): void {
if (json || !summary || status >= 400) return;
console.log("");
@@ -176,13 +197,27 @@ Examples:
.action(async (opts: {
instance: string;
currentPacket: string;
exit: "handoff" | "waiting" | "done" | "failed";
exit: string;
actorSession: string;
resultNote?: string;
blockedOn?: string;
nextOwner?: string;
json?: boolean;
}) => {
// HG-6 — runtime-validate the --exit enum BEFORE the daemon
// call. TypeScript types are erased at runtime; without this
// guard `--exit banana` would forward to the transactional
// workflow surface.
if (!isProjectExitKind(opts.exit)) {
emit3PartError(
Boolean(opts.json),
`--exit must be one of ${PROJECT_EXIT_KINDS.join(" | ")} (got "${opts.exit}").`,
"rig workflow project did not run; daemon was not contacted.",
`Pass a valid exit kind. Example: --exit handoff.`,
);
return;
}
const exitKind: ProjectExitKind = opts.exit;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<{
@@ -193,7 +228,7 @@ Examples:
}>("/api/workflow/project", {
instanceId: opts.instance,
currentPacketId: opts.currentPacket,
exit: opts.exit,
exit: exitKind,
actorSession: opts.actorSession,
resultNote: opts.resultNote,
blockedOn: opts.blockedOn,
+38 -1
View File
@@ -46,6 +46,32 @@ async function withClient<T>(
return fn(client);
}
// release-0.3.2 slice 01 BC repair — strict-int validator for
// --max-files. Rejects `12abc`, `abc`, `0`, `-1`, etc. with a 3-part
// fact/consequence/action error; does NOT call the daemon on invalid
// input. Keeps positive cases (`10000`, `12`, etc.) flowing through.
export function parseMaxFilesStrict(raw: string): number {
if (!/^[1-9][0-9]*$/.test(raw)) {
const err = new Error(
`--max-files must be a positive integer (got "${raw}").`,
) as Error & { fact: string; consequence: string; action: string };
err.fact = `--max-files must be a positive integer (got "${raw}").`;
err.consequence = "rig workspace validate did not run; daemon was not contacted.";
err.action = "Pass a positive integer like --max-files 10000.";
throw err;
}
return Number.parseInt(raw, 10);
}
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
if (json) {
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
} else {
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
}
process.exitCode = 1;
}
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
const cmd = new Command("workspace").description(
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
@@ -78,6 +104,17 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
json?: boolean;
},
) => {
// HG-6 — CLI-side validation BEFORE the daemon call. Reject
// malformed --max-files with a 3-part error; never silently
// coerce `12abc` → 12.
let maxFiles: number;
try {
maxFiles = parseMaxFilesStrict(opts.maxFiles);
} catch (err) {
const e = err as Error & { fact?: string; consequence?: string; action?: string };
emit3PartError(Boolean(opts.json), e.fact ?? e.message, e.consequence ?? "", e.action ?? "");
return;
}
const root = path.resolve(rootArg ?? process.cwd());
const deps = getDeps();
await withClient(deps, async (client) => {
@@ -86,7 +123,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
workspaceKind: opts.kind,
recursive: opts.recursive !== false,
requireFrontmatter: opts.requireFrontmatter ?? false,
maxFiles: Number.parseInt(opts.maxFiles, 10),
maxFiles,
});
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
@@ -0,0 +1,142 @@
// release-0.3.2 slice 01 BC repair — HG-6 discriminators for the
// workflow project --exit enum. Without runtime validation, the
// TypeScript-only enum allowed `--exit banana` to flow through to
// the daemon transactional-scribe surface.
import { describe, expect, it, vi } from "vitest";
import {
workflowCommand,
isProjectExitKind,
PROJECT_EXIT_KINDS,
type WorkflowDeps,
} from "../src/commands/workflow.js";
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
function captureStdout(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
};
return { logs, restore: () => { console.log = original; } };
}
function captureStderr(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = process.stderr.write;
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
return { logs, restore: () => { process.stderr.write = original; } };
}
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkflowDeps {
return {
lifecycleDeps: {} as LifecycleDeps,
clientFactory: () => ({
post,
get: vi.fn(),
postText: vi.fn(),
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
};
}
describe("isProjectExitKind — pure helper", () => {
it("accepts every documented exit kind", () => {
for (const kind of PROJECT_EXIT_KINDS) {
expect(isProjectExitKind(kind)).toBe(true);
}
});
it("rejects garbage strings (BC-2 root cause)", () => {
expect(isProjectExitKind("banana")).toBe(false);
expect(isProjectExitKind("")).toBe(false);
expect(isProjectExitKind("HANDOFF")).toBe(false); // case-sensitive
});
it("rejects non-strings", () => {
expect(isProjectExitKind(undefined)).toBe(false);
expect(isProjectExitKind(null)).toBe(false);
expect(isProjectExitKind(42)).toBe(false);
});
});
describe("rig workflow project --exit — CLI-side discriminator (BLOCK 2)", () => {
it("rejects '--exit banana' with --json: exit=1, body.error 3-part, client.post NOT called", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", "banana",
"--actor-session", "x@y",
"--json",
]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = out.logs.join("\n");
const parsed = JSON.parse(joined);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/--exit must be one of handoff \| waiting \| done \| failed.*banana/);
expect(parsed.error.consequence).toMatch(/did not run/);
expect(parsed.error.action).toMatch(/--exit handoff/);
});
it("emits 3-part error to stderr in human mode", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const err = captureStderr();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", "banana",
"--actor-session", "x@y",
]);
err.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = err.logs.join("");
expect(joined).toMatch(/Error:.*--exit must be one of/);
expect(joined).toMatch(/banana/);
expect(joined).toMatch(/did not run/);
});
it("rejects each garbage variant ('', 'HANDOFF', 'shipped') without calling the daemon", async () => {
for (const bad of ["", "HANDOFF", "shipped"]) {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", bad,
"--actor-session", "x@y",
"--json",
]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode, `--exit "${bad}" should fail`).toBe(1);
expect(post, `--exit "${bad}" must not call the daemon`).not.toHaveBeenCalled();
}
});
// Positive-path validation (e.g. --exit handoff forwards correctly)
// is covered by isProjectExitKind pure-helper above; the live action
// path is gated by getDaemonStatus and tested at the integration
// tier, mirroring the slice-01 workflow-ux test pattern.
});
@@ -0,0 +1,130 @@
// release-0.3.2 slice 01 BC repair — HG-6 discriminators.
// Verifies that `rig workspace validate --max-files <garbage>` rejects
// CLI-side with a 3-part error AND does NOT call the daemon. Matches
// the guard BC-1 fix recipe.
import { describe, expect, it, vi } from "vitest";
import { workspaceCommand, parseMaxFilesStrict, type WorkspaceDeps } from "../src/commands/workspace.js";
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
function captureStdout(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
};
return { logs, restore: () => { console.log = original; } };
}
function captureStderr(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = process.stderr.write;
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
return { logs, restore: () => { process.stderr.write = original; } };
}
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkspaceDeps {
return {
lifecycleDeps: {} as LifecycleDeps,
clientFactory: () => ({
post,
get: vi.fn(),
postText: vi.fn(),
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
};
}
describe("parseMaxFilesStrict — pure helper", () => {
it("accepts positive integers", () => {
expect(parseMaxFilesStrict("1")).toBe(1);
expect(parseMaxFilesStrict("12")).toBe(12);
expect(parseMaxFilesStrict("10000")).toBe(10000);
});
it("rejects garbage tails (BC-1 root cause)", () => {
expect(() => parseMaxFilesStrict("12abc")).toThrow(/positive integer/);
});
it("rejects non-numeric input", () => {
expect(() => parseMaxFilesStrict("abc")).toThrow(/positive integer/);
expect(() => parseMaxFilesStrict("")).toThrow(/positive integer/);
});
it("rejects zero and negatives", () => {
expect(() => parseMaxFilesStrict("0")).toThrow(/positive integer/);
expect(() => parseMaxFilesStrict("-1")).toThrow(/positive integer/);
});
it("3-part error carries fact + consequence + action fields", () => {
try {
parseMaxFilesStrict("12abc");
throw new Error("should have thrown");
} catch (err) {
const e = err as Error & { fact?: string; consequence?: string; action?: string };
expect(e.fact).toMatch(/positive integer/);
expect(e.consequence).toMatch(/did not run/);
expect(e.action).toMatch(/Pass a positive integer/);
}
});
});
describe("rig workspace validate --max-files — CLI-side discriminator (BLOCK 1)", () => {
it("rejects '12abc' with --json: exit=1, body.error has fact/consequence/action, client.post NOT called", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workspaceCommand(deps);
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc", "--json"]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = out.logs.join("\n");
const parsed = JSON.parse(joined);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/positive integer/);
expect(parsed.error.consequence).toMatch(/did not run/);
expect(parsed.error.action).toMatch(/--max-files/);
});
it("rejects 'abc', '0', '-1' equivalently", async () => {
for (const bad of ["abc", "0", "-1"]) {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const root = workspaceCommand(deps);
const originalExitCode = process.exitCode;
process.exitCode = 0;
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", bad, "--json"]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode, `--max-files ${bad} should fail`).toBe(1);
expect(post, `--max-files ${bad} must not call the daemon`).not.toHaveBeenCalled();
}
});
it("emits 3-part error to stderr in human mode (no --json)", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const err = captureStderr();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workspaceCommand(deps);
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc"]);
err.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = err.logs.join("");
expect(joined).toMatch(/Error:.*positive integer/);
expect(joined).toMatch(/did not run/);
expect(joined).toMatch(/Pass a positive integer/);
});
// Positive case is covered by parseMaxFilesStrict pure-helper test
// above ("accepts positive integers"); driving end-to-end here
// requires stubbing the daemon-status check too, which is covered
// by the daemon-lifecycle adopt/expand test pattern (out of scope
// for this BC discriminator).
});