Skip to content

feat(runners): redact sensitive task options from serialized/printed state#1232

Merged
ocervell merged 6 commits into
mainfrom
feat/sensitive-options
Jul 6, 2026
Merged

feat(runners): redact sensitive task options from serialized/printed state#1232
ocervell merged 6 commits into
mainfrom
feat/sensitive-options

Conversation

@ocervell

@ocervell ocervell commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

Adds a sensitive: True option meta-flag (a sibling of the existing internal flag). An option value flagged sensitive is masked with [REDACTED] everywhere runner state is persisted, serialized, or printed, while the real value still travels in-flight to the worker and runs in the actual subprocess.

This lets users pass their own addon API keys through the existing options channel — e.g. the ai task's api_key (now marked sensitive) — without the secret ever landing in the Mongo runner doc, API responses, --driver api egress, or logs.

Why

We want users to bring their own secrets (BYO addon keys, etc.) per run. The opts channel already carries these to the worker and persists runner state — but it persisted the secret in plaintext. Rather than build a separate config-override channel, we keep secrets on the opts channel and make the data model secret-aware. (Design: docs/superpowers/specs/2026-06-29-sensitive-options-and-per-run-overrides-design.md in gke-admin.)

Leak surfaces covered

  1. toDict() run_opts — Mongo runner doc, API responses, --driver api egress (_base.Runner.resolved_opts).
  2. The built command string — a secret passed as a CLI flag (--api-key X) is masked in the cmd that toDict() and print_command expose, while the executed subprocess keeps the real value.
  3. Debug / console echoes of the command and its options.

Accepted, non-user-facing exposures (out of scope, by design)

  • The value still rides the Redis broker message transiently (same as the existing external-keys vault).
  • If a task passes a secret as a CLI flag, it appears in worker process args (ps); tasks that read the secret from an env var avoid this.

