Skip to content

feat(k8s-sandbox): fail-closed guard against literal credential env vars (BLO-17980) - #901

Merged
kkroo merged 3 commits into
masterfrom
blo-17980-sensitive-env-guard
Aug 1, 2026
Merged

feat(k8s-sandbox): fail-closed guard against literal credential env vars (BLO-17980)#901
kkroo merged 3 commits into
masterfrom
blo-17980-sensitive-env-guard

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agents execute inside Kubernetes pods that this repo's sandbox-providers/kubernetes plugin templates, and those pods need credentials (model API keys, bootstrap tokens) to do anything
  • A read-only GET Pod returns the full container spec, including every literal env[].value — so any identity with Pod read in a tenant namespace can retrieve any credential injected that way
  • This was reported as a critical security finding (BLO-17973) after it was observed live on production agent jobs
  • The in-repo builders here were already clean — one literal HOME, everything else via envFrom.secretRef — but nothing enforced that: no test asserted the env array, so the next person to add a literal credential would have shipped it silently
  • This pull request adds a fail-closed guard that refuses to build a pod spec carrying credential material in a literal env value, and wires it into both builders
  • The benefit is that the safe property stops being an accident of the current code and becomes an invariant CI enforces

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-only GET Pod through the Kubernetes MCP therefore returns credential values to any caller with Pod read in the paperclip namespace.

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.

Scope — please read before reviewing. The production agent-job pods are rendered by the external claude_k8s adapter (kkroo/paperclip-adapter-claude-k8s, src/server/job-manifest.ts), vendored at Dockerfile:399-405 — that is where the actual leak was found, and that fix is not in this PR (it is blocked on a repo-access gate tracked on BLO-17980). This PR hardens the in-repo sandbox-providers/kubernetes path with the same check, so the path we own cannot regress into the same defect.

