feat(k8s-sandbox): fail-closed guard against literal credential env vars (BLO-17980) - #901
Conversation
…ars (BLO-17980) A read-only GET Pod returns the full container spec, including every literal env[].value. Any identity with Pod read in a tenant namespace can therefore retrieve credentials injected that way -- the defect reported in BLO-17973. The in-repo builders were already clean (one literal HOME, everything else via envFrom.secretRef), but nothing pinned that: no test asserted the env array, so a future literal credential would have shipped silently. - Add sensitive-env-guard.ts: isSensitiveEnvName /TOKEN|SECRET|PASSWORD|KEY| CREDENTIAL|AUTH/i, findLiteralSensitiveEnvVars (walks containers, initContainers and ephemeralContainers), and a fail-closed assertNoLiteralSensitiveEnv. - Call the assertion from buildJobManifest and buildSandboxCrManifest, so an offending manifest throws at build time rather than reaching the API server. - Exempt *_FILE path pointers: those hold a mount path, not the secret, and are the pattern we want callers reaching for. - The thrown message names container.env[NAME] but never the value, so the guard cannot itself become a disclosure path. - Pin the literal allowlist in both builder suites and assert findLiteralSensitiveEnvVars returns [] on the real manifests. Mirrors the same check in the external claude_k8s adapter (paperclip-adapter-claude-k8s job-manifest.ts), which renders the production agent-job pods; this covers the in-repo sandbox-provider path. Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
@ally please review this security guard (BLO-17980 / BLO-17973). Focus areas:
|
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
…point The builder-only guard was bypassable by construction: createJob and createSandboxCr are exported and accept an arbitrary manifest, so a hand-built one would have reached the API server unchecked. Nothing does that today (plugin.ts is the only caller and it uses the builders), but the guard should not depend on that staying true. - Add assertManifestHasNoLiteralSensitiveEnv, which locates pod specs structurally rather than by path, so it works on both the Job shape (spec.template.spec) and the Sandbox CR shape (spec.podTemplate.spec) without the call site knowing which it holds. Depth-bounded, so a cyclic or deeply nested manifest terminates instead of blowing the stack. - Call it from createJob and createSandboxCr, immediately before the create. - Cover both manifest shapes, the no-pod-spec case, and a cyclic manifest. The builder-level assertions stay: they fail earlier, with a job name in the message, which is the better developer experience. This is defence in depth. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally re-review at head 79c676e — I addressed my own focus area 3 since the last request. New in this push: Remaining focus areas, unchanged and still worth your eye:
|
…t two collectContainers scanned initContainers, containers and ephemeralContainers, but collectPodSpecs recognised a pod spec only by containers or initContainers. A spec carrying only ephemeralContainers would therefore be walked past and never scanned -- the recogniser and the scanner disagreeing about where containers live. Share one CONTAINER_LIST_KEYS constant between them so they cannot drift, and pin the agreement with a test that runs the same credential-bearing spec under each of the three keys. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally re-review at head Scope is confined to Focus areas:
Note the two red checks are pre-existing flakes unrelated to this diff ( |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 49f4daf
Critical Issues (1)
- [gstack/review + native-codex]
packages/plugins/sandbox-providers/kubernetes/src/sensitive-env-guard.ts:15— The guard is not fail-closed: it permits every literal whose name missesTOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH, including the known credential-bearingMCP_CONFIG. It also exempts every*_FILEvariable based only on its name, so{ name: "API_TOKEN_FILE", value: "<actual token>" }bypasses the guard even though the value is not a path. This leaves the originalGET Podcredential disclosure reachable through ordinary future manifest changes. Invert the policy: reject literalvalueentries unless the env name/value pair is on a narrow, explicit non-secret allowlist (currentlyHOME), and permit*_FILEonly after validating an allowed absolute mounted-secret path. Add regression cases forMCP_CONFIGand a non-path*_FILEvalue.
Important Issues (0)
Suggestions (1)
- [pr-review-toolkit/tests]
packages/plugins/sandbox-providers/kubernetes/src/job-orchestrator.ts:17— Add mockedcreateJobandcreateSandboxCrtests proving a hand-built leaking manifest throws before either Kubernetes client method is called. The guard itself is tested, but the security-critical API-call wiring is not, so removing either choke-point call would currently leave the suite green.
Strengths
- The guard is placed immediately before both Kubernetes workload creation calls, so callers cannot bypass it by skipping the pure builders.
containers,initContainers, andephemeralContainersare recognized and scanned from one shared key list.- Findings retain only container and environment-variable names; no throw path reads or interpolates the literal value.
Recommended Action
- Replace the sensitive-name denylist with an explicit safe-literal policy before merge.
- Add direct choke-point regression tests in this cycle.
|
Following up on @ally's review of this PR, which landed at 11:06Z — before the PR was merged at 13:13Z, and was not addressed first. Recording the resolution here so the thread is not left dangling. The Critical finding was correct and I have adopted it in full, in #917. The guard shipped here was a name denylist (
I had flagged (1) myself in this PR's body as focus area 1 and shipped it as a documented residual. That was the wrong call: a guard whose stated purpose is to be fail-closed should not merge with a known bypass, and labelling it a "residual" made it sound smaller than it was. #917 inverts to an explicit safe-literal allowlist and validates No live exposure from this repo in the meantime — both builders emit exactly one literal ( For anyone tracing the production side: the actual reported leak is in the external adapter that renders production agent-job pods, now open as kkroo/paperclip-adapter-claude-k8s#30. Refs BLO-17980 / BLO-17973. |
…980) (#917) * fix(k8s-sandbox): invert credential env guard to an allowlist (BLO-17980) Review of #901 found the guard was not actually fail-closed. It rejected names matching /TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH/i, which leaves two holes: 1. Every literal whose name misses the pattern passes. The known counter-example is MCP_CONFIG, which carries a merged mcp.json with embedded `Authorization: Bearer ...` headers and matches nothing. 2. `*_FILE` was exempted on the name alone, so { name: "API_TOKEN_FILE", value: "<the actual token>" } passed even though the value was never a path. Both leave the original GET Pod disclosure reachable through ordinary future manifest changes, which is the defect #901 existed to prevent. Invert the policy: a literal `value` is refused unless affirmatively known safe — an explicitly allowlisted non-secret name (currently just HOME), or a `*_FILE` pointer whose value validates as an absolute path, free of whitespace and `..`, under a known secret-mount root. Adding a new literal env var now requires a deliberate edit to SAFE_LITERAL_ENV_NAMES, which is the review checkpoint this class of defect warrants. The error message names the variable and the reason but never the value, so the guard cannot itself disclose. Also adds the choke-point regression tests the review asked for: createJob and createSandboxCr must reject a leaking manifest *before* touching the Kubernetes client, so deleting either call site fails the suite rather than passing silently. Verified by injecting a literal MCP_CONFIG into pod-spec-builder: the build throws, and the injected value appears zero times in the output. Refs BLO-17980, BLO-17973. Follow-up to #901. * fix(k8s-sandbox): bind the safe-literal allowlist to name=value pairs Ally's review of #917 found that the allowlist was keyed on the env *name* alone, so `{ name: "HOME", value: "<credential>" }` passed `isSafeLiteralEnv` and reached both API-server choke points. That is the same unsound policy class the denylist inversion was meant to remove, narrowed to one variable. - SAFE_LITERAL_ENV_NAMES (Set<string>) becomes SAFE_LITERAL_ENV_VALUES (Map<string, ReadonlySet<string>>); an allowlisted name is accepted only with an exact allowlisted value. Both builders emit HOME=/home/paperclip, so that single pair is sufficient. - New `value-not-allowlisted` reason distinguishes "approved name, wrong value" from "name never approved". As before, the value itself is never echoed. - Narrow the /paperclip/ mount root to /paperclip/.secrets/ (Ally's suggestion). /paperclip is the whole persistence volume, so it admitted every workspace path; .secrets is the actual convention (PAPERCLIP_GITHUB_TOKEN_FILE default in statefulset.yaml). No builder emits a *_FILE var today, so nothing breaks. Regression tests, per the review: a non-allowlisted HOME value is rejected in createJob and createSandboxCr with the Kubernetes client asserted untouched, plus unit coverage for the pair rule and for a non-secret /paperclip path. Negative-verified by injecting HOME=<credential> into pod-spec-builder: the build throws 13x naming agent.env[HOME] (value-not-allowlisted), and the injected value appears 0 times in the output. --------- Co-authored-by: CTO <cto@blockcast.net> Co-authored-by: kkroo <kkroo@paperclip.ai> Co-authored-by: Omar Ramadan <omar.ramadan93@gmail.com>
Thinking Path
Linked Issues or Issue Description
Refs BLO-17973 (critical security finding), Refs BLO-17980 (code fix + guard sub-task).
These are tracked in Paperclip rather than GitHub Issues. Restating the underlying bug inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR":
Bug — what happened: Agent-job Pod specs inject sensitive runtime inputs as literal
spec.containers[].env[].value. A read-onlyGET Podthrough the Kubernetes MCP therefore returns credential values to any caller with Pod read in thepaperclipnamespace.Expected: Sensitive values reach the container by reference (
envFrom.secretRef,valueFrom.secretKeyRef, or a mounted secret volume), so the Pod object never contains them.Impact: Credential disclosure to any Pod-read identity. Two runs are in the exposure inventory. Credential rotation is deliberately sequenced after the injection fix, since rotating into a spec that re-exposes literal values would burn the new credentials too.
What Changed
src/sensitive-env-guard.ts—isSensitiveEnvName(/TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH/i),findLiteralSensitiveEnvVars(walkscontainers,initContainers, andephemeralContainers), and fail-closedassertNoLiteralSensitiveEnv.src/pod-spec-builder.ts—buildJobManifestnow asserts on the rendered pod spec before returning.src/sandbox-cr-builder.ts—buildSandboxCrManifestdoes the same for the Sandbox CR'spodTemplate.src/job-orchestrator.ts/src/sandbox-cr-orchestrator.ts—createJobandcreateSandboxCrre-assert immediately before the API call, via a new shape-agnosticassertManifestHasNoLiteralSensitiveEnvthat locates pod specs structurally (works on bothspec.template.specandspec.podTemplate.spec, depth-bounded so a cyclic manifest terminates). Both functions are exported and take an arbitrary manifest, so a builder-only guard was bypassable by construction. Defence in depth: the builder assertions fail earlier and name the job, the choke-point assertion cannot be routed around.*_FILEnames are exempt: they hold a mount path, not the secret (e.g.PAPERCLIP_GITHUB_TOKEN_FILE), and are the pattern we want callers reaching for. Rejecting them would push people off the secure path.container.env[NAME]but never the value, so the guard cannot itself become a disclosure path.test/unit/sensitive-env-guard.test.ts(12 assertions), plus 2 assertions added to each of thepod-spec-builderandsandbox-cr-buildersuites pinning the literal allowlist and assertingfindLiteralSensitiveEnvVarsreturns[]on the real manifests.Verification
Automated — CI job: the
General testsshard (vitest), coveringpackages/plugins/sandbox-providers/kubernetes/test/unit/. Assertions that exercise the acceptance criteria:Locally:
Negative verification — the part that matters. A guard that never fires is not a guard, so I confirmed it actually rejects the regression it exists to catch. Temporarily adding
{ name: "ANTHROPIC_API_KEY", value: "sk-leaked-literal" }topod-spec-builder.ts:Note the message names the variable and not the value. Reverting the injection returns the suite to green.
Reviewer note: 5 test files (
kube-client,plugin,plugin-lease-lifecycle,types,wrap-command-with-env) fail to collect in my sandbox on unresolved@kubernetes/client-node/@paperclipai/plugin-sdk/zod, because I installed with--ignore-scripts. I verified this is identical on the stashed baseline — pre-existing local-env noise, unrelated to this change. CI installs normally.Risks
Low-to-moderate, and the moderate part is deliberate.
/KEY/imatchesPAPERCLIP_K8S_ISOLATION_KEY, which is not secret-bearing. Nothing in the current builders trips it, but a future non-secret name containing one of these tokens would need renaming or a*_FILE-style exemption. Erring toward false positives is the correct direction here.MCP_CONFIGis the real example — it ships a mergedmcp.jsonthat embeds MCPAuthorizationheaders and matches no pattern. The external adapter had to special-case it. This guard would not catch that class; raised as focus area 1.Model Used
Claude Opus 5 (
claude-opus-5), 1M context window, extended thinking enabled, with tool use and code execution, driven via Claude Code.Checklist
secret env pod spec credential,BLO-17980 OR BLO-17973, andsensitive env guard secretKeyRefacross all states; no duplicate. Nearest neighbours are fix(github): read app token from mounted secret #370 and fix(runtime): give the git CLI the bot GitHub credential (BLO-18484) #872, both merged and both about reading the GitHub bot token, not pod-spec env injection.SECURITY.mdnote if a reviewer wants one.🤖 Generated with Claude Code