Skip to content

fix: redact credentials in all task specs - #144

Open
kaiitunnz wants to merge 16 commits into
mainfrom
kaiitunnz/fix/redact-task-credentials
Open

kaiitunnz wants to merge 16 commits into
mainfrom
kaiitunnz/fix/redact-task-credentials

Conversation

@kaiitunnz

@kaiitunnz kaiitunnz commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Workflow task records are persisted and exposed through server APIs after submission and restart. PR #137 redacts credentials for raw_yaml and API tasks, but every other credential-bearing task spec also carries secrets, including data retrieval, data profiling, RAG, embedding, inference, training, model-backed/serve, omni, and SSH tasks.

This PR extends credential redaction across those task specs while preserving the in-memory task used for execution. It also prevents a task whose credential was redacted during persistence from being resumed after a server restart.

Changes

  • Spec-owned redaction. TaskSpecStrictBase and TaskSpecTemplateBase redact the output-destination delivery headers that every task shares; credential-bearing specs override redact_credentials()/has_redacted_credentials() for their own fields and chain super(), so a subclass only declares the fields it adds.
  • Shared redaction helpers. src/shared/utils/redact.py provides recursive key-based redaction and credential-marker detection for nested mappings and lists. Existing raw YAML and API redaction use the same helpers.
  • Task coverage. Every credential-bearing spec redacts its own fields: API, data retrieval, data profiling, RAG, embedding, inference, training (SFT/LoRA/PPO/image classification), model-backed/serve, omni, and SSH — covering provider configuration, adapter headers, Qdrant/search settings, serve API keys, data-source connection strings, checkpoint request headers, SSH authorized keys, environment credentials, and output-delivery request headers.
  • Persistence boundary. TaskRecord serializes a credential-redacted copy of the record and lets the default handler walk it, so the live task is untouched — execution and worker dispatch still see real credentials — while every dump option (by_alias, exclude/include, exclude_unset, exclude_defaults, exclude_none) applies to the redacted output natively.
  • Restart guard. Credential retention is checked at dispatch, per task. A task whose own credential was not retained across a restart fails on the pre-dispatch guard; a merged child whose credential was not retained is failed individually while the parent dispatches with its remaining valid children, so a well-shaped task is never sunk by a bad sibling it was batched with. Gating at dispatch rather than merge time also catches a credential that only surfaces after stage-reference resolution.
  • Tests and documentation. Added serialization, in-memory preservation, task-specific coverage, and restart-guard tests; updated docs/SERVICE_RESTARTS.md to describe the credential-retention behavior.

Design

Redaction is owned by each task spec because credential locations are task-specific and the base implementation should not make assumptions about arbitrary task data. The base handles only the output destination that every task shares; everything else is declared by the spec that owns it, and overrides chain super() so shared coverage is never dropped. Shared traversal and key matching live in src/shared/utils/redact.py to keep implementations consistent and avoid mutating the execution object.

The serialized record caches the redacted spec, just as the source redaction is cached. Credential retention is a dispatchability property, so it is gated at dispatch rather than at merge time; plan_merge stays purely about merge compatibility. Restart detection is intentionally marker-aware and task-specific, preventing a literal [REDACTED] value in unrelated task data from blocking recovery.

Test Plan

uv run pre-commit run --all-files
uv run pytest tests/ --ignore=tests/worker/test_mp_executor_cleanup_gpu.py -v

Test Result

  • uv run pre-commit run --all-files — passed: gitleaks, isort, black, ruff, codespell, mypy, and requirements synchronization.
  • uv run pytest tests/ --ignore=tests/worker/test_mp_executor_cleanup_gpu.py -v — 1980 passed, 23 warnings.
  • Hosted PR checks (lint/typecheck, package build, unit tests, security scans, DCO, and title validation) run on each push.

A separate unfiltered local test run had one failure in the GPU-only vLLM cleanup test because this environment has zero CUDA devices; the repository’s PR workflow excludes that test.


Pre-submission Checklist
  • I have read the contribution guidelines.
  • I have run pre-commit run --all-files and fixed any issues.
  • I have added or updated tests covering my changes (if applicable).
  • I have verified that uv run pytest tests/ passes locally (the GPU-only cleanup test requires CUDA; the PR-equivalent command above passes).
  • If I changed shared schemas or proto definitions, I have checked downstream compatibility across Server and Worker.
  • If I changed the SDK or CLI, I have verified the affected packages work (uv sync --all-packages --group ci --frozen).
  • If this is a breaking change, I have prefixed the PR title with [BREAKING] and described migration steps above.
  • I have updated documentation or config examples if user-facing behavior changed.

@kaiitunnz
kaiitunnz marked this pull request as ready for review September 18, 2026 17:46
@kaiitunnz
kaiitunnz requested a review from timzsu as a code owner September 18, 2026 17:46

@timzsu timzsu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Three comments. PTAL

Comment thread src/shared/tasks/specs/common.py Outdated
return True

if self._has_redacted_credential(rendered_task.spec):
if rendered_task.spec.has_redacted_credentials():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Seems that the children tasks are not checked. When resolving children, should we also check for redacted credentials?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed by also checking merged children.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry for reopening it. I wonder if a better fix is to check redaction before merging them into a task, so that such a pattern will be avoided structurally. While the current implementation works, it could fail a well-shaped task just because it is batched with some bad sibling tasks, which is the scheduler's responsibility.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

