From 6df7ace90fb8c2766665cbcb8577bfedcdccdb8a Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 2 Aug 2026 14:37:38 +0300 Subject: [PATCH 1/2] fix(api): deliver the agent prompt unredacted on runner claim claimRuns pushed publicLoop(claim.loop), which rewrites target.prompt to "[redacted N chars]" for agent targets. The runner executes the loop it is handed by the claim response, so every agent-type loop ran with a placeholder as its entire instruction and exited 0 having done nothing. Command targets were unaffected because target.command is not redacted. Redaction belongs on operator-facing reads: publicLoop still guards all six operator read paths and GET /v1/loops/:id stays redacted. The regression test asserts BOTH arms, because fixing only the first would convert a correctness fix into a security regression. Discriminating proof, both directions measured: with the fix -> 1 pass, 0 fail, rc=0 against a file byte-identical to b6486cef (diff -q IDENTICAL) -> 0 pass, 1 fail, rc=1, Received: "[redacted 97 chars]" Full src/api/index.test.ts suite: 22 pass, 0 fail, 184 expect() calls. 0.4.28-based hotfix. The equivalent fix is on main since 2e5e492, but the 0008/0009/0010 tenancy migrations landed in 93f6adb which PREDATES it, so no main commit carries the fix without a schema delta. Single commit on top of b6486cef (the deployed digest) so it satisfies the parent-ancestry build gate. Task: c64e66bd Agent: Corbulo --- src/api/index.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++ src/api/index.ts | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/api/index.test.ts b/src/api/index.test.ts index 651ce83..7124d9d 100644 --- a/src/api/index.test.ts +++ b/src/api/index.test.ts @@ -1014,4 +1014,63 @@ describe("loops-api foundation", () => { await storage.close(); } }); + // Regression: incident 607176 / task c64e66bd. `claimRuns` pushed + // `publicLoop(claim.loop)`, which rewrites target.prompt to + // "[redacted N chars]" for agent targets. The runner executes the loop it is + // handed by the claim response, so every agent-type loop ran with a + // placeholder as its entire instruction and exited 0 having done nothing. + // Redaction belongs on operator-facing reads (GET /v1/loops), never on the + // runner's execution payload. This test FAILS against the unfixed base. + test("runner claim delivers the agent prompt unredacted while operator reads stay redacted", async () => { + const mod = await import("./index.js"); + const storage = createSqliteLoopStorage(":memory:"); + const now = new Date("2026-01-01T00:00:00Z"); + const server = mod.createLoopsApiServer({ host: "127.0.0.1", port: 0, storage, now: () => now }); + const prompt = "Using the Write tool, create /tmp/loop-probe.txt containing exactly SENTINEL-OK and nothing else."; + + try { + const loop = await storage.createLoop( + { + name: "api-runner-agent-prompt", + schedule: { type: "once", at: "2026-01-01T00:00:00Z" }, + target: { type: "agent", provider: "claude", prompt }, + leaseMs: 60_000, + }, + new Date("2025-12-31T00:00:00Z"), + ); + + const register = await fetch(apiUrl(server, "/v1/runners/register"), { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ runnerId: "runner-prompt", machineId: "machine-prompt" }), + }); + expect(register.status).toBe(200); + + const claimResponse = await fetch(apiUrl(server, "/v1/runners/claim"), { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ runnerId: "runner-prompt", maxClaims: 1 }), + }); + expect(claimResponse.status).toBe(200); + const claimed = (await claimResponse.json()) as { + claims: Array<{ loop: { id: string; target: { prompt?: string } } }>; + }; + + // The runner must receive the REAL prompt. + expect(claimed.claims).toHaveLength(1); + expect(claimed.claims[0]!.loop.id).toBe(loop.id); + expect(claimed.claims[0]!.loop.target.prompt).toBe(prompt); + expect(claimed.claims[0]!.loop.target.prompt).not.toMatch(/^\[redacted/); + + // ...while the operator-facing read stays redacted. Without this arm the + // fix could be "corrected" into a credential-leaking regression. + const read = await fetch(apiUrl(server, `/v1/loops/${loop.id}`)); + expect(read.status).toBe(200); + const body = (await read.json()) as { loop: { target: { prompt?: string } } }; + expect(body.loop.target.prompt).toBe(`[redacted ${prompt.length} chars]`); + } finally { + server.stop(true); + await storage.close(); + } + }); }); diff --git a/src/api/index.ts b/src/api/index.ts index 3179963..7cf6d89 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -728,7 +728,7 @@ async function claimRuns( { claimToken: claim.claimToken }, ) ?? claim.run; claims.push({ - loop: publicLoop(claim.loop), + loop: claim.loop, run: publicRun(run, false, { redactError: true }), claimToken: claim.claimToken, }); From 9c6ec82093ca134e65391e1d786800cdbf25ad20 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 2 Aug 2026 15:14:00 +0300 Subject: [PATCH 2/2] fix(api): require runner execution scope Agent: unresolved-account001 --- src/api/index.test.ts | 65 +++++++++++++++++++++++++++++++++++++++++++ src/api/index.ts | 14 ++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/api/index.test.ts b/src/api/index.test.ts index 7124d9d..9e55016 100644 --- a/src/api/index.test.ts +++ b/src/api/index.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { mintApiKey, verifyApiKey } from "@hasna/contracts/auth"; import { describe, expect, test } from "bun:test"; import { createSqliteLoopStorage } from "../lib/storage/sqlite.js"; import type { Loop, WorkflowSpec } from "../types.js"; @@ -1073,4 +1074,68 @@ describe("loops-api foundation", () => { await storage.close(); } }); + + test("runner claim requires execution scope before returning the raw loop payload", async () => { + const mod = await import("./index.js"); + const storage = createSqliteLoopStorage(":memory:"); + const now = new Date("2026-01-01T00:00:00Z"); + const signingSecret = "runner-scope-test-signing-secret"; + const authenticator = verifyApiKey({ + app: "loops", + signingSecret, + nowMs: () => now.getTime(), + isRevoked: async () => false, + }); + const readKey = mintApiKey({ + app: "loops", + scopes: ["loops:read"], + signingSecret, + nowMs: now.getTime(), + ttlSeconds: 60, + }); + const runnerKey = mintApiKey({ + app: "loops", + scopes: ["loops:execute"], + signingSecret, + nowMs: now.getTime(), + ttlSeconds: 60, + }); + const server = mod.createLoopsApiServer({ host: "127.0.0.1", port: 0, storage, now: () => now, authenticator }); + const prompt = "NONSECRET_RUNNER_SCOPE_MARKER"; + + try { + await storage.createLoop( + { + name: "api-runner-scope", + schedule: { type: "once", at: now.toISOString() }, + target: { type: "agent", provider: "claude", prompt }, + leaseMs: 60_000, + }, + new Date("2025-12-31T00:00:00Z"), + ); + + const readClaim = await fetch(apiUrl(server, "/v1/runners/claim"), { + method: "POST", + headers: { ...jsonHeaders, "x-api-key": readKey.token }, + body: JSON.stringify({ runnerId: "unregistered-reader", maxClaims: 1 }), + }); + expect(readClaim.status).toBe(403); + expect(await storage.listRuns({ status: "running" })).toHaveLength(0); + + const runnerClaim = await fetch(apiUrl(server, "/v1/runners/claim"), { + method: "POST", + headers: { ...jsonHeaders, "x-api-key": runnerKey.token }, + body: JSON.stringify({ runnerId: "scoped-runner", maxClaims: 1 }), + }); + expect(runnerClaim.status).toBe(200); + const claimed = (await runnerClaim.json()) as { + claims: Array<{ loop: { target: { prompt?: string } } }>; + }; + expect(claimed.claims).toHaveLength(1); + expect(claimed.claims[0]!.loop.target.prompt).toBe(prompt); + } finally { + server.stop(true); + await storage.close(); + } + }); }); diff --git a/src/api/index.ts b/src/api/index.ts index 7cf6d89..d711228 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -50,6 +50,7 @@ const DEFAULT_EVIDENCE_LIMIT_BYTES = 256 * 1024; // The client batches by byte budget well under this ceiling. const DEFAULT_IMPORT_LIMIT_BYTES = 32 * 1024 * 1024; const MIN_RUNNER_LEASE_MS = 1_000; +const RUNNER_EXECUTION_SCOPES = ["loops:execute"] as const; program .name("loops-api") @@ -92,6 +93,17 @@ function authorizeRequest(request: Request, host: string): Response | undefined : Response.json({ ok: false, error: "unauthorized" }, { status: 401 }); } +function requiredScopesForRequest(method: string, pathname: string): readonly string[] | undefined { + if (method !== "POST") return undefined; + if (/^\/v1\/runners\/(?:[^/]+\/)?(?:register|heartbeat|poll|claim)$/.test(pathname)) { + return RUNNER_EXECUTION_SCOPES; + } + if (/^\/v1\/runs\/[^/]+\/(?:heartbeat|finalize|evidence)$/.test(pathname)) { + return RUNNER_EXECUTION_SCOPES; + } + return undefined; +} + function ok(payload: Record = {}, init?: ResponseInit): Response { return Response.json({ ok: true, ...payload }, init); } @@ -197,9 +209,11 @@ export function createLoopsApiServer(opts: LoopsApiServerOptions = {}) { } // ── Authenticated control plane (/status included) ─────────────────── if (opts.authenticator) { + const requiredScopes = requiredScopesForRequest(request.method, url.pathname); const decision = await opts.authenticator.authenticate(request.headers, { method: request.method, path: url.pathname, + requiredScopes, }); if (!decision.ok) { return Response.json(