Skip to content

Allow custom actions to return encrypted secret values - #1128

Merged
ggreer merged 1 commit into
mainfrom
ggreer/encrypted-action-results
Sep 10, 2026
Merged

Allow custom actions to return encrypted secret values#1128
ggreer merged 1 commit into
mainfrom
ggreer/encrypted-action-results

Conversation

@ggreer

@ggreer ggreer commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add ActionHandlerWithSecrets so connectors can return PlaintextData separately from the public action Struct. The SDK encrypts those values for the request's encryption_configs before they appear on invoke or status responses.
  • Existing ActionHandler registrations are unchanged. Secret-marked return_types (is_secret) must use the new registration path; plaintext never sits in OutstandingAction or on the wire.
  • C1 and local invoke tasks now carry encryption configs. Local invoke keeps the old constructor and adds NewActionInvokerWithEncryption.

Verification plan and evidence: docs/verification/encrypted-action-results/.

Test plan

  • go test -count=1 ./pkg/actions ./pkg/connectorbuilder ./pkg/connectorrunner ./pkg/tasks/local
  • go test -count=1 -run '^TestActionInvokeTaskThreadsEncryptionConfigs$' ./pkg/tasks/c1api
  • go test -race -count=1 ./pkg/actions ./pkg/connectorbuilder
  • buf lint and buf breaking --against '.git#branch=main'
  • Confirm a connector that registers ActionHandlerWithSecrets can decrypt invoke and status encrypted_data with the matching private key
  • Confirm an existing non-secret custom action still completes without encryption configs

Made with Cursor

