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.
Files changed (24) hide show
  1. watchlight_claude_agent-0.2.0/PKG-INFO +105 -0
  2. watchlight_claude_agent-0.2.0/README.md +77 -0
  3. watchlight_claude_agent-0.2.0/pyproject.toml +69 -0
  4. watchlight_claude_agent-0.2.0/setup.cfg +4 -0
  5. watchlight_claude_agent-0.2.0/tests/test_declare_intent_injection.py +378 -0
  6. watchlight_claude_agent-0.2.0/tests/test_governance_backend_injection.py +17 -0
  7. watchlight_claude_agent-0.2.0/tests/test_intent_extraction.py +286 -0
  8. watchlight_claude_agent-0.2.0/tests/test_observation_source.py +65 -0
  9. watchlight_claude_agent-0.2.0/tests/test_plugin.py +196 -0
  10. watchlight_claude_agent-0.2.0/tests/test_quarantine_kill_signal.py +97 -0
  11. watchlight_claude_agent-0.2.0/tests/test_subagents.py +558 -0
  12. watchlight_claude_agent-0.2.0/tests/test_translator.py +206 -0
  13. watchlight_claude_agent-0.2.0/watchlight_claude_agent/__init__.py +103 -0
  14. watchlight_claude_agent-0.2.0/watchlight_claude_agent/intent.py +109 -0
  15. watchlight_claude_agent-0.2.0/watchlight_claude_agent/plugin.py +575 -0
  16. watchlight_claude_agent-0.2.0/watchlight_claude_agent/py.typed +0 -0
  17. watchlight_claude_agent-0.2.0/watchlight_claude_agent/subagents.py +172 -0
  18. watchlight_claude_agent-0.2.0/watchlight_claude_agent/synthetic_tools.py +284 -0
  19. watchlight_claude_agent-0.2.0/watchlight_claude_agent/traces.py +356 -0
  20. watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/PKG-INFO +105 -0
  21. watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/SOURCES.txt +22 -0
  22. watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/dependency_links.txt +1 -0
  23. watchlight_claude_agent-0.2.0/watchlight_claude_agent.egg-info/requires.txt +9 -0
  24. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)