feat(secrets): broker output redaction in labctl secret exec #105

Closed
opened 2026-07-10 17:36:45 +02:00 by dominik.polakovics · 2 comments

What to build

labctl secret exec pipes the wrapped command's stdout and stderr through a redactor: any occurrence of an injected secret's value — exact, base64, hex, or URL-encoded form (the matcher from #104) — is replaced with [REDACTED:NAME] before the bytes reach the terminal, and therefore before they can reach the transcript or chat. This kills the two biggest accidental-leak paths at the source: an API echoing the key back in an error response, and a casual secret exec FOO -- printenv.

Implementation notes:

  • Rolling buffer across stream chunks so a value split over two reads is still caught; flush safely on close.
  • Only the values injected for THIS exec are scanned for (the broker already holds them — no extra fetch).
  • Child exit code is preserved; stderr stays stderr, stdout stays stdout.
  • The child runs on pipes, not a TTY — acceptable and documented (agents don't run interactive commands through the broker).
  • Contained redaction hits are quiet — no operator alert, no log of the value. This is the harness working, not an incident (grill decision).

Acceptance criteria

  • A wrapped command that prints an injected value (any of the four forms) reaches the terminal as [REDACTED:NAME]
  • Value split across write chunks is still redacted (rolling-buffer test)
  • Exit code, stdout/stderr separation, and non-secret output pass through byte-identical
  • secret exec NAME -- printenv NAME shows only the redaction placeholder
  • No redaction event is logged or surfaced with the plaintext value anywhere

Blocked by

  • #104 (secrets + broker exec + matcher must exist)
## What to build `labctl secret exec` pipes the wrapped command's stdout and stderr through a redactor: any occurrence of an injected secret's value — exact, base64, hex, or URL-encoded form (the matcher from #104) — is replaced with `[REDACTED:NAME]` before the bytes reach the terminal, and therefore before they can reach the transcript or chat. This kills the two biggest accidental-leak paths at the source: an API echoing the key back in an error response, and a casual `secret exec FOO -- printenv`. Implementation notes: - Rolling buffer across stream chunks so a value split over two reads is still caught; flush safely on close. - Only the values injected for THIS exec are scanned for (the broker already holds them — no extra fetch). - Child exit code is preserved; stderr stays stderr, stdout stays stdout. - The child runs on pipes, not a TTY — acceptable and documented (agents don't run interactive commands through the broker). - Contained redaction hits are quiet — no operator alert, no log of the value. This is the harness working, not an incident (grill decision). ## Acceptance criteria - [ ] A wrapped command that prints an injected value (any of the four forms) reaches the terminal as `[REDACTED:NAME]` - [ ] Value split across write chunks is still redacted (rolling-buffer test) - [ ] Exit code, stdout/stderr separation, and non-secret output pass through byte-identical - [ ] `secret exec NAME -- printenv NAME` shows only the redaction placeholder - [ ] No redaction event is logged or surfaced with the plaintext value anywhere ## Blocked by - #104 (secrets + broker exec + matcher must exist)
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: labctl secret exec pipes the wrapped command's stdout and stderr through a redactor so any injected secret value — exact, base64, hex, or URL-encoded form — reaches the terminal (and thus the transcript and chat) only as [REDACTED:NAME].

Current behavior:
labctl secret exec <NAME...> -- <cmd> (landed in #104) fetches the named values at exec time, injects them into the child process env, and streams the child's stdout/stderr through untouched. A command that echoes a value — an API error response quoting the key, or a casual printenv — puts the plaintext on the terminal. The streaming matcher (secrets.NewMatcher / Matcher.Feed in the shared secrets package) exists with tests but has no consumer yet.

Desired behavior:
The broker scans both child streams for the values it injected for THIS exec (it already holds them — no extra fetch) and replaces every occurrence, in any of the four derived forms, with [REDACTED:NAME] before the bytes are written out. A value split across write chunks is still caught (rolling buffer; held-back bytes flushed safely on stream close). Exit code is preserved; stdout stays stdout and stderr stays stderr; non-secret bytes pass through byte-identical. The child runs on pipes, not a TTY — acceptable and documented (agents don't run interactive commands through the broker). Redaction hits are quiet: no operator alert, no log entry — this is the harness working, not an incident.

Key interfaces:

  • The secret exec code path in the labctl broker — wrap the child's stdout/stderr pipes with a redacting writer/reader before forwarding
  • secrets.NewMatcher(map[string]string) and Matcher.Feed(chunk) []Match — matches report name, form, and position; the redactor must hold back a tail window so a match straddling a chunk boundary can still be rewritten before emission. A Matcher is not safe for concurrent use — one matcher (or serialised access) per stream
  • Replacement token format: [REDACTED:NAME] where NAME is the secret's env-var name

Acceptance criteria:

  • A wrapped command printing an injected value (any of the four forms) reaches the terminal as [REDACTED:NAME]
  • A value split across write chunks is still redacted (rolling-buffer test)
  • Exit code, stdout/stderr separation, and non-secret output pass through byte-identical
  • secret exec NAME -- printenv NAME shows only the redaction placeholder
  • No redaction event is logged or surfaced with the plaintext value anywhere

Out of scope:

  • Scanning for secrets not injected by this exec (other repo secrets are invisible here by design)
  • Alerting/telemetry on redaction hits (contained hits stay quiet — grill decision)
  • Transcript scanning and exposure flags (#108), git push guard (#106), tracker-write sanitization (#107)
  • TTY passthrough for interactive child commands
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** `labctl secret exec` pipes the wrapped command's stdout and stderr through a redactor so any injected secret value — exact, base64, hex, or URL-encoded form — reaches the terminal (and thus the transcript and chat) only as `[REDACTED:NAME]`. **Current behavior:** `labctl secret exec <NAME...> -- <cmd>` (landed in #104) fetches the named values at exec time, injects them into the child process env, and streams the child's stdout/stderr through untouched. A command that echoes a value — an API error response quoting the key, or a casual `printenv` — puts the plaintext on the terminal. The streaming matcher (`secrets.NewMatcher` / `Matcher.Feed` in the shared secrets package) exists with tests but has no consumer yet. **Desired behavior:** The broker scans both child streams for the values it injected for THIS exec (it already holds them — no extra fetch) and replaces every occurrence, in any of the four derived forms, with `[REDACTED:NAME]` before the bytes are written out. A value split across write chunks is still caught (rolling buffer; held-back bytes flushed safely on stream close). Exit code is preserved; stdout stays stdout and stderr stays stderr; non-secret bytes pass through byte-identical. The child runs on pipes, not a TTY — acceptable and documented (agents don't run interactive commands through the broker). Redaction hits are quiet: no operator alert, no log entry — this is the harness working, not an incident. **Key interfaces:** - The `secret exec` code path in the labctl broker — wrap the child's stdout/stderr pipes with a redacting writer/reader before forwarding - `secrets.NewMatcher(map[string]string)` and `Matcher.Feed(chunk) []Match` — matches report name, form, and position; the redactor must hold back a tail window so a match straddling a chunk boundary can still be rewritten before emission. A `Matcher` is not safe for concurrent use — one matcher (or serialised access) per stream - Replacement token format: `[REDACTED:NAME]` where NAME is the secret's env-var name **Acceptance criteria:** - [ ] A wrapped command printing an injected value (any of the four forms) reaches the terminal as `[REDACTED:NAME]` - [ ] A value split across write chunks is still redacted (rolling-buffer test) - [ ] Exit code, stdout/stderr separation, and non-secret output pass through byte-identical - [ ] `secret exec NAME -- printenv NAME` shows only the redaction placeholder - [ ] No redaction event is logged or surfaced with the plaintext value anywhere **Out of scope:** - Scanning for secrets not injected by this exec (other repo secrets are invisible here by design) - Alerting/telemetry on redaction hits (contained hits stay quiet — grill decision) - Transcript scanning and exposure flags (#108), git push guard (#106), tracker-write sanitization (#107) - TTY passthrough for interactive child commands
Author
Owner

This was generated by AI while landing a PR.

PR #116 landing — BLOCKED (needs rework), not merged.

#116 passed CI in isolation, but it was tested against a main that did not yet contain #108 (PR #118, merged just now). The two collide and cannot coexist as-is:

  • internal/secrets/redactor.go + redactor_test.go — add/add conflict. Both #116 and #108 add a new file at this path, each declaring type Redactor / func NewRedactor in package internal/secrets with incompatible signatures:

    • #116 (this PR): NewRedactor(dst io.Writer, m *Matcher) — a streaming io.Writer redactor (Write/Flush) for broker stdout/stderr.
    • #108 (now on main): NewRedactor(values map[string]string) — a whole-string Redact(s string) (string, []string) for chat render.

    Even if the file conflict were force-resolved, the two definitions duplicate-declare Redactor/NewRedactor → compile break. This is a design reconciliation, not a mechanical merge.

  • internal/labctl/labctl.go — content conflict in the secret subcommand help text (#116 annotates exec with redaction; #106/PR #119, also now on main, added a scan line). Trivial union, but it must be redone on top of the new main.

Rework needed before #116 can land: rebase afk/105 onto current main and reconcile the two redactors — e.g. rename this streaming one to StreamRedactor (or fold both into one type exposing both a streaming and a whole-string entry point), then resolve the labctl.go help-text union. Re-run CI after the rebase (its green predates #118/#119 and no longer reflects main).

Migration note: the 0008→0009 run_title renumber this PR carries is now redundant — main already renumbered it (and #108 added 0010_repo_secret_exposure); the current sequence is a gapless 0001–0010 with no duplicates. The rebase should drop the redundant renfor a clean diff.

#116 stays open in this blocked state for an AFK re-run or manual rebase.

> *This was generated by AI while landing a PR.* **PR #116 landing — BLOCKED (needs rework), not merged.** #116 passed CI in isolation, but it was tested against a `main` that did not yet contain #108 (PR #118, merged just now). The two collide and cannot coexist as-is: - **`internal/secrets/redactor.go` + `redactor_test.go` — add/add conflict.** Both #116 and #108 add a new file at this path, each declaring `type Redactor` / `func NewRedactor` in package `internal/secrets` with **incompatible signatures**: - #116 (this PR): `NewRedactor(dst io.Writer, m *Matcher)` — a streaming `io.Writer` redactor (`Write`/`Flush`) for broker stdout/stderr. - #108 (now on main): `NewRedactor(values map[string]string)` — a whole-string `Redact(s string) (string, []string)` for chat render. Even if the file conflict were force-resolved, the two definitions duplicate-declare `Redactor`/`NewRedactor` → compile break. This is a design reconciliation, not a mechanical merge. - **`internal/labctl/labctl.go` — content conflict** in the `secret` subcommand help text (#116 annotates `exec` with redaction; #106/PR #119, also now on main, added a `scan` line). Trivial union, but it must be redone on top of the new main. **Rework needed before #116 can land:** rebase `afk/105` onto current `main` and reconcile the two redactors — e.g. rename this streaming one to `StreamRedactor` (or fold both into one type exposing both a streaming and a whole-string entry point), then resolve the `labctl.go` help-text union. Re-run CI after the rebase (its green predates #118/#119 and no longer reflects main). Migration note: the `0008→0009 run_title` renumber this PR carries is now redundant — `main` already renumbered it (and #108 added `0010_repo_secret_exposure`); the current sequence is a gapless `0001–0010` with no duplicates. The rebase should drop the redundant renfor a clean diff. #116 stays open in this blocked state for an AFK re-run or manual rebase.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#105
No description provided.