Comment thread pkg/actions/actions.go
if handler == nil {
return errors.New("action handler cannot be nil")
}
if hasSecretReturnTypes(schema) {

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.

🟠 Bug: Register/RegisterAction/RegisterResourceAction now hard-reject any schema with an is_secret return type, and registration errors propagate out of addConnectorBuilderProviders/addActionManager to NewConnector — so the whole connector refuses to start, not just this action. Before this PR is_secret on config.Field meant only "the UI obscures this" (pkg/field.WithIsSecretschemaFieldToV1), so an existing connector that marks a return type secret goes from working to a total startup failure. pkg/sdk/version.go is unchanged at v0.29.0, so downstream gets no version signal for the break.

Either bump the minor in pkg/sdk/version.go and add a migration note, or soften the rejection for a release (register the action via the non-secret path with a deprecation warning) so the break is opt-in rather than fatal at NewConnector.

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.

There are currently no connectors with is_secret in their return schemas, so this is acceptable. We only bump versions when cutting a release, which is a separate process from pull requests.

Comment thread pkg/actions/actions.go Outdated
Comment on lines +934 to +938
ctxzap.Extract(ctx).Error("panic in action handler",
zap.String("resource_type", resourceTypeID),
zap.String("action", actionName),
zap.Any("panic", r),
zap.Stack("stack"))
oa.SetError(ctx, fmt.Errorf("panic in action handler: %v", r))
oa.SetError(ctx, errors.New("panic in action handler"))

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.

🟡 Suggestion: panic redaction is applied in invokeRegisteredAction, which is the shared path for every handler. Non-secret action handlers previously logged zap.Any("panic", r) and surfaced panic in action handler: %v; both now drop the panic value, so ordinary connector panics lose their message and leave only a stack. Consider keeping the value when !handler.returnsSecrets so existing actions don't regress in debuggability.

// WithOnDemandInvokeAction creates an option for invoking an action.
// If resourceTypeID is provided, it invokes a resource-scoped action.
func WithOnDemandInvokeAction(c1zPath string, action string, resourceTypeID string, args *structpb.Struct) Option {
return WithOnDemandInvokeActionWithEncryption(c1zPath, action, resourceTypeID, args, nil)

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.

🟡 Suggestion: WithOnDemandInvokeActionWithEncryption and local.NewActionInvokerWithEncryption have no caller that supplies recipients — pkg/cli/commands.go:307 still uses WithOnDemandInvokeAction, which passes nil. So baton --invoke-action against a secret action always fails with at least one encryption config is required for secret action results, and connector developers have no way to exercise the new path locally. Consider wiring a recipient flag through the CLI, or noting the gap in the PR.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Allow custom actions to return encrypted secret values

Blocking Issues: 1 (carried over from the previous review, still unresolved) | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 9dc6379325eb.
Review mode: full — reviewed head c27aa044 against base 9dc63793.
View review run

Review Summary

Scanned the full PR diff for security and correctness: the additive proto/wire changes, both generated pb/ variants, the pkg/actions registration/settlement/encryption path, pkg/connectorbuilder dispatch, and the c1api/local task plumbing. Both prior suggestions are addressed in this head: invokeRegisteredAction now preserves the handler's own error with errors.Join(result.err, resultErr) (pkg/actions/actions.go:963), and prepareActionResult rejects a missing is_required secret return type (pkg/actions/actions.go:1085), each covered by a new test (TestActionHandlerWithSecretsFailsWhenRequiredSecretIsMissingOnSuccess, TestActionHandlerWithSecretsDoesNotRequireSecretWhenHandlerFails). The one remaining blocking finding — registration rejecting existing is_secret return-type schemas — is unchanged and restated below. No new security issues found.

Risk triage per docs/BUG_CATCHING.md section 2 — silence: yes, a dropped or mis-scoped secret settles as a well-formed response; durability: yes, encrypted_data is a new wire field that SDK versions which do not exist yet will read; uncontrolled dimensions: yes, encryption runs on the detached settlement goroutine and races invoke-time cancellation; consumer distance: the c1 platform and future SDK versions; consequence: rung 5, an exposed plaintext credential is irrecoverable. Verdict HIGH, in the absence/error-path review-blind class. The PR carries the instruments that verdict asks for: a table-driven output-validation permutation table (actions_test.go:1410), -race runs on pkg/actions and pkg/connectorbuilder, an age round-trip decrypt oracle at both the manager and RPC seams, a caller-mutates-recipient-after-invoke test proving settlement uses the invoke-time snapshot, a ciphertext ownership-clone test, and a cancellation-then-late-completion test. Proto changes are additive with fresh field numbers (InvokeActionRequest 6, InvokeActionResponse 6, GetActionStatusResponse 6, Task.ActionInvokeTask 5), no renumbering or reuse, no new import cycle from action.protoresource.proto, and the checked-in generated output matches the source on every field tag. go.mod/go.sum are unchanged and filippo.io/age is already a direct dependency, so the new tests need no dependency change.

Security Issues

None found. Plaintext never reaches OutstandingAction: setOutcomeWithEncryptedData clears EncryptedData on every error path, SetError clears it, the public response is dropped wholesale when output validation fails, recovered panic values are omitted from the log and error for secret handlers, and the local and c1api handlers log only ciphertext. Task-supplied encryption_configs follow the same trust model as the existing CreateAccountTask/RotateCredentialsTask fan-out and are validated by crypto.ValidateEncryptionConfigs before the handler runs.

Correctness Issues

  • pkg/actions/actions.go:457 (and :533) — carried over, still present. Register/RegisterAction/RegisterResourceAction reject any schema with an is_secret return type; the error propagates through registerLegacyAction and out of NewConnector, so an existing connector that marks a return type secret fails to start entirely. No pkg/sdk/version.go bump signals the break, and the resource-scoped rejection at :533 has no test.

Suggestions

  • pkg/actions/actions.go:1085 — the is_required cell is now closed, but a handler that returns no PlaintextData and no error for a non-required secret return type still settles COMPLETE with empty encrypted_data, indistinguishable from a successful issuance. docs/verification/encrypted-action-results/plan.md:43 lists "no plaintext" in the output coverage model and no test asserts that cell.
  • pkg/connectorrunner/runner.go:570carried over, still present. The new local/on-demand encryption plumbing has no caller supplying recipients; pkg/cli/commands.go:307 still calls WithOnDemandInvokeAction, which delegates with nil, so baton --invoke-action cannot invoke an action registered through RegisterWithSecrets.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Correctness Issues

In `pkg/actions/actions.go`:
- Around lines 453-459 and 529-535: `RegisterAction` and `RegisterResourceAction`
  return an error for any schema whose `return_types` contain a field with
  `is_secret` set. Because `Register` delegates to `RegisterAction` and
  `registerLegacyAction` in `pkg/connectorbuilder/actions.go:406` delegates to
  `registry.Register`, a connector that already marks an action return type
  secret now fails registration, and the error propagates out of `NewConnector`
  so the connector cannot start at all. Either keep accepting such schemas on
  the plaintext path (with the secret value simply not encrypted, preserving
  today's behavior) or gate the rejection behind an explicit opt-in, and bump
  `pkg/sdk/version.go` plus document the migration if the break is intended.
  Whichever path you choose, add a test for the resource-scoped rejection at
  line 533 and for the legacy `CustomActionManager` path: only the global
  `Register` rejection is covered today, by
  `TestSecretActionRegistrationValidatesSchemaAndRegistry`.

## Suggestions

In `pkg/actions/actions.go`:
- Around lines 1081-1090: `prepareActionResult` now rejects a missing
  `is_required` secret return type, but a secret return type that is not
  marked `is_required` and has no matching `PlaintextData` still settles the
  action as COMPLETE with empty `encrypted_data`. The caller cannot
  distinguish that from a successful credential issuance, and
  `docs/verification/encrypted-action-results/plan.md:43` lists "no plaintext"
  as an output-coverage cell with no test. Either add a test in
  `pkg/actions/actions_test.go` that invokes a secret handler returning a nil
  plaintext slice and a nil error against a schema whose secret return type is
  optional, asserting COMPLETE with empty `encrypted_data` and no plaintext in
  the public response, or reject that outcome the same way required secrets
  are rejected.

In `pkg/connectorrunner/runner.go`:
- Around line 570: `WithOnDemandInvokeActionWithEncryption` and
  `local.NewActionInvokerWithEncryption` accept encryption recipients, but no
  caller supplies them — `pkg/cli/commands.go:307` still calls
  `WithOnDemandInvokeAction`, which delegates with a nil recipient list. Since
  `invokeRegisteredAction` rejects a secret action when
  `len(encryptionConfigs) == 0`, `baton --invoke-action` can never invoke an
  action registered through `RegisterWithSecrets`. Add a CLI flag that accepts
  a recipient (for example an age recipient string or a JWK public key path),
  build the `EncryptionConfig` list from it, and pass it through
  `WithOnDemandInvokeActionWithEncryption`. `localActionInvoker.Process`
  already logs the returned ciphertext, so also decide whether the CLI should
  write the `encrypted_data` somewhere the operator can decrypt it.

@github-actions github-actions 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.

Blocking issues found — see review comments.

@ggreer
ggreer force-pushed the ggreer/encrypted-action-results branch from 4d95157 to 63531e4 Compare September 9, 2026 15:22
Comment thread pkg/actions/actions.go Outdated
)
if resultErr != nil {
result.response = nil
result.err = resultErr

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.

🟡 Suggestion: result.err = resultErr replaces the handler's own error rather than wrapping it. prepareActionResult validates plaintext names/bytes even when encrypt is false (i.e. when the handler already failed), so a handler that returns both a real failure and a malformed PlaintextData surfaces only the SDK validation message — the actual cause is dropped along with result.response. Consider errors.Join(result.err, resultErr) or wrapping so the original failure stays visible in oa.Rv["error"].

Comment thread pkg/actions/actions.go
seen[plaintext.GetName()] = struct{}{}
}

if !encrypt {

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.

🟡 Suggestion: a handler registered via RegisterWithSecrets that returns no PlaintextData and no error settles as COMPLETE with empty encrypted_data — including when the secret return_types field is is_required. That is the silent-failure shape C1 cannot distinguish from a successful credential issuance. plan.md lists "no plaintext" as a coverage cell, but actions_test.go has no case for a secret handler returning zero values; either assert the accepted behavior or reject a missing required secret return type here.

@github-actions github-actions 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.

Blocking issues found — see review comments.

@kans kans 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.

I'd tackle the error handling comment before merging.

Connectors can now register ActionHandlerWithSecrets so plaintext
return values never leave the connector process; the SDK encrypts
them for the request's recipients before invoke and status responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ggreer
ggreer force-pushed the ggreer/encrypted-action-results branch from 63531e4 to c27aa04 Compare September 9, 2026 16:07
Comment thread pkg/actions/actions.go
Comment on lines +1081 to +1090
if !encrypt {
return nil, nil
}

for _, name := range handler.requiredSecretReturnNames {
if _, ok := seen[name]; !ok {
return nil, fmt.Errorf("required secret return type %q is missing", name)
}
}

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.

🟡 Suggestion: the required-secret gate closes the is_required cell, but the optional cell is still silent and untested. A handler registered via RegisterWithSecrets that returns no PlaintextData and no error for a non-required secret return type settles COMPLETE with empty encrypted_data — indistinguishable from a successful issuance by the caller. plan.md:43 lists "no plaintext" in the output coverage model and no test asserts that cell. Add a test in pkg/actions/actions_test.go covering success with an optional secret absent (asserting COMPLETE and empty encrypted_data), or reject the outcome as with required secrets.

@github-actions github-actions 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.

Blocking issues found — see review comments.

@ggreer
ggreer dismissed github-actions[bot]’s stale review September 9, 2026 23:24

False positive. We know that no connectors use is_secret in return schemas for custom actions.

@ggreer
ggreer merged commit 3e7a07f into main Sep 10, 2026
12 checks passed
@ggreer
ggreer deleted the ggreer/encrypted-action-results branch September 10, 2026 04:12
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