Changes

  • runners/_base.pyRunner.sensitive_opt_names (resolved from the runner's own opts/meta_opts or, for Task/Workflow/Scan, from its config tree's task classes; memoized) + redaction in resolved_opts. New REDACTED_OPT_VALUE constant.
  • runners/command.py — build a redacted command alongside the real one; emit it from toDict() and print_command. No behavior change for tasks without a sensitive opt (cmd_redacted == cmd, flag off).
  • cli_helper.py — drop the sensitive key before building click options (it's not a click param).
  • tasks/ai.py — mark api_key sensitive: True.
  • tests/unit/test_sensitive_opts.py — both leak surfaces (run_opts + cmd) for a Command task, the Task-runner path (ai api_key), and the non-sensitive no-op path.

Testing

  • New test_sensitive_opts.py: 7 passing.
  • test_command.py, test_python_runner.py, test_ai_task_opts.py: green (33 total).
  • flake8 clean on all changed files.
  • Verified secator x ai --help still builds the --api-key option (the sensitive key no longer breaks click).
  • Manually verified: executed cmd keeps the real secret; toDict()/printed cmd and run_opts are masked.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Sensitive option values are now automatically hidden in displayed and serialized command/runner output.
    • Secret fields such as API keys are marked to stay masked wherever they appear.
  • Bug Fixes

    • Prevented sensitive option metadata from being treated like a regular command option.
    • Improved command printing so secret values are no longer exposed in logs or debug output.

…state

Add a 'sensitive: True' option meta-flag (sibling of 'internal'). Option
values flagged sensitive are masked with [REDACTED] everywhere runner state
is persisted, serialized, or printed -- toDict() run_opts, the built command
string (toDict cmd + print_command), and debug echoes -- while the real value
still travels in-flight to the worker and runs in the actual subprocess.

This lets users pass their own addon API keys through the existing opts
channel (e.g. the ai task's api_key, now marked sensitive) without the secret
landing in the Mongo runner doc, API responses, --driver api egress, or logs.

- _base.py: Runner.sensitive_opt_names (resolved from the runner's own opts or
  its config tree's task classes, memoized) + redaction in resolved_opts.
- command.py: build a redacted cmd alongside the real one; emit it from
  toDict() and print_command. No behavior change for tasks without a sensitive
  opt (cmd_redacted == cmd).
- cli_helper.py: drop the 'sensitive' key before building click options.
- ai.py: mark api_key sensitive.
- tests: cover both leak surfaces + the non-sensitive no-op path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWZJdNgJKrn1XHkENKqVo7
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d1fd7709-bace-4874-9229-a1ed55361391

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds a REDACTED_OPT_VALUE sentinel and plumbs sensitive-option detection through Command._build_cmd, Runner.resolved_opts, and CLI option registration. Commands build a parallel redacted string for logging and serialization while keeping the real value for execution. The ai task's api_key is marked sensitive: True, and new unit tests verify the behavior end-to-end.

Changes

Sensitive Option Redaction

Layer / File(s) Summary
REDACTED_OPT_VALUE constant and CLI sensitive key removal
secator/runners/_base.py, secator/cli_helper.py, secator/runners/command.py
Defines the [REDACTED] sentinel constant, strips the sensitive key from click option configs before registration, and imports the constant into command.py.
Command build and serialization redaction
secator/runners/command.py
Adds cmd_redacted and _has_sensitive_cmd_opts to Command; _build_cmd constructs a parallel redacted options string; _build_opt_str gains a redact parameter; toDict and print_command switch to the redacted form when sensitive opts are present.
Runner.sensitive_opt_names and resolved_opts redaction
secator/runners/_base.py
Adds sensitive_opt_names cached property scanning own opts/meta_opts and referenced task classes; resolved_opts replaces truthy sensitive values with REDACTED_OPT_VALUE.
ai task api_key marked sensitive and unit tests
secator/tasks/ai.py, tests/unit/test_sensitive_opts.py
Adds sensitive: True to the api_key option; new test module covers command-level and runner-level redaction including edge cases for empty values and plain commands.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A secret's a secret, I hop to declare,
No tokens in logs, no keys in the air!
[REDACTED] I stamp on each sensitive line,
The real value runs, but the print looks just fine.
With tests and a flag, the warren is sealed—
Your API keys shall never be revealed! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: redacting sensitive task options from serialized and printed runner state.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sensitive-options

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
tests/unit/test_sensitive_opts.py (1)

35-36: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a direct print_command() redaction assertion.

The PR objective includes masking secrets in printed command output, but _build() forces print_cmd=False and no test calls print_command(). That leaves the user-visible logging path unverified.

Also applies to: 44-58

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_sensitive_opts.py` around lines 35 - 36, The
sensitive-options tests only exercise object construction because _build()
forces print_cmd=False, so the printed command redaction path is never verified.
Update the test helper or add a dedicated test that instantiates the relevant
class with printing enabled and calls print_command() directly, then assert that
secret-bearing options are masked in the returned/printed command for the
symbols _build() and print_command().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@secator/runners/_base.py`:
- Around line 323-328: The sensitive option discovery in the task traversal is
failing open because both broad exception handlers in the collection logic
silently continue, which can leave sensitive_opt_names incomplete and let
resolved_opts expose secrets. Tighten the handling around the
collect(Task.get_task_class(node.name)) flow in the redaction/metadata
resolution code: catch only expected discovery/tree traversal errors, and on
failure either log a clear redaction-warning or abort the redaction path instead
of swallowing the exception and continuing.
- Around line 332-338: resolved_opts only redacts runtime values, but toDict()
still serializes config and opts where sensitive option metadata can leak
through real defaults. Update the serialization path around resolved_opts and
toDict() so sensitive entries are sanitized in config/opts as well as run_opts,
using the existing sensitive_opt_names and REDACTED_OPT_VALUE logic to mask any
default or metadata tied to sensitive keys.

In `@secator/runners/command.py`:
- Around line 660-667: The option debug logging is still exposing secrets
because `_process_opts()` logs raw values before redaction and `opts_display`
only masks the top-level `value`, leaving nested fields like `conf.default`
untouched. Update the logging path around `_process_opts()`,
`self.debug('options', ...)`, and the `cmd_options` redaction logic to use a
single shared sanitizer that recursively redacts every sensitive field in the
option object, including nested config values, before any debug call.
- Around line 653-667: The command emission paths still use the raw command in
`yielder()`-related output, which can expose sensitive options even though
`print_command()` already redacts them. Update the command display logic to use
the same redacted command source everywhere a message is emitted, replacing
direct `self.cmd` usage in dry-run `Info(message=...)` and worker-start
`cmd_str` with `self._display_cmd()` (or the existing redacted command selection
used in `print_command()`), and keep `cmd_display`/`opts_display` consistent
with the redaction rules in `CommandRunner`.

In `@tests/unit/test_sensitive_opts.py`:
- Around line 10-11: Remove the module-level logging initialization in the test
module so importing it no longer mutates global logging state. Drop the
top-level level/setup_logging call pair from test_sensitive_opts, and if logging
is actually needed for any specific case, move it into a test-local setup inside
the relevant test function or fixture rather than module scope.

---

Nitpick comments:
In `@tests/unit/test_sensitive_opts.py`:
- Around line 35-36: The sensitive-options tests only exercise object
construction because _build() forces print_cmd=False, so the printed command
redaction path is never verified. Update the test helper or add a dedicated test
that instantiates the relevant class with printing enabled and calls
print_command() directly, then assert that secret-bearing options are masked in
the returned/printed command for the symbols _build() and print_command().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 388b0a04-514b-4d38-82a7-5a3bc840d8ab

📥 Commits

Reviewing files that changed from the base of the PR and between e26f394 and a1fff88.

📒 Files selected for processing (5)
  • secator/cli_helper.py
  • secator/runners/_base.py
  • secator/runners/command.py
  • secator/tasks/ai.py
  • tests/unit/test_sensitive_opts.py

Comment thread secator/runners/_base.py Outdated
Comment thread secator/runners/_base.py
Comment thread secator/runners/command.py Outdated
Comment thread secator/runners/command.py
Comment thread tests/unit/test_sensitive_opts.py Outdated
ocervell and others added 3 commits June 29, 2026 20:59
Address valid findings from the PR review:

- Serialized config/opts: an option's `default` can itself be a secret --
  ai.api_key defaults to CONFIG.addons.ai.api_key, so the *platform* key was
  landing in every runner doc via toDict()['config'].opts.<opt>.default, even
  though run_opts was already redacted. Add Runner._redact_sensitive (recursive,
  operates on a deep copy so the live config is never mutated) and apply it to
  the serialized config/opts in toDict().
- Command emission paths that bypassed redaction: the dry-run Info(message=cmd)
  and the IN_WORKER cmd stream used the raw self.cmd. Route every display/emit
  site through a new Command.cmd_for_display property (execution still uses the
  real self.cmd).
- Debug echoes: print_command's cmd_options dump masked only the top-level
  value, leaving a secret-bearing conf.default; and _get_opt_value's verbose
  'got opt value' debug logged the raw value/values. Both now redacted.
- sensitive_opt_names: stop failing open silently -- narrow the per-node catch
  to ValueError and log a warning if whole-tree discovery fails (an unresolved
  set would let a secret through).
- tests: cover print_command(), the dry-run emission path, nested-default
  redaction, and the serialized-config platform-default leak. Drop module-level
  setup_logging (no global logging mutation on import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWZJdNgJKrn1XHkENKqVo7
…er secrets)

The serialized config/opts scrub from the prior commit guarded a non-issue: an
option default must never be a config-sourced secret. The correct convention is
an empty default plus a runtime `passed or CONFIG.addons.*` fallback in the task
(ai.api_key was fixed this way in a separate PR), so config/opts carry no secret
and need no masking.

Kept (these redact the *user-supplied* value, a real secret surface):
- cmd_for_display on every emit path (dry-run Info, IN_WORKER stream, toDict, print)
- run_opts redaction via resolved_opts
- _get_opt_value verbose-debug value/values masking
- print_command cmd_options value masking (reverted to value-only, no default)
- sensitive_opt_names fail-loud (warn instead of silent swallow)

Removed: Runner._redact_sensitive + toDict deepcopy/scrub + the two tests that
asserted default redaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWZJdNgJKrn1XHkENKqVo7
…t (CRITICAL)

secator-api serves task opts (including defaults) to the UI, so setting the
api_key/api_base opt `default` to a CONFIG value leaked the platform's LLM API
key into the Runner Create form for every user. Default these opts to empty and
fall back to CONFIG.addons.ai.{api_key,api_base} at runtime
(`api_key = passed or CONFIG.addons.ai.api_key`). Supersedes the earlier
`internal` approach. No secret is ever a task-option default now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5vSjfkBuGAAHdKxHS3ySm
@ocervell ocervell added the feature:secret-hardening Secret hygiene: redact sensitive opts, no secret defaults label Jun 30, 2026
Comment thread secator/runners/_base.py
Comment on lines +291 to +334
@property
def sensitive_opt_names(self):
"""Names of options flagged ``sensitive: True`` in their definition.

Sensitive option values are redacted from any serialized/printed runner state
(``toDict()``, debug echoes, the built command string) so a user-supplied secret
(e.g. a BYO addon API key) never lands in the Mongo runner doc, the API response,
``--driver api`` egress, or logs. The value still travels in-flight to the worker.

Resolved once and memoized. Sources, unioned defensively:
- a direct task instance's own ``opts``/``meta_opts`` (Command / PythonRunner),
- the task classes referenced by this runner's config tree (Task / Workflow / Scan,
which carry no ``opts`` of their own).
"""
cached = getattr(self, '_sensitive_opt_names_cache', None)
if cached is not None:
return cached
names = set()

def collect(holder):
for attr in ('opts', 'meta_opts'):
confs = getattr(holder, attr, None)
if isinstance(confs, dict):
names.update(k for k, v in confs.items() if isinstance(v, dict) and v.get('sensitive'))

collect(self)
try:
from secator.runners.task import Task
from secator.tree import build_runner_tree, get_flat_node_list
for node in get_flat_node_list(build_runner_tree(self.config)):
if node.type != 'task':
continue
try:
collect(Task.get_task_class(node.name))
except ValueError:
# Unknown task name in this node: skip it, but keep scanning the rest.
continue
except Exception as e:
# Discovery failed for the whole tree: we can't enumerate sensitive opts, so
# redaction may be incomplete. Surface it loudly rather than failing silently
# (a silent miss here would let a secret through into serialized/printed state).
logger.warning('Could not resolve sensitive option names for %s (redaction may be incomplete): %s', getattr(self, 'unique_name', self), e) # noqa: E501
self._sensitive_opt_names_cache = names
return names

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This code is way too complicated for just getting sensitive option names. Aim to simplify it. I don't think there is a need for a broad try / except here, or caching.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in b71f123. Dropped the memoization (discover_tasks() is already @cached, so it was redundant) and both try/except layers. The exception-based get_task_class lookup is replaced by a non-raising registry lookup — unknown task names just skip, and a direct Command/PythonRunner instance is already covered by collect(self). Net −22/+9, and it now fails closed (the old broad except swallowed discovery errors → could return an incomplete set and leak a secret). Tests: test_sensitive_opts + test_runners green.

ocervell and others added 2 commits July 6, 2026 00:26
…ept)

Per review: getting sensitive option names was over-engineered. discover_tasks()
is already @cache'd so instance-level memoization was redundant, and the broad
try/except failed *open* (a swallowed discovery error silently returned an
incomplete set, letting a secret through). Replace the exception-based task
lookup with a non-raising registry lookup (unknown task names simply skip; a
direct Command/PythonRunner instance is already covered by collect(self)), and
drop both the memoization and the try/except. Fail-closed and ~half the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5vSjfkBuGAAHdKxHS3ySm
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5vSjfkBuGAAHdKxHS3ySm
@ocervell ocervell merged commit 0203f02 into main Jul 6, 2026
17 of 18 checks passed
@ocervell ocervell deleted the feat/sensitive-options branch July 6, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:secret-hardening Secret hygiene: redact sensitive opts, no secret defaults

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant