Metadata-Version: 2.4
Name: watchlight-strands
Version: 0.1.1
Summary: Watchlight governance for Strands Agents, installed once at the root: authorize every tool call (Python, MCP and agents used as tools) before it runs, open every invocation as a governed run, and attach automatically to every sub-agent, Swarm and Graph node beneath the root as an attenuated governed spawn, fail-closed. Model turns are not authorized one by one. Independent plugin, not affiliated with or endorsed by Amazon Web Services.
Author-email: Watchlight AI <team@watchlight.ai>
License: Apache-2.0
Project-URL: Homepage, https://watchlight.ai
Project-URL: Documentation, https://docs.watchlight.ai/de
Project-URL: Strands Agents, https://github.com/strands-agents/sdk-python
Keywords: watchlight,strands-agents,ai-agents,ai-governance,agent-runtime
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: watchlight-agent-sdk<0.11,>=0.10.1
Requires-Dist: strands-agents<2,>=1.55
Requires-Dist: mcp<2,>=1.30
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"

# watchlight-strands

Watchlight authorization for Strands Agents: every tool call, every sub-agent, every Swarm and Graph node, fail-closed. Install it once, on the root.

```bash
pip install watchlight-strands
```

> **Independent third-party plugin.** This is an independent integration built by Watchlight AI. It is **not affiliated with, endorsed by, or sponsored by** Amazon Web Services. `Strands Agents` and related names are used here only to describe compatibility.

## What it does

`watchlight-strands` puts a Watchlight authorization decision in front of every tool call a Strands agent makes, so each call is allowed or denied *before* it runs. You add one plugin, to the root agent or the root Swarm or Graph. The plugin then attaches itself to every agent and orchestrator the root reaches and governs them too: agents used as tools, agents a tool function calls, and Swarm and Graph nodes, including nested ones. Each child runs as a governed sub-agent under its parent's run, with a narrower scope.

The plugin uses only Strands' public hook API. It registers its callbacks on each agent's public hook registry (`agent.hooks.add_callback`). It does not patch, wrap or replace any Strands function, and your tools, prompts and agent code stay as they are. The policy decisions run on Watchlight's engine, in-process for local development or on the governed control plane in production.

## Setup: one line, at the root

```python
from strands import Agent
from watchlight_strands import WatchlightStrandsPlugin

researcher = Agent(name="researcher", tools=[web_search])
writer = Agent(
    name="writer",
    tools=[researcher.as_tool()],
    plugins=[WatchlightStrandsPlugin(agent_slug="agent-writer")],   # the only line you add
)
writer("Write a short brief on agent governance.")
```

The same plugin attaches as a hook provider (`Agent(hooks=[...])`) and to orchestrators (`Swarm(nodes, plugins=[...])`, `GraphBuilder().set_plugins([...])`). Install it on the root only.

## Identity

Who an agent runs as comes from Watchlight and from configuration, never from your code.

- **The root** runs as the registered agent named by `agent_slug=` or the `WATCHLIGHT_AGENT_SLUG` environment variable, or by an `agent_map` entry for its name. When watchlight-core gains credential-bound roots, the root will run as the agent bound to the process's verified credential (for example the AWS role mapping on Amazon Bedrock AgentCore), and `agent_slug` becomes an optional override.
- **A child** never needs a registered agent of its own. It runs as a governed sub-agent of its parent, with a scope Watchlight narrows to a strict subset of the parent's: by default it asks for its own tool names. Until watchlight-core supports labelled sub-agents, an unmapped child runs under its parent's registered agent.
- **`agent_map`** (or a JSON file named by `WATCHLIGHT_AGENT_MAP`) maps an agent's `name` (an orchestrator's `id`) to a registered agent. A mapped child runs as that agent, with its own policies, and is still spawned and narrowed under its parent. `subagent_scopes` maps a child's name to the scope it asks for.
- **An agent that cannot be identified is refused.** A root with no slug and no map entry does not run. A child invoked on its own, outside any governed run, does not run unless `agent_map` names it.

## What it enforces

- **Run start.** Each invocation (`agent(...)`, `invoke_async`, `stream_async`) opens a governed run before the first model turn: a root run, or a sub-agent run when a governed run is active in the calling context. If the run cannot be opened (no identity, an unregistered agent, a refused spawn, an unreachable backend, a missing pre-flight manifest, egress that cannot be governed), the invocation raises and the agent does not run: no model turn and no tool call.
- **Tool calls.** Every tool call is authorized on the invocation's own run before it runs. That covers Python function tools (sync, async and streaming), MCP tools served through Strands' `MCPClient`, and agents used as tools. A denial, an unreachable backend, a timeout (`authorize_timeout_seconds`, 30 seconds by default) or an unexpected answer cancels the call through Strands' `cancel_tool`, and the tool body never runs. Concurrent tool calls are each authorized separately. Only argument names, count and value types are sent, never argument values.
- **Reaching children.** When the root is built, the plugin attaches to every agent and orchestrator it can already see: agents used as tools, agents and orchestrators a tool function refers to (by closure or module name), and Swarm and Graph nodes, recursively. Before a tool call or a node runs, it attaches again to what that call will run, so agents and nodes added later are covered. A child it cannot attach to is refused before it runs.
- **Run-ending verdicts.** A quarantine, terminate, sever or revoke verdict raises, which ends the invocation.
- **Human approval.** An approval-required decision becomes a Strands interrupt named `watchlight-approval`, and the tool has not run. Resume with an interrupt response of `"approved"` (or `True`, or `{"approved": True}`) after an operator has approved the request in Watchlight; the plugin retries the authorization on the same run, and the tool runs only if Watchlight now allows it. Watchlight binds an approval to the intent it was requested under, so a run that uses approvals declares a typed intent: `WatchlightStrandsPlugin(intent_objective="pay the invoice", intent_category="financial_operation")` (a category from `watchlight_core.INTENT_CATEGORIES`, or `custom:<name>`; add `intent_justification` for a category such as `financial_operation` that Watchlight refuses without one). Every authorization of the run carries it, and the retry carries the approval inside it. Without a declared intent, an approved resume is refused with a message that says so, because Watchlight could not redeem the approval. The approval request id never leaves your process.
- **Run end.** The run closes when the invocation ends: completed, failed or cancelled. Sub-agent runs still open under it end with it. An invocation that stops for an interrupt keeps its run for the resume.
- **Lineage.** Each run emits its `execution_started` and terminal events, sub-agent runs link to their parent's, and each tool call emits a `tool_call_observed` event with the decision and the value-free argument shape.
- **Egress.** With a wl-proxy URL configured (`connect_proxy_url` or `WATCHLIGHT_PROXY_URL`), each root run installs the `wl-sidecar` with that run's proxy token, so model calls (Amazon Bedrock included) and tool HTTP calls go through wl-proxy. If no proxy token was issued or the sidecar fails to start, the run is refused.

### What is refused

These never run:

- A child the plugin cannot attach to: an agent type it does not govern (a remote `A2AAgent` node, a `BidiAgent`, any object that is not a `strands.Agent` or a Swarm/Graph orchestrator), or a registry that will not take the callbacks. The tool call or node is cancelled.
- An agent with `background_tasks=...`, root or child: a background tool call runs after its governed run has ended.
- A tool function that refers to an agent or orchestrator the plugin cannot govern: the call is cancelled, because the plugin cannot tell whether the body would run it.
- An agent that cannot be identified (see Identity).
- Direct tool calls (`agent.tool.my_tool(...)`) made outside an invocation, an unknown tool, and a tool selection another hook rewrote inconsistently.
- A second Watchlight plugin on the same agent, and one plugin installed on two roots.

### What it does not enforce