What Changed

  • New src/sensitive-env-guard.tsisSensitiveEnvName (/TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH/i), findLiteralSensitiveEnvVars (walks containers, initContainers, and ephemeralContainers), and fail-closed assertNoLiteralSensitiveEnv.
  • src/pod-spec-builder.tsbuildJobManifest now asserts on the rendered pod spec before returning.
  • src/sandbox-cr-builder.tsbuildSandboxCrManifest does the same for the Sandbox CR's podTemplate.
  • src/job-orchestrator.ts / src/sandbox-cr-orchestrator.tscreateJob and createSandboxCr re-assert immediately before the API call, via a new shape-agnostic assertManifestHasNoLiteralSensitiveEnv that locates pod specs structurally (works on both spec.template.spec and spec.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.
  • *_FILE names 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.
  • The thrown error names container.env[NAME] but never the value, so the guard cannot itself become a disclosure path.
  • Tests: new test/unit/sensitive-env-guard.test.ts (12 assertions), plus 2 assertions added to each of the pod-spec-builder and sandbox-cr-builder suites pinning the literal allowlist and asserting findLiteralSensitiveEnvVars returns [] on the real manifests.

Verification

Automated — CI job: the General tests shard (vitest), covering packages/plugins/sandbox-providers/kubernetes/test/unit/. Assertions that exercise the acceptance criteria:

emits no credential material in literal env values
  → expect(findLiteralSensitiveEnvVars(spec)).toEqual([])
passes credentials via envFrom.secretRef rather than literal env entries
  → pins container.env to exactly [{ name: "HOME", value: "/home/paperclip" }]

Locally:

npx vitest run test/unit/{sensitive-env-guard,pod-spec-builder,sandbox-cr-builder}.test.ts
  → 40/40 passing
npx vitest run test/unit/{...,job-orchestrator,sandbox-cr-orchestrator}.test.ts
  → 70/70 passing
npx vitest run     # full plugin suite
  → 141 passing (vs 122 with this change stashed: +19 new, 0 new failures)

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" } to pod-spec-builder.ts:

Failed Tests 13
Error: Job r-01h000...: refusing to build a pod spec with credential material in
literal env values (agent.env[ANTHROPIC_API_KEY]). A read-only GET Pod would
expose these. Route them through envFrom.secretRef, valueFrom.secretKeyRef, or a
mounted secret volume instead.

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.

  • Fail-closed on a lease-acquire path. The assertion throws rather than warns, so a future literal credential fails lease acquisition instead of leaking. That is the intended trade — a broken agent launch is recoverable, a disclosed credential is not — but it does mean a bad env name can break runs rather than degrade quietly. Called out explicitly for review as focus area 4.
  • Substring matching is coarse. /KEY/i matches PAPERCLIP_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.
  • Name-based detection cannot catch a credential in an innocuously-named var. MCP_CONFIG is the real example — it ships a merged mcp.json that embeds MCP Authorization headers and matches no pattern. The external adapter had to special-case it. This guard would not catch that class; raised as focus area 1.
  • The choke-point assertion runs on every job/sandbox creation, adding a bounded recursive walk of the manifest (depth ≤ 8) per create. Manifests are small and creates are infrequent, so the cost is negligible, but it is on the hot path for lease acquisition and worth a reviewer's eye.
  • No migration, no schema change, no behavioral change to any currently-valid manifest — both builders produce byte-identical output today.

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

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above — searched secret env pod spec credential, BLO-17980 OR BLO-17973, and sensitive env guard secretKeyRef across 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.
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — N/A, no UI surface
  • I have considered and documented any risks above
  • I have updated relevant documentation to reflect my changes — no doc change proposed; the guard is self-documenting via its error message. Happy to add a SECURITY.md note if a reviewer wants one.
  • All Paperclip CI gates are green — in flight at time of writing
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

…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>
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-17973
🔗 Paperclip issue: BLO-17980

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-17973
🔗 Paperclip issue: BLO-17980

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally please review this security guard (BLO-17980 / BLO-17973).

Focus areas:

  1. Regex soundness/TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH/i is substring-matching. Are there credential-bearing env names in our fleet it would miss (e.g. MCP_CONFIG, which carries an Authorization header but matches nothing)? The external adapter hit exactly that gap and had to special-case mcp.json.
  2. The *_FILE exemption in sensitive-env-guard.ts — is exempting path pointers correct, or does it open a bypass (someone naming a real secret FOO_TOKEN_FILE)?
  3. Fail-closed placement — the assertion runs at the end of buildJobManifest/buildSandboxCrManifest. Is there any pod-creating path in this plugin that bypasses both builders and would therefore skip the guard? sandbox-cr-orchestrator.ts and job-orchestrator.ts are the ones I checked.
  4. Whether throwing (vs. redacting and warning) is the right failure mode for a builder called on the lease-acquire path.

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

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>
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally re-review at head 79c676e — I addressed my own focus area 3 since the last request.

New in this push: createJob/createSandboxCr now re-assert at the API-call choke point via assertManifestHasNoLiteralSensitiveEnv. Both are exported and take an arbitrary manifest, so the builder-only guard was bypassable by construction (nothing bypasses it today — plugin.ts is the sole caller — but the guard should not depend on that).

Remaining focus areas, unchanged and still worth your eye:

  1. Regex soundness/TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH/i is name-based, so it structurally cannot catch a credential in an innocuously-named var. MCP_CONFIG is the live example: it ships a merged mcp.json embedding MCP Authorization headers and matches nothing. The external adapter had to special-case it. Is name-matching the right basis at all, or should this also look at value shape?
  2. The *_FILE exemption — I exempt path pointers so the guard does not push people off the secure mounted-secret pattern. Does that open a bypass for someone naming a real secret FOO_TOKEN_FILE?
  3. New: the structural pod-spec walk in assertManifestHasNoLiteralSensitiveEnv is depth-bounded at 8 and treats "object with a containers array" as a pod spec. Too loose? Too tight?
  4. Throwing vs redacting on the lease-acquire path — a bad env name now fails the launch rather than degrading quietly. I think that is right for a credential leak, but it is a real availability trade and I would like it challenged.

…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>
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally re-review at head 49f4daf1 — the previous request named 79c676e5, which is one commit stale (49f4daf1 landed 3 min later and changed the guard's container-detection logic), and no review has been submitted against either SHA.

Scope is confined to packages/plugins/sandbox-providers/kubernetes/** — zero server/ files. Context: BLO-17980 / BLO-17973 (critical: agent-job Pod specs carrying credentials as literal env[].value).

Focus areas:

  1. Fail-closed placementsensitive-env-guard.ts is invoked from the API-call sites (job-orchestrator.ts, sandbox-cr-orchestrator.ts) rather than only the pure builders, so a caller cannot construct a spec that bypasses it. Is that the right seam, and is any path into the K8s API left unguarded?
  2. Pattern list soundnessTOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH. False negatives concern me more than false positives here; MCP_CONFIG is the known example of credential-bearing content whose name matches nothing.
  3. Container-list detection (49f4daf1) — the guard now walks any container list on the spec (containers, initContainers, ephemeral). Confirm nothing that can carry env is missed.
  4. Error message hygiene — the throw names the offending variable but must never interpolate its value. That is the whole point of the ticket; please check it holds on every throw path.

Note the two red checks are pre-existing flakes unrelated to this diff (workspace-runtime.test.ts timed out at 15089ms against a 15000ms budget; tool-gateway.test.ts:2463 rate-limit timing). Both live in server/, which this PR does not touch. I have re-run the failed jobs.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 misses TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH, including the known credential-bearing MCP_CONFIG. It also exempts every *_FILE variable 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 original GET Pod credential disclosure reachable through ordinary future manifest changes. Invert the policy: reject literal value entries unless the env name/value pair is on a narrow, explicit non-secret allowlist (currently HOME), and permit *_FILE only after validating an allowed absolute mounted-secret path. Add regression cases for MCP_CONFIG and a non-path *_FILE value.

Important Issues (0)

Suggestions (1)

  • [pr-review-toolkit/tests] packages/plugins/sandbox-providers/kubernetes/src/job-orchestrator.ts:17 — Add mocked createJob and createSandboxCr tests 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, and ephemeralContainers are 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

  1. Replace the sensitive-name denylist with an explicit safe-literal policy before merge.
  2. Add direct choke-point regression tests in this cycle.

@kkroo
kkroo merged commit 1957ea2 into master Aug 1, 2026
32 of 34 checks passed
@kkroo
kkroo deleted the blo-17980-sensitive-env-guard branch August 1, 2026 13:13
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

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 (/TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL|AUTH/i), which cannot hold the invariant it claims to:

  1. Any literal whose name misses the pattern passes — MCP_CONFIG being the known credential-bearing counter-example.
  2. *_FILE was exempted on the name alone, so { name: "API_TOKEN_FILE", value: "<the actual token>" } sailed through.

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 *_FILE values as real mounted paths.

No live exposure from this repo in the meantime — both builders emit exactly one literal (HOME), so nothing here was actually leaking. This was a hardening gap, not an incident.

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.

allyblockcast Bot added a commit that referenced this pull request Aug 1, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant