diff --git a/CHANGELOG.md b/CHANGELOG.md index d7745f8..82f35da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ release gates. Stable promotion and publication require approval of the exact te - Automatically continue the original agent thread after approving a server for a hidden variable; no manual “continue” message is needed. +- Redact hidden proxy placeholders from detached background-shell logs before persistence, + while keeping explicitly Readable configuration visible in job output. + - Clarify that the network tool's policy is advisory in `/sudo` direct-host threads, with no OS-level egress cutoff. Ordinary proxy-mode container enforcement is unchanged. diff --git a/FEATURES.md b/FEATURES.md index be6c05f..b089101 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1557,7 +1557,8 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. pending product decision, not shipped). - **Secret handling:** masked dialogs collect password proof. Known credential values are redacted from primary/fallback replies, stream events, errors and background delivery checkpoints. Shell - jobs also redact injected channel secrets inside their detached container process before stdout + jobs also redact injected channel secrets and their hidden proxy placeholders inside their + detached container process before stdout or stderr reaches the durable artifact log. Independent stream buffers protect split values, and the wrapper survives the daemon while preserving the command's terminal status. The wrapper receives secret names only; jobs do not inherit engine service credentials. Existing historical diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 55f738d..f71ba1e 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -5029,7 +5029,11 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated explicit exits 17/23 remain recoverable through the terminal marker. Short final tails survive. - [x] Runtime integration: `test/runtime-integration-jobs.test.js` confirms the isolated spawn receives only secret names in wrapper arguments and values through its existing environment, - keeps its container run ID/lease, and uses the same durable recovery path. + keeps its container run ID/lease, and uses the same durable recovery path. The 2026-11-06 proxy + regression starts a real `BackgroundJobs` shell through the egress resolver and actual wrapper: + full and interleaved split hidden placeholders are redacted in raw logs, status and continuation + input, while explicit Readable configuration survives. The lease releases, exit status remains + truthful, and exactly one continuation retires the durable row. - [x] Candidate wrapper executed directly as a detached exec in an owned live container with a disposable random canary: raw artifact bytes contained exactly two `[REDACTED]` markers and a recoverable exit 23. This wrapper-level check does not substitute for the engine-origin gate. diff --git a/src/gateway/background.js b/src/gateway/background.js index 89e4389..caa9609 100644 --- a/src/gateway/background.js +++ b/src/gateway/background.js @@ -552,8 +552,10 @@ export class BackgroundJobs { child = target.runtime.spawn(target, { cmd: "bash", // Isolated: a runtime-side wrapper redacts BOTH streams before writing the durable - // log, and starts the command in one login shell. The outer shell only execs the wrapper. - args: isolatedJob ? ["-c", containerJobScript(cmd, logFile, Object.keys(jobEnv).filter((name) => jobSecretValues.includes(jobEnv[name])))] : ["-lc", cmd], + // log, and starts the command in one login shell. Hidden proxy placeholders are selected + // by resolver metadata: their bytes differ from the real redaction values. Readable + // configuration has no placeholder and remains shareable. The outer shell only execs the wrapper. + args: isolatedJob ? ["-c", containerJobScript(cmd, logFile, Object.keys(jobEnv).filter((name) => Object.hasOwn(jobRunEnv.placeholders, name) || jobSecretValues.includes(jobEnv[name])))] : ["-lc", cmd], cwd, env: buildShellEnv(shellEnv), stdio: ["ignore", "pipe", "pipe"], diff --git a/test/runtime-integration-jobs.test.js b/test/runtime-integration-jobs.test.js index d0e8c95..3bfee72 100644 --- a/test/runtime-integration-jobs.test.js +++ b/test/runtime-integration-jobs.test.js @@ -4,6 +4,7 @@ // restart can only be found again through the backend — its pid belonged to a client process that // died with the old daemon. import path from "node:path"; +import { randomBytes } from "node:crypto"; import { mkdirSync, writeFileSync, readFileSync } from "node:fs"; import test from "node:test"; import assert from "node:assert/strict"; @@ -191,3 +192,68 @@ test("a container job in Auto mode starts without a card; an unisolated one stil assert.equal(hostCard.approvalType, "agent"); assert.match(hostRefusal.error, /run outside every container/); }); + +test("proxy-mode background jobs redact hidden placeholders before persistence and preserve readable config", async () => { + const quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'"; + const backend = createFakeRuntimeBackend({ egress: { active: true, mode: "proxy", network: "none" } }); + const channelId = "C_JOB_PROXY_REDACT"; + const { entry, meta } = await autoChannel(channelId, "job-proxy-redact"); + const hidden = "synthetic-hidden-" + randomBytes(16).toString("hex"); + const readable = "intentionally-readable-" + randomBytes(8).toString("hex"); + await saveChannelMeta(entry.slug, { ...meta, env: { + QA_HIDDEN_RULED: { provider: "local", value: hidden, exposure: "hidden", hosts: ["example.com"] }, + QA_READABLE_CONFIG: { provider: "local", value: readable, exposure: "readable" }, + } }); + const continuations = [], delivered = []; + const jobs = new BackgroundJobs({ + resolveTarget: (slug, currentMeta) => fakeTarget(backend, slug, currentMeta), + runner: async ({ text }) => { + continuations.push(text); + return { content: "safe fixture summary", engine: "claude" }; + }, + deliver: async (_client, { result }) => { delivered.push(result.content); }, + }); + // Keep completion pending until its durable log and status have both been inspected. + jobs._client = () => null; + const source = `const s=process.env.QA_HIDDEN_RULED; + process.stdout.write('full '+s+'\\n'); + process.stdout.write('out '+s.slice(0,12)); + setTimeout(()=>process.stderr.write('err '+s.slice(0,17)),20); + setTimeout(()=>process.stdout.write(s.slice(12)+' end\\n'),40); + setTimeout(()=>process.stderr.write(s.slice(17)+' end\\n'),60); + setTimeout(()=>process.stdout.write('visible '+process.env.QA_READABLE_CONFIG+'\\n'),80);`; + const started = await jobs.start({ + channelId, authorId: "U_JOB", threadKey: "proxy-redaction", label: "safe synthetic integration", + command: "node -e " + quote(source), + }); + assert.equal(started.ok, true, started.error); + const rec = jobs.jobs.get(started.id); + const spec = backend.calls.spawn.at(-1); + const placeholder = spec.env.QA_HIDDEN_RULED; + assert.notEqual(placeholder, hidden, "the actual resolver replaces hidden values in proxy mode"); + assert.match(placeholder, /^cgph_/); + assert.equal(spec.env.QA_READABLE_CONFIG, readable); + const deadline = Date.now() + 12_000; + while (!rec.pendingDelivery && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 25)); + assert.ok(rec.pendingDelivery, "the actual wrapper must finish and checkpoint completion"); + const raw = readFileSync(rec.logFile, "utf8"); + const status = jobs.status(started.id); + jobs._client = () => ({ platform: "slack", post: async () => ({ ok: true }) }); + await jobs._deliver(rec); + + // Check booleans so a failing assertion cannot print the disposable credential or placeholder. + for (const text of [raw, status.tail, continuations.join("\n")]) { + assert.equal(text.includes(hidden), false, "no real hidden value reaches output"); + assert.equal(text.includes(placeholder), false, "no hidden placeholder reaches output"); + assert.equal(text.includes(placeholder.slice(0, 12)), false, "interleaved writes cannot expose a held prefix"); + assert.equal(text.includes(readable), true, "explicit readable configuration remains shareable"); + assert.equal((text.match(/\[REDACTED\]/g) || []).length, 3); + } + assert.ok(spec.args[1].includes('["QA_HIDDEN_RULED"]'), "the wrapper receives the hidden env name"); + assert.equal(spec.args.join("").includes(hidden) || spec.args.join("").includes(placeholder), false); + assert.equal(parseContainerJobExit(raw), 0); + assert.equal(continuations.length, 1); + assert.deepEqual(delivered, ["safe fixture summary"]); + assert.equal(persistedRow(started.id), null, "successful continuation retires the durable row"); + assert.ok(backend.calls.leases.every((lease) => lease.released)); +});