For safety, I decided to check redaction after resolving the child tasks (mirroring how the parent task is checked after resolution). Fixed by dropping redacted children from the parent task and failing them immediately so that the parent task and the clean children can be dispatched unaffected.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

To ensure atomicity, I think we should unlink all redacted children, mark them failed (with dependent failure cascade), and persist the record of both parent and children within one transaction. We should also mention this behavior in the doc.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Attempted to fix. Please check.

Comment thread src/server/task/models.py Outdated
redacted_api = self._redact_api()
if redacted_api is not None:
data["task"]["spec"]["api"] = redacted_api
data["task"]["spec"] = self._redact_spec().model_dump(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If the user calls record.model_dump(exclude={"task"}), then this line would raise because it assumes the existence of the "task" field. I think we should add a guard

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Guard both the "source" and "task" fields.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems that there could be nested exclude like model_dump(exclude={"task": {"spec": {"api"}}}), although I am not sure if we should consider such corner cases.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Attempted to handle robustly by parsing SerializationInfo.exclude.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There are other options as well, e.g., include, exclude_unset, and exclude_defaults. If we want to handle it robustly, should we consider them altogether?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed by revising the processing order: redacting "source" and "task" first before applying handler, so that the arguments to model_dump apply cleanly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is a mode exclude_unset which is not properly addressed, because this way sets them regardless of the option.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Add and use the copy_preserving_fields_set helper to copy a model while retaining the unset field record across the redact_credentials methods so that TaskRecord._serialize with exclude_unset is correct.

@kaiitunnz
kaiitunnz force-pushed the kaiitunnz/fix/redact-task-credentials branch from b975365 to 96c4695 Compare September 19, 2026 16:58
Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Data profiling, embedding, inference, training (SFT/LoRA/PPO/image
classification), and omni specs carry credentials in their data,
checkpoint, and related payloads via the shared data mixin and
checkpoint request headers, but were persisted and served unredacted.
Give each its own redact_credentials/has_redacted_credentials override,
chaining super() so subclasses only declare their added fields.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The record serializer replaced task.spec with a fixed mode="python"
dump, ignoring the outer call's by_alias/exclude_none/mode. That let the
served spec sub-object drift from the rest of the record (e.g. the
_upstreamResults alias). Thread the serializer's own SerializationInfo
into the redacted-spec dump.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
An HTTP output destination's headers are used to deliver results and can
carry credentials (e.g. Authorization). Redact them in the base spec so
every task type is covered, and have the standalone spec overrides chain
super() so the base redaction reaches them.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Dispatch checked only the parent spec for a redacted credential, so a
merged child whose credential was redacted at persist time would ship
[REDACTED] to the worker after a restart. Check each resolved child and
fail the task with credential_not_retained, naming the child.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The record serializer assumed source and task.spec were always present,
so model_dump(exclude={"task"}) raised and exclude={"source"} re-added
the field. Redact each only when the handler output still contains it.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
ModelSpecStrict/Template did not chain super() in redact_credentials or
has_redacted_credentials, so model-based specs (embedding, inference,
training, serve, omni) skipped the base output-destination header
redaction. Chain super() and add a test asserting every spec's redaction
reaches the base.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The record serializer replaces the redacted spec wholesale, so a nested
exclude like exclude={"task": {"spec": {"api"}}} re-added the excluded
field. Descend the caller's exclude selector to the spec and forward it
to the redacted-spec dump.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The record serializer dumped the spec separately and spliced it back,
which meant hand-threading each serialization option. Instead redact a
copy of the record and let the default handler walk it, so by_alias,
exclude/include, exclude_unset, exclude_defaults, and exclude_none all
apply to the redacted output natively. Redaction stays scoped to
TaskRecord, so the live task and worker dispatch still see real
credentials. Drops the manual exclude-descend and IncEx cast.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Dispatch failed the whole task when any merged child had a credential
redacted at persist time, sinking well-shaped siblings for how the
scheduler batched them. Fail just the offending child during merge
resolution and dispatch the parent with its remaining valid children,
via a new drop_merged_child that unlinks one child without requeuing.
Gating at dispatch rather than merge time also catches a credential that
only surfaces after stage-reference resolution.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
…ow atomic

Merged batches can span workflows (merge keys are spec-based), but plan_merge
committed all siblings under the parent's workflow, drifting other workflows'
status sets; and failing a redacted merged child unlinked the parent and failed
the child in separate transactions. Persist each workflow's records per
workflow, and when failing part of a batch commit the failed children (with
their dependent cascade) before the parent's unlink — children-first — so a
crash can only leave a child failed while the parent still lists it, reconciled
on the parent's next dispatch.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The per-class redact_credentials overrides rebuilt the spec with
model_copy(update={...}) on optional fields, and pydantic unions the updated
keys into __pydantic_fields_set__ — so fields the caller never set (data,
inference, checkpoint, nested model config) surfaced under exclude_unset. Add a
redacted_copy helper that restores the original fields-set after the copy
(redaction only rewrites values of existing fields) and use it at every
redaction site.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The helper performs no redaction — it does model_copy(update=...) while keeping
the original fields-set — so name it for that behavior. Also document why
object.__setattr__ is needed to write __pydantic_fields_set__.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The helper is a generic pydantic utility with no redaction logic, so move it
from tasks/specs/common to shared/utils/pydantic_utils. Also use it for the
copies in TaskRecord._serialize, so an optional field later added to that update
can't leak under exclude_unset.

Signed-off-by: Noppanat Wadlom <noppanat.wad@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.

2 participants