Repository navigation
RSPEED: harden /v1/workflows/run submissions (#51 Phase 0a) - #57
Conversation
- Reject caller-sent credentials_secret on the run provider and definition.provider (400); the stack alone picks the credential - Restrict sandbox images (request, workflow/step spawn_config, skills.image) to the spawner default unless the caller is ADMIN - Require ADMIN for advisory mode and effective spawn none/local - Deny ephemeral steps when no spawner is configured - Add byte cap (413) and step/MCP/secret_headers count caps (422) - Add is_admin() and store resolved roles on request.state, since ADMIN never appears in authorized_actions - Move prompt/instruction limits into workflow/limits.py Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
beesarmy
left a comment
There was a problem hiding this comment.
Reviewed the full diff. The PR is well-scoped, correctly closes G1/G2/G9/G10/G11 on POST /v1/workflows/run, and the implementation is defensive (pure checks, _as_dict coercion, fail-closed on missing roles). I traced each path against the vendored cloud-agents (APPROVED_INFERENCE_PROVIDERS, SkillsSpec, WorkflowStepSpec defaults, _validate_workflow_submission's shape order) and the orderings all line up with the design plan. A few observations, non-blocking:
Things I'd tighten before merge
-
reject_oversized_definitionusesjson.dumps(definition, default=str).default=stris dead code in practice — FastAPI has already coerced the request body to a JSON-cleandict[str, Any], and any value that needsdefault=strwould have been a 422 long before reaching this point. Recommend removing it (json.dumps(definition)and let aTypeErrorsurface as 500 if it ever happens). The byte cap is a safety net; it shouldn't be where we discover a non-serializable body. -
is_adminsilently returns False whenrequest.state.user_rolesis unset. That's the correct fail-closed behavior, but in production it'll be invisible if a future endpoint callsis_adminoutside an@authorize-decorated handler. Worth alogger.debug("is_admin called without resolved user_roles") so it's diagnosable, and a docstring note thatis_adminis only valid after the auth check. -
enforce_submission_hardeningdoesn't write an audit log for denials. Intentional (Phase 4 owns audit), but worth calling out in the docstring so reviewers don't expect it: "this gate raises HTTPException directly; audit logging lands in Phase 4." Alogger.infowith the denial reasons is harmless and gives an interim signal — optional.
Edge cases the design claims but the tests don't cover
-
provider={"name": "openai", "credentials_secret": null}—RunWorkflowRequest.providerisOptional[dict[str, Any]], so the caller can sendnull. Trace:"credentials_secret" in provideris True (key present, value None), so 400 fires. Good. Worth a parametrized test entry alongside the existingcredentials_secret_rejectedcases ("MY_CUSTOM_KEY",None,"") so the rejection isn't tied to the value being a non-empty string. -
Definition with both
definition.provider.credentials_secretAND malformedspec.steps. Today the 422 shape gate fires first (from_validate_workflow_submission), masking the credential issue. This is fine (both deny) but means the caller never learns the credential route was closed unless they fix the shape first. If that's the design intent, the rule is "shape errors take precedence over policy errors" and is worth one line in the docstring; if not, swap the order in the handler. My read is "shape first" is right. -
_PRIVILEGED_SPAWN_MODESallows admins to runnone/localeven with no spawner and even though the plan says these stay admin-only until lightspeed-core#269. Testtest_none_without_spawner_fine_for_admindocuments this. Worth adding one test that confirms a non-admin withspawn: "none"andspawner_configured=Falseis still 403 — i.e., the admin gate is the only bypass, not the absence of a spawner.
Handler order note
The new pipeline is reject_oversized_definition → _validate_workflow_submission → enforce_submission_hardening. That's 413 → 422 shape → 400/422 caps/403. Matches the plan. One observation: _validate_workflow_submission is still called with provider containing the stack-injected credentials_secret, while enforce_submission_hardening is called with caller_provider (pre-injection). That's correct — they test different things — but the dual dict is easy to miss. A one-line note above the call site would help: "caller_provider keeps the caller's keys for the credential-rejection check; provider carries the stack's injected reference for the shape check."
Risk register for Phase 0a
- The credential-rejection at 400 is unconditional (even admins). Design said "D2 accepts the break," but worth a CHANGELOG/release note since this is a behavior change callers can hit.
- Admins retain the right to set
sandbox_imageandadvisory: trueandspawn: none/local. There's no audit log for these (Phase 4). For deployments where the ADMIN role is broadly granted, this is a noticeable gap until Phase 4 lands. /query/directis untouched (G5 still open, plus the G7 secret-headers-on-nonequestion from the design review). Title says "Phase 0a" — confirm Phase 0b is the next PR so the half-open paths aren't left dangling.
What I'd actually block on
Nothing. The PR correctly implements Phase 0a as planned and the test surface is solid (per-rule, admin vs non-admin, boundary on caps, approval-step carve-out, non_admin_ephemeral_workflow_accepted happy path). The mutate parametrize over image sources (workflow spawn_config / step spawn_config / skills image) is a particularly nice touch — covers all three G10 vectors in one test. Approving.
Verification I did:
- All
spawn_modesand_effective_spawn_modestraces againstWorkflowStepSpec.type = "agent"default and_DEFAULT_SPAWN = "ephemeral"(matches cloud-agentsexecution.py:1035). SkillsSpechas onlyimage(andpaths) — no per-skill images — so theskills.imagecheck covers the surface.WorkflowSpechas noproviderfield; top-levelWorkflowDefinition.provideris the only path the PR'sdefinition.get("provider")reaches.validate_definitionin cloud-agents runs the definition-levelprovider.credentials_secretcheck viavalidate_credential_reference(shape only) before this PR'senforce_submission_hardeningruns — so a credential-shaped value is 422 and a well-formed reference is 400 (no overlap on the same input).is_admincorrectly callsaccess_resolver.check_access(Action.ADMIN, user_roles)directly (bypassing the recurse-into-ADMIN branch inAccessResolver.check_access), so it doesn't false-positive when only non-ADMIN actions are granted.- The e2e tests in
test_step_executor_e2e.pypasscredentials_secrettocloud_agents.workflow.core.step_runner.run_stepdirectly (not through the HTTP handler), so they bypass the new gate and are unaffected. get_authorization_resolversis@lru_cache(maxsize=1)and is already called by_perform_authorization_checkvia@authorize, sois_admin's second call is free.
- drop dead default=str in size check - log is_admin without roles and gate denials; document ordering and scope - note caller_provider vs provider at the call site - tests: credentials_secret None/empty, non-admin none spawn without spawner Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Thanks. Pushed the review nits: removed dead |
- jwk-token auth with role_rules (k8s auth has no roles, no-op resolvers) - admin access rule lists only the admin action (config load rejects more) - verify.py builds the real role and access resolvers - README: status, auth prerequisite, Bearer prefix note Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- cases.yaml: accepted and denied requests, /query/direct and config-load cases, run per stage (pre/post cloud-agents#269) via reference_gate.py - post-269 overrides, hardened Deployment/RBAC/NetworkPolicy, External Secrets source, admin inline-MCP workflow, digest-pinned images, verify-full - README: goal coverage matrix, /query/direct, audit events, production checklist, and plan gaps found while building the examples Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mits - remove direct_cases, direct_query_eligible and its load check from the examples and reference gate; point to the deferred-chat issue (#59) - keep MAX_PROMPT_LENGTH/MAX_INSTRUCTIONS_LENGTH in query_executor.py (chat only; removed with the endpoint in the separate removal PR) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Summary
Phase 0a of the #51 design plan. Closes G1, G2, G9, G10, G11 on
POST /v1/workflows/run.credentials_secret(run provider ordefinition.provider) is now 400; the stack picks the credential.spawn_config,skills.image) must be the spawner default unless the caller is ADMIN (403).advisory: trueand effective spawnnone/localneed ADMIN (403). Approval steps are ignored.spawner_configurationare 403.secret_headerscount caps (422).is_admin()inauthorization/middleware.py:ADMINis never inauthorized_actions(get_actionsexpands it), so it asks the resolver using roles now stored onrequest.state.user_roles. Plan scheduled this for Phase 2; pulled forward because the ADMIN checks need it.workflow/limits.pyholds the definition size and count caps.Breaking (accepted in D2): callers sending
credentials_secretor a custom image now get 400/403.Target state (feature-level TDD)
examples/workflows/is the end state for #51 + cloud-agents#269: operator config, K8s secrets, two caller workflows, andverify.py(uv run python examples/workflows/verify.py) which pins the contract. Later phases make more of it real. Chat on cloud-agents (/query/direct) is out of scope (removal PR separate, tracked in #59).Admin caveat:
is_admin()uses the access resolver. Withk8s,noop,noop-with-tokenorapi-keyauth the resolvers are no-ops, so every caller counts as admin and the admin-only 403s only apply underjwk-tokenorrh-identitywith access rules. The example usesjwk-token.Breaking
On
POST /v1/workflows/run: a caller-sentcredentials_secret(run provider ordefinition.provider) is now 400 for everyone; a custom sandbox image,advisory: trueorspawn: none/localis now 403 without ADMIN; ephemeral steps without a spawner are 403; definitions over 256 KiB are 413. Underk8s/noop/api-keyauth every caller counts as admin, so the 403s apply only withjwk-tokenorrh-identityplus access rules.Test plan
test_submission_guard.py(per-rule, admin vs non-admin, caps) and endpoint tests incl. non-admin happy pathis_admintests against a realGenericAccessResolveruv run make test-unit: 3416 passed; format, ruff, schema regenerated🤖 Generated with Claude Code