Skip to content

Add Tenuo authorization contrib module#1447

Open
aimable100 wants to merge 2 commits intotemporalio:mainfrom
tenuo-ai:main
Open

Add Tenuo authorization contrib module#1447
aimable100 wants to merge 2 commits intotemporalio:mainfrom
tenuo-ai:main

Conversation

@aimable100
Copy link
Copy Markdown

Summary

Adds temporalio.contrib.tenuo, a SimplePlugin that wires Tenuo warrant-based authorization into Temporal workflows. Agents (workflows) carry signed warrants specifying which tools (activities) they can call and with what argument constraints. Sub-agents (child workflows) receive attenuated warrants — capabilities can only shrink, never expand.

  • TenuoPlugin — registers client interceptor (warrant header injection), worker interceptors (PoP signing + authorization verification), and sandbox passthrough for the tenuo native extension.
  • Thin adapter — only 3 symbols exported from the contrib module (TenuoPlugin, TENUO_PLUGIN_NAME, ensure_tenuo_workflow_runner). All other types are imported from tenuo.temporal, matching the pattern established by openai_agents and other contrib modules.
  • No private imports — all tenuo.temporal internals used by the plugin are exposed through public lazy-loaded names.

Files

File Lines Purpose
temporalio/contrib/tenuo/__init__.py 27 Public API (3 exports)
temporalio/contrib/tenuo/_plugin.py 180 TenuoPlugin SimplePlugin subclass
temporalio/contrib/tenuo/README.md 371 Documentation with multi-agent delegation example
tests/contrib/tenuo/test_tenuo.py 464 Unit tests + live integration tests
tests/contrib/tenuo/test_tenuo_replay.py 243 Record-and-replay determinism tests
pyproject.toml +2 tenuo optional dependency

Replay safety

Replay determinism is verified at two levels:

  1. Static analysis — source inspection confirms workflow.now() (not time.time()), no datetime.now(), no os.urandom/random/uuid4, no time.sleep, no threading.Thread.
  2. Live record-and-replay — workflows execute against a local Temporal server, history is captured via fetch_history(), and a fresh TenuoPlugin instance replays via Replayer. Tests cover single-tool and multi-tool (sequential PoP ordering) scenarios.

Integration tests

  • test_authorized_activity_succeeds — full warrant → PoP → authorization flow
  • test_start_workflow_authorizedstart_workflow_authorized returns a handle
  • test_unauthorized_activity_is_non_retryable — unauthorized tool call produces WorkflowFailureError with ApplicationError(non_retryable=True)
  • test_duplicate_registration_raises — same plugin instance on two workers raises RuntimeError

Test plan

  • pytest tests/contrib/tenuo/test_tenuo.py -v — unit + integration tests
  • pytest tests/contrib/tenuo/test_tenuo_replay.py -v — replay determinism tests
  • ruff check temporalio/contrib/tenuo/ tests/contrib/tenuo/ — no lint errors

Adds `temporalio.contrib.tenuo`, a SimplePlugin integration for
Tenuo warrant-based authorization in Temporal workflows.

The plugin (`TenuoPlugin`) wires client interceptors, worker
interceptors, and workflow sandbox passthrough in a single line:

    from temporalio.contrib.tenuo import TenuoPlugin
    plugin = TenuoPlugin(config)
    client = await Client.connect("localhost:7233", plugins=[plugin])

Key design decisions:
- Thin adapter: only TenuoPlugin, TENUO_PLUGIN_NAME, and
  ensure_tenuo_workflow_runner are exported from the contrib module.
  All other types (TenuoPluginConfig, EnvKeyResolver, etc.) are
  imported directly from tenuo.temporal.
- No private imports: all tenuo.temporal internals used by the plugin
  are exposed through public lazy-loaded names.
- No re-exports of external package types, matching the pattern
  established by openai_agents and other contrib modules.

Files:
- temporalio/contrib/tenuo/__init__.py — public API (3 exports)
- temporalio/contrib/tenuo/_plugin.py — TenuoPlugin SimplePlugin subclass
- temporalio/contrib/tenuo/README.md — multi-agent delegation example
- tests/contrib/tenuo/test_tenuo.py — unit + live integration tests
- tests/contrib/tenuo/test_tenuo_replay.py — record-and-replay tests
- pyproject.toml — tenuo optional dependency
Add Tenuo authorization contrib module
@aimable100 aimable100 requested a review from a team as a code owner April 14, 2026 21:52
@tconley1428
Copy link
Copy Markdown
Contributor

I don't think it is likely that we are willing to accept this. We welcome folks using Temporal as a part of their solution, but including it in the SDK's contrib comes with an implication of our maintenance and ownership of the solution. From a technical perspective, you are welcome to create a plugin external to the SDK repo, and we can have a discussion about partnership. If you reach out in our community slack, I can put you in touch with the folks running AI partnership.

@aimable100
Copy link
Copy Markdown
Author

Thanks for the note. This was submitted through Temporal's AI Partner Program — I was invited and completed the submission form. Happy to move to an external plugin if that's the preferred path for partners too. Will follow up with the team to confirm.

@jssmith
Copy link
Copy Markdown
Contributor

jssmith commented Apr 14, 2026

@aimable100 - thank you for preparing this plugin. I will leave this PR open so that our team can provide feedback. You should plan to move it to one of your repositories, though.

@0xbrainkid

This comment was marked as low quality.

Copy link
Copy Markdown
Contributor

@DABH DABH left a comment

Choose a reason for hiding this comment

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

This plugin appears to be following the recommended strategy of using SimplePlugin's interface. The plugin is also performing authorization by intercepting activities - interceptors are indeed designed to perform actions like this as seen in the OpenTelemetryPlugin and BraintrustPlugin, so no concerns there. Replay testing is being taken seriously here, which is nice to see.

One overall concern is the naming/shipping strategy here. Tenuo appears to already ship a Temporal plugin (https://tenuo.ai/temporal). The present plugin imports a bunch of stuff (including TenuoPlugin) from tenuo.temporal, so it seems like the present PR is really a thin wrapper/adapter around what's already been published. Can you explain how the present PR is different than the existing plugin, and whether both need to exist? On naming, Tenuo's README on tenuo-ai/tenuo uses the name TenuoTemporalPlugin, while the integration docs page uses TenuoPlugin, and the PR adds a third binding temporalio.contrib.tenuo.TenuoPlugin - three names in three places could be a little confusing.

I left a few inline comments and suggestions in the code below. Happy to continue the conversation and keep iterating. Thank you for your efforts!

__all__ = [
"TENUO_PLUGIN_NAME",
"TenuoPlugin",
"ensure_tenuo_workflow_runner",
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.

Does ensure_tenuo_workflow_runner need to be publicly exposed? This looks more like an internal implementation detail

*passthrough
)
)
if isinstance(existing, SandboxedWorkflowRunner):
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.

