feat(security): P21 I3 CLI reconcile — queue-spine verbs stop sending body identity

The I3 fold made the daemon queue routes derive the actor/sender from the
X-OpenRig-Session transport header (requireSenderIdentity). This is the CLI half:
the queue verbs stop sending forgeable body-identity claims and stop advertising
the identity flags as authoritative — matching the resolve/update/block
adopt-drops and the P21 charter (body identity REMOVED, not just tolerated).

Verbs reconciled (deprecate+ignore the flag, drop the body claim, add an env
pre-check that fails-loud locally when the seat env is unset — the header source):
- create        --source  → sourceSession dropped
- claim         --destination → destinationSession dropped
- unclaim       --destination → destinationSession dropped
- update        --actor   → actorSession dropped
- block         --actor   → actorSession dropped (writes via the same update route)
- handoff       --from    → fromSession dropped
- handoff-and-complete --from → fromSession dropped
- outbox-record --sender  → senderSession dropped (--sender was still required+sent)

TARGET fields stay body fields (destinationSession/toSession on create/handoff —
the recipient, not the caller). The daemon tolerates an equal body claim in the
adopt-drop window, so no live breakage: the CLI flags defaulted from the seat env
== the header, header==claim==tolerated; the 409 only ever fired on a genuine
flag!=env mismatch (the forgery this closes).

Tests: 5 CLI unit tests revised from the pre-P21 "sends identity from env in the
body" contract to "sends NO body identity claim (daemon derives from the header);
an explicit/forged flag is dropped; the TARGET stays". cli tsc0; CLI queue suite
100/100; daemon queue cluster 130/130; daemon tsc0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-07 21:51:13 +00:00
1 parent be4790265a
commit 081c1c9308
2 files changed
+111 -85

No files matched your search