- **Agents a tool function creates itself.** An agent constructed inside a tool body exists only there, and Strands has no public hook through which the plugin could reach it before it runs. It is not governed in-process. With wl-proxy configured, its network calls still go through wl-proxy under the root run. Build sub-agents outside the tool function (or pass them as tools) so the plugin can reach them.
- **Lost context.** An agent invoked from a thread your own code starts (Python threads do not inherit context) has no parent run, so it is refused unless `agent_map` names it.
- **Model turns are not authorized one by one.** A model turn is refused when it belongs to no governed run or its run is quarantined or severed; egress governance comes from wl-proxy.
- **Hooks after the gate.** The gate runs after every other hook. A hook you register after the plugin at an infinite order runs after it; if it changes the tool, the plugin withholds the result and logs an error.
- **Intent declaration** is not wired yet, and a quarantine does not interrupt an in-flight model call; it stops the run at the next model turn or tool call.

## Developer Edition

The zero-infrastructure **Developer Edition** runs the compiled engine in-process (requires the `watchlight-engine` package):

```bash
pip install watchlight-strands "watchlight-engine>=0.2.4,<0.3"
```

```python
from strands import Agent, tool
from watchlight_core import InProcessClient
from watchlight_strands import WatchlightStrandsPlugin

# Cedar policies: these two tools may run, nothing else.
POLICIES = [
    {"name": "search",
     "code": 'permit(principal, action == Action::"execute", resource == Resource::"web_search");'},
    {"name": "delegate-to-researcher",
     "code": 'permit(principal, action == Action::"execute", resource == Resource::"researcher");'},
]

@tool
def web_search(query: str) -> str:
    """Search the web."""
    ...

researcher = Agent(name="researcher", tools=[web_search])
writer = Agent(
    name="writer",
    tools=[researcher.as_tool()],
    plugins=[WatchlightStrandsPlugin(agent_slug="agent-writer", governance=InProcessClient(POLICIES))],
)
writer("Write a short brief on agent governance.")
```

Each tool call is authorized as action `execute` on `Resource::"<tool name>"`. An agent used as a tool is authorized under its tool name (here `researcher`) and then runs as a governed sub-agent under the writer's run, limited to its own tools.

## Configuration

| Argument | Meaning |
|---|---|
| `agent_slug` | The root's registered agent (env `WATCHLIGHT_AGENT_SLUG`). Optional when `agent_map` names the root. |
| `agent_map` | Agent name to registered agent, a mapping or a JSON file path (env `WATCHLIGHT_AGENT_MAP`). |
| `subagent_scopes` | Agent name to the scope it asks for as a sub-agent: `allowed_tools`, `allowed_resources`, `allowed_intents`, `max_depth`, `time_budget_seconds`. A named `time_budget_seconds` is granted exactly or the spawn is refused. |
| `authorize_timeout_seconds` | Upper bound on each tool authorization (default 30). |
| `connect_proxy_url` | wl-proxy URL for governed egress (env `WATCHLIGHT_PROXY_URL`). |
| `application_id` | The AI application the runs belong to (env `WATCHLIGHT_APPLICATION_ID`). An Enterprise feature: the in-process engine refuses it. |
| `governance` | A governance backend to use as is, such as `InProcessClient`. |

In a Swarm, agents hand over with Strands' built-in `handoff_to_agent` tool; like any tool, your policy must allow it.

## Two backends, same code

|  | Backend | Runs |
|---|---|---|
| **Developer Edition** | `InProcessClient` | The compiled engine, **in-process**: no server, no network |
| **Enterprise** | `ApdpClient` | The governed control plane: signed lineage, drift detection, fleet-wide governance |

Moving from local to production means dropping `governance=` and pointing the plugin at the control plane:

```python
WatchlightStrandsPlugin("https://apdp.your-company.example", agent_slug="agent-writer")
# The credential is loaded from the environment or your credential provider
# (for example WATCHLIGHT_CREDENTIAL_FILE); never put a key in source.
```

## Compatibility

`strands-agents` 1.55 or later, below 2. The test suite asserts the Strands hook surface the plugin relies on, so a Strands release that moves it fails the tests instead of silently weakening governance.

## Links

- Documentation: https://docs.watchlight.ai/de
- Website: https://watchlight.ai

## License

Apache-2.0