If the user has a custom non-sandboxed workflow runner (e.g., UnsandboxedWorkflowRunner for debugging), this branch silently returns existing unchanged (tenuo and tenuo_core don't get added as passthrough modules). That feels like an edge case that should either be handled, or, the plugin should detect that case and throw an error

raise RuntimeError(
"Duplicate Tenuo plugin registration: the same TenuoPlugin "
"instance was used to configure_worker more than once. "
"Create separate instances for each worker."
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.

If you create a client object and pass the plugin into that, then worker objects you create with the client will automatically get the plugin. So instead of creating different plugin instances, I think the recommendation here (and anywhere else in the docs?) should be to create one plugin object and just pass it into the client.

Comment on lines +157 to +158
config.activity_fns = list(existing)
config._activity_registry = build_activity_registry(config.activity_fns)
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.

Here the code appears to be modifying config, which is a user-owned object. Should the code be referencing self._tenuo_config instead? If not - can we avoid mutating objects that users are passing in (which presumably they don't expect to be modified?)?

As a possible failure mode: if the user constructs one TenuoPluginConfig and hands it to two TenuoPlugin instances for different workers with different activity sets, the second worker's activities silently overwrite the first's registry (well, they don't, because of the if not config.activity_fns guard; but then the second worker silently inherits the first worker's activities, which is arguably worse: the user gets authorization checks against the wrong activity function references).

@@ -0,0 +1,27 @@
"""Tenuo warrant-based authorization for Temporal workflows.
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.

Tenuo appears to have specific exception types (PopVerificationError, WarrantExpired, ChainValidationError, etc.) that should arguably be registered as workflow_failure_exception_types so they fail workflows cleanly rather than getting wrapped in generic ActivityError. (Current code doesn't register any)

KEY_ID = "replay-key"


class DictKeyResolver(KeyResolver):
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.

Should this have been used in test_tenuo.py too to avoid manipulating things like _key_cache?

def resolve_sync(self, key_id: str) -> tenuo.SigningKey:
"""Resolve a key by ID synchronously."""
if key_id not in self.keys:
raise ValueError(f"Key {key_id!r} not found")
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.

Nit, but KeyResolutionError instead of ValueError?

plugins=[replay_plugin],
).replay_workflow(history, raise_on_replay_failure=False)

assert replay_result.replay_failure is None, (
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.

Would be good to add a test of a replay with tampered history - a negative test where you corrupt the warrant header in the captured history and assert replay_failure is not None would prove the verification path is actually engaged during replay. Without it, we don't know the plugin is verifying anything during replay; we only know it's not crashing.



# ---------------------------------------------------------------------------
# Tests
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.

A couple tests to consider adding that I think might be useful:

  1. Replay of a denied workflow. The recording captures a successful authorization. Replay of a denied workflow (the unauthorized activity case from test_tenuo.py) should also terminate identically on replay. Not tested currently since the replay file doesn't have any unauthorized scenarios.
  2. Replay after issuer-key rotation. Tenuo's docs make a specific claim: "On refresh failure, the worker retains the previous Authorizer and logs a warning." The replay tests use a static trusted root. A test where the plugin config for replay uses a rotated root set (with overlap) would validate the rotation path.
  3. Replay across clock-boundary. Tenuo's PoP uses window_ts = (unix_now // 30) * 30 - 30-second buckets. A test that records a workflow whose activities span a window boundary would exercise the bucket-crossing logic, which could be helpful.

Comment on lines +165 to +169
if _preload_all is not None:
try:
_preload_all()
except Exception as exc:
_logger.warning("key preload failed: %s", exc)
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.

My understanding is that preloading is not optional for EnvKeyResolver - if it silently fails here, every subsequent workflow execution fails with KeyResolutionError. Assuming that is the case, we should do something stronger than warning - this should either (a) raise and abort worker construction, (b) record which resolver failed so the later error message can point back here, or (c) at minimum, log at error level.

aimable100 added a commit to tenuo-ai/tenuo that referenced this pull request Apr 23, 2026
## Summary

Addresses worker-side feedback from the Temporal team (DABH) on

[temporalio/sdk-python#1447](temporalio/sdk-python#1447).

**Plugin (`tenuo-python/tenuo/temporal_plugin.py`)**
- No longer mutates the user's `TenuoPluginConfig`; works on a shallow
copy so two workers sharing a config stay isolated.
- Registers Tenuo's domain exceptions (`TenuoContextError`,
`PopVerificationError`, `TemporalConstraintViolation`, `WarrantExpired`,
`ChainValidationError`, `KeyResolutionError`, `LocalActivityError`) as
`workflow_failure_exception_types` on SDKs that support it.
- Preload failures log at `ERROR` with the resolver class name;
`EnvKeyResolver` preload failure raises `ConfigurationError` (no safe
`os.environ` fallback in the sandbox).
- `ensure_tenuo_workflow_runner` emits a `UserWarning` plus a logger
warning when given `UnsandboxedWorkflowRunner` (Tenuo still works — the
user is just opting out of Temporal's own determinism guardrails, which
is a legitimate choice for debugging), and warns for unknown custom
runners.
- Duplicate-registration error now points at
`Client.connect(plugins=[plugin])` inheritance instead of advising
one-plugin-per-worker.

**Plugin-confusion rename (`tenuo.temporal.TenuoPlugin` →
`TenuoWorkerInterceptor`)**
- The old name was a Temporal SDK `WorkerInterceptor`, not a Temporal
SDK `Plugin`, and its resemblance to
`tenuo.temporal_plugin.TenuoTemporalPlugin` caused real
misconfigurations (e.g. `Worker(plugins=[TenuoPlugin(...)])` silently
accepting an unusable argument).
- New canonical name: `tenuo.temporal.TenuoWorkerInterceptor`.
- Backward compat: `tenuo.temporal.TenuoPlugin` is still importable as a
deprecated alias and emits a `DeprecationWarning` on first resolution;
scheduled for removal in a future beta. Most users register
`TenuoTemporalPlugin` via `Client.connect(plugins=[plugin])` and are
unaffected.
- Updated all internal usages, tests, examples (5 files), and docs.
Added an "About the names" callout table in `docs/temporal.md` and a
"renamed from" breadcrumb in `docs/temporal-reference.md`.
- New unit test asserts the alias warns and resolves to the new class.

**Tests**
- `DictKeyResolver` raises `KeyResolutionError` instead of `ValueError`.
- 7 new unit tests in `tests/adapters/test_temporal_plugin.py` cover
every plugin-side change above, plus the deprecation-alias test.

**Deferred to follow-ups**
- Making `ensure_tenuo_workflow_runner` private — useful public escape
hatch for advanced users; keep public.
- Replay-time negative tests (tampered history, rotated trusted roots,
clock-boundary). Initial attempts revealed that the current plugin
architecture does not re-verify activity PoP during replay — activities
don't re-execute, and the workflow inbound interceptor only stashes
headers without re-checking. Designing meaningful replay-safety tests
requires plumbing changes and should be scoped as its own task.

## Test plan
- [x] `uv run pytest tests/adapters/test_temporal_plugin.py` — 33 passed
(incl. new deprecation test).
- [x] `uv run pytest tests/adapters/test_temporal.py
tests/adapters/test_transparent_interceptor.py
tests/adapters/test_temporal_integration.py
tests/e2e/test_temporal_replay.py` — 166 passed.
- [x] `uv run pytest tests/e2e/test_temporal_e2e.py
tests/e2e/test_temporal_replay.py` — 61 passed.
- [x] `uv run pytest tests/security/test_security_contracts.py
tests/security/test_integration_invariants.py` — 117 passed, 22 skipped.
- [x] `uvx ruff check` clean on modified files.
- [x] `mypy tenuo/temporal/__init__.py tenuo/temporal/_interceptors.py
tenuo/temporal_plugin.py` — no errors.
- [x] All 5 Temporal examples byte-compile.
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.

5 participants