+36 -32
View File
@@ -306,7 +306,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
cmd
.command("create")
.description("Create a new qitem")
.requiredOption("--source <session>", "Source session")
.option("--source <session>", "(deprecated, ignored) the source is derived from the seat env (X-OpenRig-Session); P21 I3 made the create route derive it from the transport header")
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
.option("--body <text>", "Qitem body inline (use - to read from stdin; mutually exclusive with --body-file)")
.option("--body-file <path>", "Read qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class for multiline bodies.")
@@ -326,7 +326,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
.option("--json", "JSON output for agents")
.action(async (opts: {
source: string;
source?: string;
destination: string;
body?: string;
bodyFile?: string;
@@ -382,6 +382,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
"warning: rig queue create called without --summary. New qitems should carry a short human-readable summary; the Story node degrades to a body truncation without it. A good summary is 1-2 plain sentences a human skims in the needs-you view — what the work is and why it needs this seat, not the agent-speak --body. Proceeding (pre-18 callers exempt).\n"
);
}
// P21 I3 reconcile: the source is DERIVED from the seat env (X-OpenRig-Session) — --source
// deprecated + ignored, no body sourceSession. Verify the env or the daemon refuses-loud 401.
if (!resolveCurrentSession(undefined, "source")) return;
const deps = getDeps();
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
// translate to canonical mission:<id> / slice:<id> tags. Composes
@@ -419,7 +422,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
}
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
qitemId: opts.id,
sourceSession: opts.source,
destinationSession: hostResolved.destination,
body: resolvedBody,
summary: opts.summary,
@@ -441,16 +443,15 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
cmd
.command("claim <qitemId>")
.description("Claim a qitem (pending → in-progress); computes closure_required_at from tier")
.option("--destination <session>", "Destination session claiming the qitem (defaults to OPENRIG_SESSION_NAME)")
.option("--destination <session>", "(deprecated, ignored) the claimant is derived from the seat env (X-OpenRig-Session); P21 I3 made the claim route derive it from the transport header")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination?: string; json?: boolean }) => {
const destination = resolveCurrentSession(opts.destination, "destination");
if (!destination) return;
// P21 I3 reconcile: the claimant is DERIVED from the seat env — --destination deprecated + ignored,
// no body claim. Verify the env (the header source) or the daemon refuses-loud 401.
if (!resolveCurrentSession(undefined, "destination")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {
destinationSession: destination,
});
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
printResult(opts.json ?? false, res.data, res.status);
});
});
@@ -458,16 +459,16 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
cmd
.command("unclaim <qitemId>")
.description("Release a claimed qitem (in-progress → pending)")
.option("--destination <session>", "Destination session releasing the qitem (defaults to OPENRIG_SESSION_NAME)")
.option("--destination <session>", "(deprecated, ignored) the releaser is derived from the seat env (X-OpenRig-Session); the unclaim route derives it from the transport header")
.option("--reason <text>", "Reason for unclaim", "manual")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination?: string; reason: string; json?: boolean }) => {
const destination = resolveCurrentSession(opts.destination, "destination");
if (!destination) return;
// P21 I3 reconcile: the releaser is DERIVED from the seat env — --destination deprecated + ignored,
// no body claim. Verify the env or the daemon refuses-loud 401.
if (!resolveCurrentSession(undefined, "destination")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
destinationSession: destination,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
@@ -477,7 +478,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
cmd
.command("update <qitemId>")
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation). Closure ≠ acceptance: handed_off_to records delivery to the next stage; acceptance is the next stage's verdict on its own qitem, not this closure.")
.option("--actor <session>", "Actor session performing the transition (defaults to OPENRIG_SESSION_NAME)")
.option("--actor <session>", "(deprecated, ignored) the actor is derived from the seat env (X-OpenRig-Session); P21 I3 made the update route derive it from the transport header")
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
.option("--closure-reason <reason>", "Required for state=done")
.option("--closure-target <target>", "Required for handed_off_to, blocked_on, escalation")
@@ -497,12 +498,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
note?: string;
json?: boolean;
}) => {
const actor = resolveCurrentSession(opts.actor, "actor");
if (!actor) return;
// P21 I3 reconcile: the actor is DERIVED from the seat env (X-OpenRig-Session, stamped by
// DaemonClient) — --actor is deprecated + ignored, no body actorSession. Verify the env (the
// header source) or the daemon refuses-loud 401 unattributable_sender. Matches `resolve`.
if (!resolveCurrentSession(undefined, "actor")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
actorSession: actor,
state: opts.state,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
@@ -526,7 +528,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.command("block <qitemId>")
.description("Park a qitem as state=blocked on a blocker (qitem id, or a human seat for a needs-human park). Non-terminal: the owner keeps the item; resolution returns to it (rig queue resolve).")
.requiredOption("--on <blocker>", "The blocker: a qitem id, or a human-seat session (e.g. human-review@kernel) for the needs-human park")
.option("--actor <session>", "Acting session (defaults to OPENRIG_SESSION_NAME)")
.option("--actor <session>", "(deprecated, ignored) the actor is derived from the seat env (X-OpenRig-Session); the park writes via the same P21 I3 header-deriving update route")
.option("--summary <text>", "Plain-language summary of the decision owed (required for human-seat parks unless already on the item)")
.option("--evidence-ref <path>", "Durable artifact the human judges (required for human-seat parks unless already on the item)")
.option("--note <text>", "Transition note for the audit log")
@@ -539,12 +541,12 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
note?: string;
json?: boolean;
}) => {
const actor = resolveCurrentSession(opts.actor, "actor");
if (!actor) return;
// P21 I3 reconcile: actor DERIVED from the seat env (X-OpenRig-Session) — --actor deprecated +
// ignored, no body actorSession. Verify the env or the daemon refuses-loud. Matches `resolve`.
if (!resolveCurrentSession(undefined, "actor")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
actorSession: actor,
state: "blocked",
blockedOn: opts.on,
summary: opts.summary,
@@ -601,7 +603,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
cmd
.command("handoff <qitemId>")
.description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to")
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
.option("--from <session>", "(deprecated, ignored) the handing-off seat is derived from the seat env (X-OpenRig-Session); P21 I3 made the handoff route derive it from the transport header")
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
@@ -633,8 +635,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
// P21 I3 reconcile: the handing-off seat is DERIVED from the seat env (X-OpenRig-Session) —
// --from deprecated + ignored, no body fromSession. Verify the env or the daemon refuses-loud 401.
if (!resolveCurrentSession(undefined, "from")) return;
// slice-08 OPR.0.4.7.8 — body-input parity. Resolve through the shipped
// resolveQueueBody ONLY when a body source is supplied; neither preserves
// today's source-body default (POST body undefined). Both/invalid reject
@@ -674,7 +677,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff`, {
fromSession: from,
toSession: hostResolved.destination,
body: resolvedBody,
summary: opts.summary,
@@ -696,7 +698,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.description(
"Atomic close (state=done, closure_reason=handed_off_to) + create new qitem owned by --to. Variant of handoff that fully terminates the source qitem."
)
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
.option("--from <session>", "(deprecated, ignored) the handing-off seat is derived from the seat env (X-OpenRig-Session); P21 I3 made the handoff route derive it from the transport header")
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
@@ -728,8 +730,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
// P21 I3 reconcile: the handing-off seat is DERIVED from the seat env (X-OpenRig-Session) —
// --from deprecated + ignored, no body fromSession. Verify the env or the daemon refuses-loud 401.
if (!resolveCurrentSession(undefined, "from")) return;
// slice-08 OPR.0.4.7.8 — body-input parity (same contract as handoff):
// resolve only when a body source is supplied; neither keeps the
// source-body default; both/invalid reject before daemon contact.
@@ -767,7 +770,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff-and-complete`, {
fromSession: from,
toSession: hostResolved.destination,
body: resolvedBody,
summary: opts.summary,
@@ -1080,7 +1082,7 @@ Examples:
cmd
.command("outbox-record")
.description("Record an outbound dispatch in the sender's outbox")
.requiredOption("--sender <session>", "Sender session")
.option("--sender <session>", "(deprecated, ignored) the sender is derived from the seat env (X-OpenRig-Session); P21 I3 made the outbox-record route derive it from the transport header")
.requiredOption("--destination <session>", "Destination session")
.option("--body <text>", "Outbox body inline (use - to read from stdin; mutually exclusive with --body-file).")
.option("--body-file <path>", "Read the outbox body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
@@ -1090,7 +1092,7 @@ Examples:
.option("--id <outboxId>", "Idempotent outbox_id")
.option("--json", "JSON output for agents")
.action(async (opts: {
sender: string;
sender?: string;
destination: string;
body?: string;
bodyFile?: string;
@@ -1110,12 +1112,14 @@ Examples:
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
return;
}
// P21 I3 reconcile: the sender is DERIVED from the seat env (X-OpenRig-Session) — --sender
// deprecated + ignored, no body senderSession. Verify the env or the daemon refuses-loud 401.
if (!resolveCurrentSession(undefined, "sender")) return;
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/queue/outbox/record", {
outboxId: opts.id,
senderSession: opts.sender,
destinationSession: opts.destination,
body: resolvedBody,
tags,
+75 -53
View File
@@ -101,28 +101,37 @@ describe("rig queue CLI", () => {
expect(subs).toContain("show");
});
it("create POSTs to /api/queue/create with sourceSession + destinationSession + body", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "qitem-x", state: "pending" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body", "do thing",
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
expect(create).toBeDefined();
const body = create!.body as Record<string, unknown>;
expect(body.sourceSession).toBe("alice@rig");
expect(body.destinationSession).toBe("bob@rig");
expect(body.body).toBe("do thing");
// R1: commander's --no-nudge sets opts.nudge to true by default.
// The CLI sends nudge: true, and the daemon treats nudge !== false as nudging.
expect(body.nudge).toBe(true);
it("create sends NO body sourceSession — the source derives from the transport header (X-OpenRig-Session); an explicit --source is dropped, never forwarded as a body claim", async () => {
const saved = process.env["OPENRIG_SESSION_NAME"];
process.env["OPENRIG_SESSION_NAME"] = "alice@rig"; // the seat env == the X-OpenRig-Session the DaemonClient stamps
try {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "qitem-x", state: "pending" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "forged@evil", // P21: IGNORED — must not ride the body
"--destination", "bob@rig",
"--body", "do thing",
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
expect(create).toBeDefined();
const body = create!.body as Record<string, unknown>;
// P21 I3 reconcile: no body identity claim — the daemon derives the source from the header, and
// the forged --source is DROPPED (not forwarded). The destination is the TARGET, a legit body field.
expect(body.sourceSession).toBeUndefined();
expect(body.destinationSession).toBe("bob@rig");
expect(body.body).toBe("do thing");
// R1: commander's --no-nudge sets opts.nudge to true by default.
// The CLI sends nudge: true, and the daemon treats nudge !== false as nudging.
expect(body.nudge).toBe(true);
} finally {
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
else process.env["OPENRIG_SESSION_NAME"] = saved;
}
});
// Slice-03 Atom 6b — --body-context snapshot + provenance rule.
@@ -557,33 +566,42 @@ describe("rig queue CLI", () => {
expect(out).toContain("validReasons");
});
it("handoff-and-complete POSTs to /api/queue/:id/handoff-and-complete with from + to", async () => {
const { deps, calls } = makeDeps({
routes: {
"POST /api/queue/qitem-src/handoff-and-complete": {
status: 201,
data: {
closed: { state: "done", closureReason: "handed_off_to" },
created: { state: "pending", qitemId: "qitem-new" },
it("handoff-and-complete sends NO body fromSession — the handing-off seat derives from the transport header (X-OpenRig-Session); --from is dropped, --to stays the target", async () => {
const saved = process.env["OPENRIG_SESSION_NAME"];
process.env["OPENRIG_SESSION_NAME"] = "bob@rig"; // the seat env == the X-OpenRig-Session the DaemonClient stamps
try {
const { deps, calls } = makeDeps({
routes: {
"POST /api/queue/qitem-src/handoff-and-complete": {
status: 201,
data: {
closed: { state: "done", closureReason: "handed_off_to" },
created: { state: "pending", qitemId: "qitem-new" },
},
},
},
},
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "handoff-and-complete", "qitem-src",
"--from", "bob@rig",
"--to", "carol@rig",
"--body", "carol's piece",
"--json",
]);
const call = calls.find((c) => c.path === "/api/queue/qitem-src/handoff-and-complete");
expect(call).toBeDefined();
const body = call!.body as Record<string, unknown>;
expect(body.fromSession).toBe("bob@rig");
expect(body.toSession).toBe("carol@rig");
expect(body.body).toBe("carol's piece");
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "handoff-and-complete", "qitem-src",
"--from", "forged@evil", // P21: IGNORED — must not ride the body
"--to", "carol@rig",
"--body", "carol's piece",
"--json",
]);
const call = calls.find((c) => c.path === "/api/queue/qitem-src/handoff-and-complete");
expect(call).toBeDefined();
const body = call!.body as Record<string, unknown>;
// P21 I3 reconcile: no body identity claim — the daemon derives fromSession from the header, and
// the forged --from is DROPPED. toSession is the TARGET, a legit body field.
expect(body.fromSession).toBeUndefined();
expect(body.toSession).toBe("carol@rig");
expect(body.body).toBe("carol's piece");
} finally {
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
else process.env["OPENRIG_SESSION_NAME"] = saved;
}
});
it("whoami GETs /api/queue/whoami with session + recentLimit query params", async () => {
@@ -643,7 +661,7 @@ describe("rig queue CLI", () => {
}
});
it("claim defaults the destination from OPENRIG_SESSION_NAME when --destination is omitted", async () => {
it("claim sends NO body destinationSession — the claimant derives from the transport header (X-OpenRig-Session); env present ⇒ POST fires", async () => {
const saved = process.env["OPENRIG_SESSION_NAME"];
process.env["OPENRIG_SESSION_NAME"] = "bob@rig";
try {
@@ -661,14 +679,16 @@ describe("rig queue CLI", () => {
const call = calls.find((c) => c.path === "/api/queue/qitem-x/claim");
expect(call).toBeDefined();
expect((call!.body as { destinationSession: string }).destinationSession).toBe("bob@rig");
// P21 I3 reconcile: the claimant is NOT a body claim — the daemon derives it from the
// X-OpenRig-Session header (env present ⇒ pre-check passes ⇒ POST fires). No forgeable body field.
expect((call!.body as Record<string, unknown>).destinationSession).toBeUndefined();
} finally {
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
else process.env["OPENRIG_SESSION_NAME"] = saved;
}
});
it("update defaults the actor from OPENRIG_SESSION_NAME when --actor is omitted", async () => {
it("update sends NO body actorSession — the actor derives from the transport header (X-OpenRig-Session); env present ⇒ POST fires", async () => {
const saved = process.env["OPENRIG_SESSION_NAME"];
process.env["OPENRIG_SESSION_NAME"] = "bob@rig";
try {
@@ -691,14 +711,15 @@ describe("rig queue CLI", () => {
const call = calls.find((c) => c.path === "/api/queue/qitem-x/update");
expect(call).toBeDefined();
expect((call!.body as { actorSession: string }).actorSession).toBe("bob@rig");
// P21 I3 reconcile: the actor is NOT a body claim — the daemon derives it from the header.
expect((call!.body as Record<string, unknown>).actorSession).toBeUndefined();
} finally {
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
else process.env["OPENRIG_SESSION_NAME"] = saved;
}
});
it("handoff defaults the source from OPENRIG_SESSION_NAME when --from is omitted", async () => {
it("handoff sends NO body fromSession — the handing-off seat derives from the transport header (X-OpenRig-Session); env present ⇒ POST fires", async () => {
const saved = process.env["OPENRIG_SESSION_NAME"];
process.env["OPENRIG_SESSION_NAME"] = "bob@rig";
try {
@@ -720,7 +741,8 @@ describe("rig queue CLI", () => {
const call = calls.find((c) => c.path === "/api/queue/qitem-x/handoff");
expect(call).toBeDefined();
expect((call!.body as { fromSession: string }).fromSession).toBe("bob@rig");
// P21 I3 reconcile: the handing-off seat is NOT a body claim — the daemon derives it from the header.
expect((call!.body as Record<string, unknown>).fromSession).toBeUndefined();
} finally {
if (saved === undefined) delete process.env["OPENRIG_SESSION_NAME"];
else process.env["OPENRIG_SESSION_NAME"] = saved;