watchlight-claude-agent 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- watchlight_claude_agent-0.2.0/PKG-INFO +105 -0
- watchlight_claude_agent-0.2.0/README.md +77 -0
- watchlight_claude_agent-0.2.0/pyproject.toml +69 -0
- watchlight_claude_agent-0.2.0/setup.cfg +4 -0
- watchlight_claude_agent-0.2.0/tests/test_declare_intent_injection.py +378 -0
- watchlight_claude_agent-0.2.0/tests/test_governance_backend_injection.py +17 -0
- watchlight_claude_agent-0.2.0/tests/test_intent_extraction.py +286 -0
- watchlight_claude_agent-0.2.0/tests/test_observation_source.py +65 -0
- watchlight_claude_agent-0.2.0/tests/test_plugin.py +196 -0
- watchlight_claude_agent-0.2.0/tests/test_quarantine_kill_signal.py +97 -0
- watchlight_claude_agent-0.2.0/tests/test_subagents.py +558 -0
- watchlight_claude_agent-0.2.0/tests/test_translator.py +206 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent/__init__.py +103 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent/intent.py +109 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent/plugin.py +575 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent/py.typed +0 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent/subagents.py +172 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent/synthetic_tools.py +284 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent/traces.py +356 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/PKG-INFO +105 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/SOURCES.txt +22 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/dependency_links.txt +1 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/requires.txt +9 -0
- watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchlight-claude-agent
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Governed Claude Agent SDK agents with Watchlight — authorize every agent action, fail-closed, with zero infrastructure. Independent plugin, not affiliated with Anthropic.
|
|
5
|
+
Author-email: Watchlight AI <team@watchlight.ai>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://watchlight.ai
|
|
8
|
+
Project-URL: Documentation, https://docs.watchlight.ai/de
|
|
9
|
+
Keywords: watchlight,claude-agent-sdk,claude-code,ai-agent,agent-governance,authorization,lineage
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: watchlight-agent-sdk>=0.5.1
|
|
22
|
+
Provides-Extra: claude
|
|
23
|
+
Requires-Dist: claude-agent-sdk>=0.1.0; extra == "claude"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# watchlight-claude-agent
|
|
30
|
+
|
|
31
|
+
Governed Claude Agent SDK agents with Watchlight — authorize every agent action, fail-closed, with zero infrastructure.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install watchlight-claude-agent
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> **Independent third-party plugin.** This is an independent integration built by Watchlight AI. It is **not affiliated with, endorsed by, or sponsored by** Anthropic. `Claude Agent SDK` and related names are trademarks of Anthropic, used here nominatively only to describe compatibility.
|
|
38
|
+
|
|
39
|
+
## What it does
|
|
40
|
+
|
|
41
|
+
`watchlight-claude-agent` puts a Watchlight authorization decision in front of every action your Claude Agent SDK agent takes — tool calls, plan steps, and native sub-agent spawns — so each one is allowed, modified, or denied *before* it runs. It's open-source glue: a thin, framework-specific layer that threads Watchlight's governance primitives into your agent's hook loop. The actual policy decisions run on Watchlight's compiled engine, either in-process for local development or against the governed control plane in production.
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
Point the plugin at a backend and wrap your run. Your agent code doesn't change — only the backend does.
|
|
46
|
+
|
|
47
|
+
For local development, the zero-infrastructure **Developer Edition** runs the compiled engine in-process (requires the `watchlight-engine` package):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install watchlight-claude-agent watchlight-engine
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from watchlight_claude_agent import WatchlightClaudeAgentSDKPlugin
|
|
55
|
+
from watchlight_core import InProcessClient
|
|
56
|
+
|
|
57
|
+
# A Cedar policy: the research agent may read, nothing else.
|
|
58
|
+
POLICIES = [
|
|
59
|
+
{"name": "reader",
|
|
60
|
+
"code": 'permit(principal == User::"research-agent", action == Action::"read", resource);'},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
plugin = WatchlightClaudeAgentSDKPlugin()
|
|
64
|
+
plugin.apdp = InProcessClient(POLICIES) # decisions run in-process, no server, no network
|
|
65
|
+
|
|
66
|
+
async def run_agent(question: str):
|
|
67
|
+
async with await plugin.start_run("research-agent") as handle:
|
|
68
|
+
# Validate a multi-step plan ahead of execution (Allow / Modify / Deny).
|
|
69
|
+
result = await handle.submit_plan(["read dataset", "summarize"])
|
|
70
|
+
if result.is_deny():
|
|
71
|
+
raise PermissionError(f"Plan denied: {result.violations}")
|
|
72
|
+
|
|
73
|
+
# Gate each action authoritatively. Returns True on Allow, False on Deny.
|
|
74
|
+
if not await handle.authorize_action("read", "dataset"):
|
|
75
|
+
raise PermissionError("Denied by policy")
|
|
76
|
+
|
|
77
|
+
# ... your Claude agent runs, every action governed ...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`authorize_action` fails closed: a denial — or an unreachable backend — returns `False`, so the action never runs.
|
|
81
|
+
|
|
82
|
+
## Two backends, same code
|
|
83
|
+
|
|
84
|
+
| | Backend | Runs |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| **Developer Edition** | `InProcessClient` | The compiled engine, **in-process** — no server, no network |
|
|
87
|
+
| **Enterprise** | `ApdpClient` | The governed control plane — signed lineage, drift detection, fleet-wide governance |
|
|
88
|
+
|
|
89
|
+
Moving from local to production is a one-line change:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from watchlight_core import ApdpClient
|
|
93
|
+
|
|
94
|
+
plugin = WatchlightClaudeAgentSDKPlugin()
|
|
95
|
+
plugin.apdp = ApdpClient("https://apdp.your-company.example", api_key="...")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Links
|
|
99
|
+
|
|
100
|
+
- Documentation: https://docs.watchlight.ai/de
|
|
101
|
+
- Website: https://watchlight.ai
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
Apache-2.0
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# watchlight-claude-agent
|
|
2
|
+
|
|
3
|
+
Governed Claude Agent SDK agents with Watchlight — authorize every agent action, fail-closed, with zero infrastructure.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install watchlight-claude-agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> **Independent third-party plugin.** This is an independent integration built by Watchlight AI. It is **not affiliated with, endorsed by, or sponsored by** Anthropic. `Claude Agent SDK` and related names are trademarks of Anthropic, used here nominatively only to describe compatibility.
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
`watchlight-claude-agent` puts a Watchlight authorization decision in front of every action your Claude Agent SDK agent takes — tool calls, plan steps, and native sub-agent spawns — so each one is allowed, modified, or denied *before* it runs. It's open-source glue: a thin, framework-specific layer that threads Watchlight's governance primitives into your agent's hook loop. The actual policy decisions run on Watchlight's compiled engine, either in-process for local development or against the governed control plane in production.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
Point the plugin at a backend and wrap your run. Your agent code doesn't change — only the backend does.
|
|
18
|
+
|
|
19
|
+
For local development, the zero-infrastructure **Developer Edition** runs the compiled engine in-process (requires the `watchlight-engine` package):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install watchlight-claude-agent watchlight-engine
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from watchlight_claude_agent import WatchlightClaudeAgentSDKPlugin
|
|
27
|
+
from watchlight_core import InProcessClient
|
|
28
|
+
|
|
29
|
+
# A Cedar policy: the research agent may read, nothing else.
|
|
30
|
+
POLICIES = [
|
|
31
|
+
{"name": "reader",
|
|
32
|
+
"code": 'permit(principal == User::"research-agent", action == Action::"read", resource);'},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
plugin = WatchlightClaudeAgentSDKPlugin()
|
|
36
|
+
plugin.apdp = InProcessClient(POLICIES) # decisions run in-process, no server, no network
|
|
37
|
+
|
|
38
|
+
async def run_agent(question: str):
|
|
39
|
+
async with await plugin.start_run("research-agent") as handle:
|
|
40
|
+
# Validate a multi-step plan ahead of execution (Allow / Modify / Deny).
|
|
41
|
+
result = await handle.submit_plan(["read dataset", "summarize"])
|
|
42
|
+
if result.is_deny():
|
|
43
|
+
raise PermissionError(f"Plan denied: {result.violations}")
|
|
44
|
+
|
|
45
|
+
# Gate each action authoritatively. Returns True on Allow, False on Deny.
|
|
46
|
+
if not await handle.authorize_action("read", "dataset"):
|
|
47
|
+
raise PermissionError("Denied by policy")
|
|
48
|
+
|
|
49
|
+
# ... your Claude agent runs, every action governed ...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`authorize_action` fails closed: a denial — or an unreachable backend — returns `False`, so the action never runs.
|
|
53
|
+
|
|
54
|
+
## Two backends, same code
|
|
55
|
+
|
|
56
|
+
| | Backend | Runs |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| **Developer Edition** | `InProcessClient` | The compiled engine, **in-process** — no server, no network |
|
|
59
|
+
| **Enterprise** | `ApdpClient` | The governed control plane — signed lineage, drift detection, fleet-wide governance |
|
|
60
|
+
|
|
61
|
+
Moving from local to production is a one-line change:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from watchlight_core import ApdpClient
|
|
65
|
+
|
|
66
|
+
plugin = WatchlightClaudeAgentSDKPlugin()
|
|
67
|
+
plugin.apdp = ApdpClient("https://apdp.your-company.example", api_key="...")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Links
|
|
71
|
+
|
|
72
|
+
- Documentation: https://docs.watchlight.ai/de
|
|
73
|
+
- Website: https://watchlight.ai
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
Apache-2.0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution name is ``watchlight-claude-agent`` — deliberately NOT
|
|
7
|
+
# ``watchlight-claude-agent-sdk``, which would shadow the upstream PyPI
|
|
8
|
+
# ``claude-agent-sdk`` distribution this plugin wraps. The importable
|
|
9
|
+
# package is ``watchlight_claude_agent``. This matches the naming of
|
|
10
|
+
# the other framework plugins (watchlight-adk / -bedrock / -langgraph /
|
|
11
|
+
# -msaf): one canonical SDK (``watchlight-agent-sdk``) + per-framework
|
|
12
|
+
# drop-in plugins that depend on it.
|
|
13
|
+
name = "watchlight-claude-agent"
|
|
14
|
+
version = "0.2.0"
|
|
15
|
+
description = "Governed Claude Agent SDK agents with Watchlight — authorize every agent action, fail-closed, with zero infrastructure. Independent plugin, not affiliated with Anthropic."
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
license = {text = "Apache-2.0"}
|
|
18
|
+
requires-python = ">=3.10"
|
|
19
|
+
authors = [
|
|
20
|
+
{name = "Watchlight AI", email = "team@watchlight.ai"},
|
|
21
|
+
]
|
|
22
|
+
keywords = [
|
|
23
|
+
"watchlight",
|
|
24
|
+
"claude-agent-sdk",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"ai-agent",
|
|
27
|
+
"agent-governance",
|
|
28
|
+
"authorization",
|
|
29
|
+
"lineage",
|
|
30
|
+
]
|
|
31
|
+
classifiers = [
|
|
32
|
+
"Development Status :: 3 - Alpha",
|
|
33
|
+
"Intended Audience :: Developers",
|
|
34
|
+
"License :: OSI Approved :: Apache Software License",
|
|
35
|
+
"Programming Language :: Python :: 3",
|
|
36
|
+
"Programming Language :: Python :: 3.10",
|
|
37
|
+
"Programming Language :: Python :: 3.11",
|
|
38
|
+
"Programming Language :: Python :: 3.12",
|
|
39
|
+
"Topic :: Security",
|
|
40
|
+
"Topic :: Software Development :: Libraries",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Hard dependency on the canonical Watchlight SDK. The upstream
|
|
44
|
+
# framework (`claude-agent-sdk`) is an OPTIONAL extra so the plugin can
|
|
45
|
+
# be installed for tests without the upstream SDK present (pattern
|
|
46
|
+
# matches the watchlight-openclaw plugin's optional `openclaw` peer
|
|
47
|
+
# dep).
|
|
48
|
+
dependencies = [
|
|
49
|
+
"watchlight-agent-sdk>=0.5.1",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[project.optional-dependencies]
|
|
53
|
+
claude = ["claude-agent-sdk>=0.1.0"]
|
|
54
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.21", "mypy>=1.8"]
|
|
55
|
+
|
|
56
|
+
[project.urls]
|
|
57
|
+
Homepage = "https://watchlight.ai"
|
|
58
|
+
Documentation = "https://docs.watchlight.ai/de"
|
|
59
|
+
|
|
60
|
+
[tool.setuptools.packages.find]
|
|
61
|
+
include = ["watchlight_claude_agent*"]
|
|
62
|
+
|
|
63
|
+
[tool.setuptools.package-data]
|
|
64
|
+
watchlight_claude_agent = ["py.typed"]
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
asyncio_mode = "strict"
|
|
68
|
+
addopts = "-ra -q"
|
|
69
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Synthetic tool injection tests for the Claude Agent SDK (Python lane).
|
|
2
|
+
|
|
3
|
+
Five load-bearing tests:
|
|
4
|
+
|
|
5
|
+
1. ``greenfield_mode_injects_synthetic_tool_at_construction`` —
|
|
6
|
+
``plugin.synthetic_tools()`` returns the canonical
|
|
7
|
+
``watchlight.declare_intent`` definition when greenfield mode is
|
|
8
|
+
resolved at plugin construction.
|
|
9
|
+
2. ``brownfield_mode_does_not_inject_synthetic_tool`` — mirror; the
|
|
10
|
+
list is empty so brownfield agents never see the tool.
|
|
11
|
+
3. ``declare_intent_call_stamps_provenance_to_declared`` — when the
|
|
12
|
+
plugin's interceptor processes a synthetic tool capture, the
|
|
13
|
+
run-handle's ``_intent_provenance`` flips to ``DECLARED`` and the
|
|
14
|
+
stored objective matches the (truncated) input.
|
|
15
|
+
4. ``synthetic_tool_call_bypasses_authorize`` — the interceptor MUST
|
|
16
|
+
NOT round-trip to APDP ``/authorize`` when it handles a synthetic
|
|
17
|
+
tool capture (the intentional authorization carve-out).
|
|
18
|
+
5. ``subsequent_authorize_action_after_declare_intent_satisfies_cedar_gate``
|
|
19
|
+
— load-bearing integration: after the synthetic tool fires, the
|
|
20
|
+
next ``authorize_action()`` call auto-injects
|
|
21
|
+
``intent={"objective": "<stored>"}`` into the APDP request body —
|
|
22
|
+
the exact wire shape ``compute_execution_intent_projection`` treats
|
|
23
|
+
as ``provenance: "declared"``, which clears the greenfield-Custodial
|
|
24
|
+
Cedar gate that forbids privileged actions unless intent was declared.
|
|
25
|
+
|
|
26
|
+
Test #5 is the intent-enforcement gate-closure proof for the Claude
|
|
27
|
+
Agent SDK (Python) lane.
|
|
28
|
+
|
|
29
|
+
# Source pointers (watchlight_core primitives)
|
|
30
|
+
- :data:`watchlight_core.WATCHLIGHT_DECLARE_INTENT_TOOL` — canonical tool shape
|
|
31
|
+
- :func:`watchlight_core.handle_declare_intent_call` — mutator
|
|
32
|
+
- :func:`watchlight_core.is_declare_intent_tool_call` — predicate
|
|
33
|
+
- :func:`watchlight_core.resolve_intent_enforcement_mode` — mode resolution
|
|
34
|
+
- :class:`watchlight_core.BaseRunHandle` — ``_intent_objective`` / ``_intent_provenance``
|
|
35
|
+
fields auto-injected into ``authorize_action_detailed`` request body
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from unittest.mock import AsyncMock
|
|
41
|
+
|
|
42
|
+
import pytest
|
|
43
|
+
import pytest_asyncio
|
|
44
|
+
|
|
45
|
+
from watchlight_claude_agent import (
|
|
46
|
+
DECLARE_INTENT_TOOL_NAME,
|
|
47
|
+
RunHandle,
|
|
48
|
+
WatchlightClaudeAgentSDKPlugin,
|
|
49
|
+
is_synthetic_tool_capture,
|
|
50
|
+
)
|
|
51
|
+
from watchlight_core import IntentProvenance
|
|
52
|
+
from watchlight_core.declare_intent import INTENT_ENFORCEMENT_MODE_ENV_VAR
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─── Fixtures ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_plugin(
|
|
59
|
+
*,
|
|
60
|
+
intent_enforcement_mode: str | None = None,
|
|
61
|
+
) -> WatchlightClaudeAgentSDKPlugin:
|
|
62
|
+
plugin = WatchlightClaudeAgentSDKPlugin(
|
|
63
|
+
apdp_url="http://localhost:8081",
|
|
64
|
+
tenant_id="tenant-test",
|
|
65
|
+
intent_enforcement_mode=intent_enforcement_mode,
|
|
66
|
+
)
|
|
67
|
+
# Mock the APDP transport so the test never hits the network and we
|
|
68
|
+
# can assert call-counts + request bodies precisely.
|
|
69
|
+
plugin.apdp.resolve_agent = AsyncMock( # type: ignore[method-assign]
|
|
70
|
+
return_value={"id": "agent-uuid-test"}
|
|
71
|
+
)
|
|
72
|
+
plugin.apdp.create_session = AsyncMock( # type: ignore[method-assign]
|
|
73
|
+
return_value={"session": {"id": "ses_test"}}
|
|
74
|
+
)
|
|
75
|
+
plugin.apdp.get_manifest = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
76
|
+
plugin.apdp.authorize = AsyncMock( # type: ignore[method-assign]
|
|
77
|
+
return_value={"decision": "Allow"}
|
|
78
|
+
)
|
|
79
|
+
plugin.apdp.publish_events = AsyncMock() # type: ignore[method-assign]
|
|
80
|
+
return plugin
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.fixture(autouse=True)
|
|
84
|
+
def _clear_env(monkeypatch):
|
|
85
|
+
"""Tests must not be affected by ambient
|
|
86
|
+
``WATCHLIGHT_INTENT_ENFORCEMENT_MODE`` in the developer's shell —
|
|
87
|
+
clear it so every test starts from the locked brownfield default."""
|
|
88
|
+
monkeypatch.delenv(INTENT_ENFORCEMENT_MODE_ENV_VAR, raising=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest_asyncio.fixture
|
|
92
|
+
async def greenfield_plugin_and_handle():
|
|
93
|
+
plugin = _build_plugin(intent_enforcement_mode="greenfield")
|
|
94
|
+
handle = await plugin.start_run("my-claude-agent")
|
|
95
|
+
handle._aentered = True # synthetic test handle
|
|
96
|
+
return plugin, handle
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest_asyncio.fixture
|
|
100
|
+
async def brownfield_plugin_and_handle():
|
|
101
|
+
plugin = _build_plugin(intent_enforcement_mode="brownfield")
|
|
102
|
+
handle = await plugin.start_run("my-claude-agent")
|
|
103
|
+
handle._aentered = True # synthetic test handle
|
|
104
|
+
return plugin, handle
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ─── 1. greenfield_mode_injects_synthetic_tool_at_construction ────────
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_greenfield_mode_injects_synthetic_tool_at_construction():
|
|
111
|
+
"""Test 1 — greenfield agents see the synthetic
|
|
112
|
+
``watchlight.declare_intent`` tool in the plugin's tool-injection
|
|
113
|
+
surface AT CONSTRUCTION TIME.
|
|
114
|
+
|
|
115
|
+
Mode resolution sits at plugin construction so the tool list is
|
|
116
|
+
stable from the agent's first turn. The customer spreads
|
|
117
|
+
``plugin.synthetic_tools()`` (or the merged variant) into
|
|
118
|
+
``options["tools"]`` when calling ``query()``.
|
|
119
|
+
"""
|
|
120
|
+
plugin = _build_plugin(intent_enforcement_mode="greenfield")
|
|
121
|
+
tools = plugin.synthetic_tools()
|
|
122
|
+
|
|
123
|
+
assert plugin.intent_enforcement_mode == "greenfield"
|
|
124
|
+
assert len(tools) == 1
|
|
125
|
+
assert tools[0]["name"] == DECLARE_INTENT_TOOL_NAME
|
|
126
|
+
assert tools[0]["name"] == "watchlight.declare_intent"
|
|
127
|
+
# The schema is the canonical declare_intent contract — assert the
|
|
128
|
+
# load-bearing parameter shape is preserved through the plugin's
|
|
129
|
+
# injection surface.
|
|
130
|
+
assert "objective" in tools[0]["parameters"]["required"]
|
|
131
|
+
assert tools[0]["parameters"]["additionalProperties"] is False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_greenfield_merge_places_synthetic_first():
|
|
135
|
+
"""When merged with customer tools, the synthetic tool comes FIRST
|
|
136
|
+
so the LLM's tool-discovery heuristic surfaces it at the top of the
|
|
137
|
+
list — maximising the chance the agent calls declare_intent BEFORE
|
|
138
|
+
any privileged tool."""
|
|
139
|
+
plugin = _build_plugin(intent_enforcement_mode="greenfield")
|
|
140
|
+
customer_tools = [
|
|
141
|
+
{"name": "search_documents", "description": "search", "parameters": {}},
|
|
142
|
+
{"name": "fetch_data", "description": "fetch", "parameters": {}},
|
|
143
|
+
]
|
|
144
|
+
merged = plugin.merge_synthetic_tools(customer_tools)
|
|
145
|
+
assert merged[0]["name"] == DECLARE_INTENT_TOOL_NAME
|
|
146
|
+
assert merged[1]["name"] == "search_documents"
|
|
147
|
+
assert merged[2]["name"] == "fetch_data"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_greenfield_merge_dedupes_customer_collision():
|
|
151
|
+
"""A customer tool that collides with the reserved synthetic name is
|
|
152
|
+
dropped — the SDK's canonical definition wins. Defensive against
|
|
153
|
+
accidental customer code; the namespace is reserved."""
|
|
154
|
+
plugin = _build_plugin(intent_enforcement_mode="greenfield")
|
|
155
|
+
bad_customer_tools = [
|
|
156
|
+
{
|
|
157
|
+
"name": DECLARE_INTENT_TOOL_NAME,
|
|
158
|
+
"description": "customer's accidental shadow",
|
|
159
|
+
"parameters": {},
|
|
160
|
+
},
|
|
161
|
+
{"name": "fetch_data", "description": "fetch", "parameters": {}},
|
|
162
|
+
]
|
|
163
|
+
merged = plugin.merge_synthetic_tools(bad_customer_tools)
|
|
164
|
+
# Exactly one synthetic + one customer tool — the duplicate is dropped.
|
|
165
|
+
assert len(merged) == 2
|
|
166
|
+
assert merged[0]["name"] == DECLARE_INTENT_TOOL_NAME
|
|
167
|
+
assert merged[1]["name"] == "fetch_data"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_greenfield_mode_resolution_falls_back_to_env_var(monkeypatch):
|
|
171
|
+
"""Mode resolution: env var fallback. Customer that sets
|
|
172
|
+
``WATCHLIGHT_INTENT_ENFORCEMENT_MODE=greenfield`` without passing
|
|
173
|
+
the constructor arg STILL gets the synthetic tool injected.
|
|
174
|
+
"""
|
|
175
|
+
monkeypatch.setenv(INTENT_ENFORCEMENT_MODE_ENV_VAR, "greenfield")
|
|
176
|
+
plugin = _build_plugin() # no explicit mode arg
|
|
177
|
+
assert plugin.intent_enforcement_mode == "greenfield"
|
|
178
|
+
assert len(plugin.synthetic_tools()) == 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ─── 2. brownfield_mode_does_not_inject_synthetic_tool ────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_brownfield_mode_does_not_inject_synthetic_tool():
|
|
185
|
+
"""Test 2 — brownfield agents NEVER see the synthetic tool.
|
|
186
|
+
|
|
187
|
+
The brownfield Cedar matrix accepts inferred provenance at every
|
|
188
|
+
tier so the synthetic-tool gate is inert; surfacing the tool would
|
|
189
|
+
confuse operators and waste LLM tokens.
|
|
190
|
+
"""
|
|
191
|
+
plugin = _build_plugin(intent_enforcement_mode="brownfield")
|
|
192
|
+
assert plugin.intent_enforcement_mode == "brownfield"
|
|
193
|
+
assert plugin.synthetic_tools() == []
|
|
194
|
+
# Merged variant: only the customer's tools survive in brownfield.
|
|
195
|
+
customer_tools = [
|
|
196
|
+
{"name": "search_documents", "description": "search", "parameters": {}},
|
|
197
|
+
]
|
|
198
|
+
assert plugin.merge_synthetic_tools(customer_tools) == customer_tools
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_default_mode_is_brownfield_no_synthetic_tool():
|
|
202
|
+
"""The locked default — no explicit arg + no env var =
|
|
203
|
+
brownfield. Customers must OPT INTO greenfield mode."""
|
|
204
|
+
plugin = _build_plugin() # no arg, no env var (autouse fixture)
|
|
205
|
+
assert plugin.intent_enforcement_mode == "brownfield"
|
|
206
|
+
assert plugin.synthetic_tools() == []
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ─── 3. declare_intent_call_stamps_provenance_to_declared ─────────────
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.asyncio
|
|
213
|
+
async def test_declare_intent_call_stamps_provenance_to_declared(
|
|
214
|
+
greenfield_plugin_and_handle,
|
|
215
|
+
):
|
|
216
|
+
"""Test 3 — the load-bearing mutation step.
|
|
217
|
+
|
|
218
|
+
When ``intercept_tool_call`` handles a PreToolUse capture for the
|
|
219
|
+
synthetic tool, the handle's intent state mutates: provenance flips
|
|
220
|
+
to ``DECLARED`` and the truncated objective is stored. Returns the
|
|
221
|
+
canonical LLM-visible tool response dict.
|
|
222
|
+
"""
|
|
223
|
+
plugin, handle = greenfield_plugin_and_handle
|
|
224
|
+
|
|
225
|
+
# Baseline — fresh handle has no declared intent.
|
|
226
|
+
assert handle.declared_intent_objective is None
|
|
227
|
+
assert handle.declared_intent_provenance is None
|
|
228
|
+
|
|
229
|
+
# Claude Agent SDK PreToolUse capture shape — the same shape the
|
|
230
|
+
# customer's hook loop captures into the translator queue.
|
|
231
|
+
capture = {
|
|
232
|
+
"hook": "PreToolUse",
|
|
233
|
+
"tool_name": "watchlight.declare_intent",
|
|
234
|
+
"tool_input": {"objective": "audit Q3 invoices"},
|
|
235
|
+
}
|
|
236
|
+
response = plugin.intercept_tool_call(handle, capture)
|
|
237
|
+
|
|
238
|
+
assert response is not None
|
|
239
|
+
assert response["status"] == "intent_declared"
|
|
240
|
+
assert response["objective"] == "audit Q3 invoices"
|
|
241
|
+
# Handle mutation — the public read surface.
|
|
242
|
+
assert handle.declared_intent_objective == "audit Q3 invoices"
|
|
243
|
+
assert handle.declared_intent_provenance == IntentProvenance.DECLARED
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@pytest.mark.asyncio
|
|
247
|
+
async def test_non_synthetic_tool_capture_returns_none(
|
|
248
|
+
greenfield_plugin_and_handle,
|
|
249
|
+
):
|
|
250
|
+
"""A capture for a customer-defined tool returns ``None`` so the
|
|
251
|
+
customer's hook loop falls through to its normal
|
|
252
|
+
authorize + translate path.
|
|
253
|
+
|
|
254
|
+
Confirms the predicate gates intercept correctly — only
|
|
255
|
+
``watchlight.declare_intent`` short-circuits.
|
|
256
|
+
"""
|
|
257
|
+
plugin, handle = greenfield_plugin_and_handle
|
|
258
|
+
capture = {
|
|
259
|
+
"hook": "PreToolUse",
|
|
260
|
+
"tool_name": "search_documents",
|
|
261
|
+
"tool_input": {"query": "Q3"},
|
|
262
|
+
}
|
|
263
|
+
assert plugin.intercept_tool_call(handle, capture) is None
|
|
264
|
+
# Handle state unchanged.
|
|
265
|
+
assert handle.declared_intent_objective is None
|
|
266
|
+
assert handle.declared_intent_provenance is None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ─── 4. synthetic_tool_call_bypasses_authorize ────────────────────────
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@pytest.mark.asyncio
|
|
273
|
+
async def test_synthetic_tool_call_bypasses_authorize(
|
|
274
|
+
greenfield_plugin_and_handle,
|
|
275
|
+
):
|
|
276
|
+
"""Test 4 — authorization carve-out: the synthetic tool MUST NOT
|
|
277
|
+
round-trip to APDP ``/authorize``.
|
|
278
|
+
|
|
279
|
+
Calling ``intercept_tool_call`` on a synthetic capture mutates
|
|
280
|
+
handle state in-process WITHOUT calling ``plugin.apdp.authorize``.
|
|
281
|
+
The Cedar gate would forbid the call before the agent could declare;
|
|
282
|
+
the bypass is the load-bearing escape valve.
|
|
283
|
+
|
|
284
|
+
This is an intentionally accepted trade-off documented in the
|
|
285
|
+
security-hardening notes.
|
|
286
|
+
"""
|
|
287
|
+
plugin, handle = greenfield_plugin_and_handle
|
|
288
|
+
capture = {
|
|
289
|
+
"hook": "PreToolUse",
|
|
290
|
+
"tool_name": "watchlight.declare_intent",
|
|
291
|
+
"tool_input": {"objective": "audit Q3 invoices"},
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
response = plugin.intercept_tool_call(handle, capture)
|
|
295
|
+
assert response is not None
|
|
296
|
+
assert response["status"] == "intent_declared"
|
|
297
|
+
|
|
298
|
+
# Load-bearing assertion — NO authorize round-trip happened.
|
|
299
|
+
plugin.apdp.authorize.assert_not_called()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_is_synthetic_tool_capture_predicate_distinguishes_calls():
|
|
303
|
+
"""The predicate gates correctly across the framework-agnostic
|
|
304
|
+
shapes the customer's hook loop might capture."""
|
|
305
|
+
# Claude Agent SDK PreToolUse shape.
|
|
306
|
+
assert is_synthetic_tool_capture(
|
|
307
|
+
{"hook": "PreToolUse", "tool_name": DECLARE_INTENT_TOOL_NAME, "tool_input": {}}
|
|
308
|
+
) is True
|
|
309
|
+
# Anthropic / OpenAI canonical tool-call shape.
|
|
310
|
+
assert is_synthetic_tool_capture(
|
|
311
|
+
{"name": DECLARE_INTENT_TOOL_NAME, "arguments": {"objective": "x"}}
|
|
312
|
+
) is True
|
|
313
|
+
# LangChain shape — defer to the SDK predicate.
|
|
314
|
+
assert is_synthetic_tool_capture(
|
|
315
|
+
{"tool": DECLARE_INTENT_TOOL_NAME, "args": {"objective": "x"}}
|
|
316
|
+
) is True
|
|
317
|
+
# A real tool call → False (falls through to authorize).
|
|
318
|
+
assert is_synthetic_tool_capture(
|
|
319
|
+
{"hook": "PreToolUse", "tool_name": "search_documents", "tool_input": {}}
|
|
320
|
+
) is False
|
|
321
|
+
# Defensive shapes.
|
|
322
|
+
assert is_synthetic_tool_capture(None) is False
|
|
323
|
+
assert is_synthetic_tool_capture("not_a_dict") is False
|
|
324
|
+
assert is_synthetic_tool_capture({}) is False
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ─── 5. subsequent_authorize_action_after_declare_intent_satisfies_cedar_gate ──
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@pytest.mark.asyncio
|
|
331
|
+
async def test_subsequent_authorize_action_after_declare_intent_satisfies_cedar_gate(
|
|
332
|
+
greenfield_plugin_and_handle,
|
|
333
|
+
):
|
|
334
|
+
"""Test 5 — LOAD-BEARING INTEGRATION TEST for the intent-enforcement
|
|
335
|
+
gate.
|
|
336
|
+
|
|
337
|
+
End-to-end proof that the synthetic-tool path closes the
|
|
338
|
+
intent-enforcement Cedar gate for the Claude Agent SDK (Python) lane:
|
|
339
|
+
|
|
340
|
+
1. Agent calls ``watchlight.declare_intent({"objective": "audit Q3"})``
|
|
341
|
+
through the SDK's PreToolUse hook.
|
|
342
|
+
2. ``plugin.intercept_tool_call(handle, capture)`` short-circuits —
|
|
343
|
+
mutates handle state to provenance=Declared, NO authorize call.
|
|
344
|
+
3. Agent's next privileged call goes through the customer's normal
|
|
345
|
+
loop: ``await handle.authorize_action("read", "Document::ledger")``.
|
|
346
|
+
4. The wire shape APDP receives includes
|
|
347
|
+
``intent={"objective": "audit Q3 invoices"}`` — the exact field
|
|
348
|
+
``compute_execution_intent_projection`` reads to stamp
|
|
349
|
+
``provenance: "declared"`` on the evaluation context.
|
|
350
|
+
5. That projection clears the greenfield-Custodial Cedar gate that
|
|
351
|
+
forbids privileged actions unless intent was declared, and the
|
|
352
|
+
action is Allowed.
|
|
353
|
+
|
|
354
|
+
This test pins the wire shape APDP receives so a regression to the
|
|
355
|
+
auto-inject path or to the synthetic-tool mutator surface fails CI
|
|
356
|
+
loudly.
|
|
357
|
+
"""
|
|
358
|
+
plugin, handle = greenfield_plugin_and_handle
|
|
359
|
+
|
|
360
|
+
# Step 1 + 2 — synthetic tool fires; handle mutates; NO authorize.
|
|
361
|
+
capture = {
|
|
362
|
+
"hook": "PreToolUse",
|
|
363
|
+
"tool_name": "watchlight.declare_intent",
|
|
364
|
+
"tool_input": {"objective": "audit Q3 invoices"},
|
|
365
|
+
}
|
|
366
|
+
assert plugin.intercept_tool_call(handle, capture) is not None
|
|
367
|
+
plugin.apdp.authorize.assert_not_called()
|
|
368
|
+
|
|
369
|
+
# Step 3 — agent's next privileged call.
|
|
370
|
+
await handle.authorize_action("read", "Document::ledger")
|
|
371
|
+
|
|
372
|
+
# Step 4 — verify the wire shape APDP saw the declared intent.
|
|
373
|
+
plugin.apdp.authorize.assert_called_once()
|
|
374
|
+
call_kwargs = plugin.apdp.authorize.call_args.kwargs
|
|
375
|
+
assert call_kwargs["intent"] == {"objective": "audit Q3 invoices"}
|
|
376
|
+
# Cedar context projection's load-bearing input — the field name
|
|
377
|
+
# the intent-enforcement projection treats as Declared.
|
|
378
|
+
assert call_kwargs["intent"]["objective"] == "audit Q3 invoices"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The Developer-Edition seam: ``governance=`` uses the supplied backend
|
|
2
|
+
verbatim (in-process ``InProcessClient``) instead of a networked ApdpClient.
|
|
3
|
+
"""
|
|
4
|
+
from watchlight_core import ApdpClient
|
|
5
|
+
from watchlight_core.testing import MockApdpClient
|
|
6
|
+
from watchlight_claude_agent import WatchlightClaudeAgentSDKPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_governance_param_uses_supplied_backend():
|
|
10
|
+
backend = MockApdpClient()
|
|
11
|
+
plugin = WatchlightClaudeAgentSDKPlugin(governance=backend)
|
|
12
|
+
assert plugin.apdp is backend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_default_still_builds_networked_apdp_client():
|
|
16
|
+
plugin = WatchlightClaudeAgentSDKPlugin()
|
|
17
|
+
assert isinstance(plugin.apdp, ApdpClient)
|