watchlight 0.1.0__py3-none-any.whl
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/__init__.py +171 -0
- watchlight/claude_agent.py +50 -0
- watchlight/cli.py +276 -0
- watchlight/inprocess.py +89 -0
- watchlight/langgraph.py +50 -0
- watchlight/pydantic_ai.py +50 -0
- watchlight-0.1.0.dist-info/METADATA +226 -0
- watchlight-0.1.0.dist-info/RECORD +11 -0
- watchlight-0.1.0.dist-info/WHEEL +5 -0
- watchlight-0.1.0.dist-info/entry_points.txt +2 -0
- watchlight-0.1.0.dist-info/top_level.txt +1 -0
watchlight/__init__.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""watchlight — Developer Edition.
|
|
2
|
+
|
|
3
|
+
Govern an AI agent in-process, with zero infrastructure. Decorate a tool with
|
|
4
|
+
an *intent*, load a local policy, run your script, and the policy engine
|
|
5
|
+
authorizes every call — allowing what a policy permits and refusing everything
|
|
6
|
+
else **before the tool body runs**.
|
|
7
|
+
|
|
8
|
+
from watchlight import govern, Denied
|
|
9
|
+
|
|
10
|
+
govern.load("watchlight.policy.json") # or govern.allow("permit(...);")
|
|
11
|
+
|
|
12
|
+
@govern.tool(intent="research")
|
|
13
|
+
def web_search(query: str) -> str:
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
The engine is the REAL Watchlight authorization engine (Cedar evaluation + the
|
|
17
|
+
surrounding pipeline) embedded via the ``watchlight-engine`` extension — the
|
|
18
|
+
same authorization model that runs in production, just in-process. Going to
|
|
19
|
+
production is pointing at a running policy service, not a rewrite.
|
|
20
|
+
|
|
21
|
+
Guarantees that are identical to production and MUST NOT be relaxed here:
|
|
22
|
+
* **Fail-closed** — no matching policy denies; an unreachable decision denies.
|
|
23
|
+
* **Explicit intent** — a tool is governed by the intent you declare, never
|
|
24
|
+
inferred from its name or body.
|
|
25
|
+
* **Value-free audit** — argument *values* never enter the trail; only who,
|
|
26
|
+
what intent, which resource, and the decision.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import datetime
|
|
32
|
+
import functools
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import pathlib
|
|
36
|
+
from typing import Any, Callable, TypeVar
|
|
37
|
+
|
|
38
|
+
import watchlight_engine as _engine
|
|
39
|
+
|
|
40
|
+
__all__ = ["Watchlight", "Denied", "govern"]
|
|
41
|
+
|
|
42
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Denied(PermissionError):
|
|
46
|
+
"""Raised when the policy engine refuses a governed tool call (fail-closed).
|
|
47
|
+
|
|
48
|
+
The decorated function's body never runs — the refusal happens *before*
|
|
49
|
+
the side effect, which is the whole point.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, tool: str, intent: str, reason: str) -> None:
|
|
53
|
+
self.tool = tool
|
|
54
|
+
self.intent = intent
|
|
55
|
+
self.reason = reason
|
|
56
|
+
super().__init__(f"watchlight denied intent '{intent}' on tool/{tool}: {reason}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Watchlight:
|
|
60
|
+
"""An in-process policy decision point for a single agent.
|
|
61
|
+
|
|
62
|
+
Wraps the ``watchlight-engine`` in-process authorization core. Policies are
|
|
63
|
+
loaded from a local file or added inline; each governed tool call is
|
|
64
|
+
authorized against them.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, agent: str | None = None, audit_dir: str | os.PathLike[str] = ".watchlight") -> None:
|
|
68
|
+
self._engine = _engine.PolicyEngine()
|
|
69
|
+
self.agent = agent or os.environ.get("WATCHLIGHT_AGENT", "my-agent")
|
|
70
|
+
self._audit_path = pathlib.Path(audit_dir) / "audit.jsonl"
|
|
71
|
+
self._announced = False
|
|
72
|
+
self._policy_count = 0
|
|
73
|
+
|
|
74
|
+
# ── policy loading ──────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def allow(self, cedar_code: str, name: str | None = None) -> "Watchlight":
|
|
77
|
+
"""Add one Cedar policy inline. Returns self for chaining."""
|
|
78
|
+
self._engine.add_policy(
|
|
79
|
+
json.dumps({"name": name or f"policy-{self._policy_count}", "code": cedar_code})
|
|
80
|
+
)
|
|
81
|
+
self._policy_count += 1
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def load(self, path: str | os.PathLike[str]) -> "Watchlight":
|
|
85
|
+
"""Load policies from a JSON file — a list of ``{"name", "code"}`` objects
|
|
86
|
+
(or ``{"policies": [...]}``). Fail-closed: a missing file loads nothing,
|
|
87
|
+
so every governed call is denied until a policy permits it."""
|
|
88
|
+
p = pathlib.Path(path)
|
|
89
|
+
if not p.exists():
|
|
90
|
+
return self
|
|
91
|
+
data = json.loads(p.read_text())
|
|
92
|
+
entries = data if isinstance(data, list) else data.get("policies", [])
|
|
93
|
+
for entry in entries:
|
|
94
|
+
self.allow(entry["code"], entry.get("name"))
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
# ── governing tools ─────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def tool(self, intent: str) -> Callable[[_F], _F]:
|
|
100
|
+
"""Decorate a function as a governed tool with the given *intent*.
|
|
101
|
+
|
|
102
|
+
On every call the engine authorizes ``(agent, intent, tool/<name>)``.
|
|
103
|
+
On ALLOW the function runs; on anything else a :class:`Denied` is raised
|
|
104
|
+
and the body never executes.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def decorator(fn: _F) -> _F:
|
|
108
|
+
resource = f"tool/{fn.__name__}"
|
|
109
|
+
|
|
110
|
+
@functools.wraps(fn)
|
|
111
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
112
|
+
decision, reason = self._authorize(intent, resource)
|
|
113
|
+
self._audit(intent, resource, decision, reason)
|
|
114
|
+
if decision != "Allow":
|
|
115
|
+
raise Denied(fn.__name__, intent, reason or "no matching policy")
|
|
116
|
+
return fn(*args, **kwargs)
|
|
117
|
+
|
|
118
|
+
return wrapper # type: ignore[return-value]
|
|
119
|
+
|
|
120
|
+
return decorator
|
|
121
|
+
|
|
122
|
+
# ── internals ───────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _authorize(self, intent: str, resource: str) -> tuple[str, str]:
|
|
125
|
+
response = json.loads(
|
|
126
|
+
self._engine.authorize(
|
|
127
|
+
json.dumps(
|
|
128
|
+
{
|
|
129
|
+
"principal": self.agent,
|
|
130
|
+
"action": intent,
|
|
131
|
+
"resource": resource,
|
|
132
|
+
"context": {},
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return response.get("decision", "Deny"), response.get("reason", "")
|
|
138
|
+
|
|
139
|
+
def _announce(self) -> None:
|
|
140
|
+
if not self._announced:
|
|
141
|
+
print(f"watchlight: governing '{self.agent}' (dev mode, in-process engine)")
|
|
142
|
+
self._announced = True
|
|
143
|
+
|
|
144
|
+
def _audit(self, intent: str, resource: str, decision: str, reason: str) -> None:
|
|
145
|
+
self._announce()
|
|
146
|
+
allowed = decision == "Allow"
|
|
147
|
+
tag = "ALLOW" if allowed else "DENY"
|
|
148
|
+
trailer = "" if allowed else f" {reason or 'no matching policy'}"
|
|
149
|
+
print(f"watchlight: {tag:5} {intent:9} {resource}{trailer}")
|
|
150
|
+
# Value-free audit: argument VALUES never enter the trail — only the
|
|
151
|
+
# governance decision. This mirrors the production audit contract.
|
|
152
|
+
record = {
|
|
153
|
+
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
154
|
+
"agent": self.agent,
|
|
155
|
+
"intent": intent,
|
|
156
|
+
"resource": resource,
|
|
157
|
+
"decision": decision,
|
|
158
|
+
}
|
|
159
|
+
try:
|
|
160
|
+
self._audit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
with self._audit_path.open("a", encoding="utf-8") as fh:
|
|
162
|
+
fh.write(json.dumps(record) + "\n")
|
|
163
|
+
except OSError:
|
|
164
|
+
# Audit is best-effort in dev mode; never let it break the app.
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# A ready-to-use default governor so `from watchlight import govern` just works.
|
|
169
|
+
# It starts with NO policies — fail-closed by default — until you `govern.load(...)`
|
|
170
|
+
# a policy file or `govern.allow(...)` a policy inline.
|
|
171
|
+
govern = Watchlight()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Govern a Claude Agent SDK agent in-process, with zero infrastructure.
|
|
2
|
+
|
|
3
|
+
from watchlight.claude_agent import governed_plugin
|
|
4
|
+
|
|
5
|
+
plugin = governed_plugin("watchlight.policy.json")
|
|
6
|
+
async with await plugin.start_run("research-agent") as handle:
|
|
7
|
+
if not await handle.authorize_action("read", "tool/web_search"):
|
|
8
|
+
raise PermissionError("denied before it executed")
|
|
9
|
+
... # run the tool
|
|
10
|
+
|
|
11
|
+
The returned object is an ordinary ``WatchlightClaudeAgentSDKPlugin`` — the SAME
|
|
12
|
+
plugin you ship to production, wired here to the in-process engine (local Cedar
|
|
13
|
+
policies, local value-free audit). Set ``WATCHLIGHT_APDP_URL`` to a running
|
|
14
|
+
policy service and the identical code runs against production APDP — one
|
|
15
|
+
environment variable, not a rewrite.
|
|
16
|
+
|
|
17
|
+
Requires the Claude Agent extra: ``pip install 'watchlight[claude-agent]'``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
from .inprocess import Policies, _select_backend_kwargs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def governed_plugin(
|
|
28
|
+
policies: Policies = None,
|
|
29
|
+
*,
|
|
30
|
+
audit_path: Optional[str] = ".watchlight/audit.jsonl",
|
|
31
|
+
**plugin_kwargs: Any,
|
|
32
|
+
) -> Any:
|
|
33
|
+
"""Return a governed ``WatchlightClaudeAgentSDKPlugin``.
|
|
34
|
+
|
|
35
|
+
:param policies: local Cedar policies — a path to a JSON policy file or an
|
|
36
|
+
in-memory list of ``{"name", "code"}`` objects. ``None`` → fail-closed.
|
|
37
|
+
:param audit_path: local JSONL lineage sink (value-free). ``None`` disables.
|
|
38
|
+
:param plugin_kwargs: forwarded to ``WatchlightClaudeAgentSDKPlugin`` (e.g.
|
|
39
|
+
``tenant_id``, ``log_decisions``).
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
from watchlight_claude_agent import WatchlightClaudeAgentSDKPlugin
|
|
43
|
+
except ImportError as exc: # pragma: no cover - import-guard message
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"governed Claude Agent support requires the claude-agent extra: "
|
|
46
|
+
"pip install 'watchlight[claude-agent]'"
|
|
47
|
+
) from exc
|
|
48
|
+
return WatchlightClaudeAgentSDKPlugin(
|
|
49
|
+
**_select_backend_kwargs(policies, audit_path, plugin_kwargs)
|
|
50
|
+
)
|
watchlight/cli.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""``watchlight`` command-line entry point.
|
|
2
|
+
|
|
3
|
+
Currently one command:
|
|
4
|
+
|
|
5
|
+
watchlight dev # a local dashboard for the in-process audit trail
|
|
6
|
+
|
|
7
|
+
``watchlight dev`` serves a tiny, dependency-free web page that tails the local
|
|
8
|
+
``.watchlight/audit.jsonl`` and shows every governance decision as it happens —
|
|
9
|
+
the ALLOWs, and (the point) the DENYs that stopped a tool **before** it ran. It
|
|
10
|
+
reads the same value-free audit the engine writes; no argument values, tokens,
|
|
11
|
+
or secrets are ever displayed because they are never in the trail.
|
|
12
|
+
|
|
13
|
+
It is deliberately minimal: the Developer-Edition dashboard shows *your one
|
|
14
|
+
process*. Fleet-wide lineage, signed tamper-evident audit, and drift→quarantine
|
|
15
|
+
live in the governed control plane (Enterprise).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import pathlib
|
|
23
|
+
import sys
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
# Reading the value-free audit trail
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
|
|
32
|
+
def _read_events(audit_path: pathlib.Path, limit: int = 500) -> list[dict[str, Any]]:
|
|
33
|
+
"""Parse the audit JSONL into normalized decision records (newest last).
|
|
34
|
+
|
|
35
|
+
Robust to both the `watchlight.govern` shape ({ts, agent, intent, resource,
|
|
36
|
+
decision}) and the plugin/in-process shape — fields are looked up with
|
|
37
|
+
fallbacks. Malformed lines are skipped. Never raises."""
|
|
38
|
+
if not audit_path.exists():
|
|
39
|
+
return []
|
|
40
|
+
events: list[dict[str, Any]] = []
|
|
41
|
+
try:
|
|
42
|
+
lines = audit_path.read_text(encoding="utf-8").splitlines()
|
|
43
|
+
except OSError:
|
|
44
|
+
return []
|
|
45
|
+
for line in lines[-limit:]:
|
|
46
|
+
line = line.strip()
|
|
47
|
+
if not line:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
raw = json.loads(line)
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
continue
|
|
53
|
+
decision = str(raw.get("decision", "")).strip()
|
|
54
|
+
events.append(
|
|
55
|
+
{
|
|
56
|
+
"ts": raw.get("ts") or raw.get("timestamp") or "",
|
|
57
|
+
"agent": raw.get("agent") or raw.get("agent_id") or raw.get("principal") or "agent",
|
|
58
|
+
"action": raw.get("intent") or raw.get("action") or "",
|
|
59
|
+
"resource": raw.get("resource") or raw.get("tool") or "",
|
|
60
|
+
"decision": decision,
|
|
61
|
+
"allowed": decision.lower() in ("allow", "permit"),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
return events
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _summary(events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
68
|
+
allowed = sum(1 for e in events if e["allowed"])
|
|
69
|
+
denied = len(events) - allowed
|
|
70
|
+
agents = sorted({e["agent"] for e in events if e["agent"]})
|
|
71
|
+
return {"total": len(events), "allowed": allowed, "denied": denied, "agents": agents}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------- #
|
|
75
|
+
# HTTP server
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
|
|
78
|
+
def _make_handler(audit_path: pathlib.Path):
|
|
79
|
+
class Handler(BaseHTTPRequestHandler):
|
|
80
|
+
def log_message(self, *_: Any) -> None: # silence access logging
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def do_GET(self) -> None: # noqa: N802 (http.server API)
|
|
84
|
+
if self.path.startswith("/api/events"):
|
|
85
|
+
events = _read_events(audit_path)
|
|
86
|
+
body = json.dumps(
|
|
87
|
+
{"summary": _summary(events), "events": events, "audit_path": str(audit_path)}
|
|
88
|
+
).encode("utf-8")
|
|
89
|
+
self.send_response(200)
|
|
90
|
+
self.send_header("content-type", "application/json")
|
|
91
|
+
self.send_header("content-length", str(len(body)))
|
|
92
|
+
self.end_headers()
|
|
93
|
+
self.wfile.write(body)
|
|
94
|
+
return
|
|
95
|
+
if self.path in ("/", "/index.html"):
|
|
96
|
+
body = _PAGE.encode("utf-8")
|
|
97
|
+
self.send_response(200)
|
|
98
|
+
self.send_header("content-type", "text/html; charset=utf-8")
|
|
99
|
+
self.send_header("content-length", str(len(body)))
|
|
100
|
+
self.end_headers()
|
|
101
|
+
self.wfile.write(body)
|
|
102
|
+
return
|
|
103
|
+
self.send_response(404)
|
|
104
|
+
self.end_headers()
|
|
105
|
+
|
|
106
|
+
return Handler
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cmd_dev(args: argparse.Namespace) -> int:
|
|
110
|
+
audit_path = pathlib.Path(args.audit).expanduser()
|
|
111
|
+
server = ThreadingHTTPServer((args.host, args.port), _make_handler(audit_path))
|
|
112
|
+
url = f"http://{args.host}:{args.port}"
|
|
113
|
+
print(f"watchlight dev — dashboard on {url}")
|
|
114
|
+
print(f" reading audit: {audit_path}")
|
|
115
|
+
print(" run your governed agent in another terminal; decisions appear live.")
|
|
116
|
+
print(" Ctrl-C to stop.")
|
|
117
|
+
if not args.no_open:
|
|
118
|
+
try:
|
|
119
|
+
import webbrowser
|
|
120
|
+
|
|
121
|
+
webbrowser.open(url)
|
|
122
|
+
except Exception: # pragma: no cover - best effort
|
|
123
|
+
pass
|
|
124
|
+
try:
|
|
125
|
+
server.serve_forever()
|
|
126
|
+
except KeyboardInterrupt:
|
|
127
|
+
print("\nwatchlight dev — stopped.")
|
|
128
|
+
finally:
|
|
129
|
+
server.server_close()
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: list[str] | None = None) -> int:
|
|
134
|
+
parser = argparse.ArgumentParser(prog="watchlight", description="Watchlight Developer Edition.")
|
|
135
|
+
sub = parser.add_subparsers(dest="command")
|
|
136
|
+
|
|
137
|
+
dev = sub.add_parser("dev", help="serve the local decision dashboard")
|
|
138
|
+
dev.add_argument("--port", type=int, default=7000, help="port (default: 7000)")
|
|
139
|
+
dev.add_argument("--host", default="127.0.0.1", help="bind host (default: 127.0.0.1)")
|
|
140
|
+
dev.add_argument(
|
|
141
|
+
"--audit",
|
|
142
|
+
default=".watchlight/audit.jsonl",
|
|
143
|
+
help="audit JSONL to tail (default: .watchlight/audit.jsonl)",
|
|
144
|
+
)
|
|
145
|
+
dev.add_argument("--no-open", action="store_true", help="do not open a browser")
|
|
146
|
+
dev.set_defaults(func=_cmd_dev)
|
|
147
|
+
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
if not getattr(args, "command", None):
|
|
150
|
+
parser.print_help()
|
|
151
|
+
return 0
|
|
152
|
+
return int(args.func(args))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------- #
|
|
156
|
+
# The dashboard page (self-contained: no external CSS/JS/fonts)
|
|
157
|
+
# --------------------------------------------------------------------------- #
|
|
158
|
+
|
|
159
|
+
_PAGE = """<!doctype html>
|
|
160
|
+
<html lang="en" data-theme="dark">
|
|
161
|
+
<head>
|
|
162
|
+
<meta charset="utf-8" />
|
|
163
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
164
|
+
<title>Watchlight · dev</title>
|
|
165
|
+
<style>
|
|
166
|
+
:root {
|
|
167
|
+
--bg:#0b0f17; --panel:#111827; --panel2:#0f1521; --border:rgba(148,163,184,.14);
|
|
168
|
+
--text:#e5e7eb; --muted:#94a3b8; --amber:#fbbf24; --green:#34d399; --red:#f87171;
|
|
169
|
+
}
|
|
170
|
+
* { box-sizing:border-box; }
|
|
171
|
+
body { margin:0; background:var(--bg); color:var(--text);
|
|
172
|
+
font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; }
|
|
173
|
+
a { color:var(--amber); text-decoration:none; }
|
|
174
|
+
header { display:flex; align-items:center; gap:12px; padding:18px 24px;
|
|
175
|
+
border-bottom:1px solid var(--border); position:sticky; top:0; background:rgba(11,15,23,.85);
|
|
176
|
+
backdrop-filter:blur(8px); z-index:2; }
|
|
177
|
+
.logo { width:26px;height:26px;border-radius:7px;background:linear-gradient(135deg,var(--amber),#f59e0b);
|
|
178
|
+
display:grid;place-items:center;color:#111;font-weight:800; }
|
|
179
|
+
h1 { font-size:15px; margin:0; font-weight:700; letter-spacing:.2px; }
|
|
180
|
+
.sub { color:var(--muted); font-size:12px; }
|
|
181
|
+
.live { margin-left:auto; display:flex; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
|
|
182
|
+
.dot { width:8px;height:8px;border-radius:50%;background:var(--green); box-shadow:0 0 0 0 rgba(52,211,153,.5);
|
|
183
|
+
animation:pulse 1.8s infinite; }
|
|
184
|
+
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(52,211,153,.45)} 70%{box-shadow:0 0 0 7px rgba(52,211,153,0)} 100%{box-shadow:0 0 0 0 rgba(52,211,153,0)} }
|
|
185
|
+
main { max-width:1000px; margin:0 auto; padding:24px; }
|
|
186
|
+
.cards { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
|
|
187
|
+
@media (max-width:640px){ .cards{ grid-template-columns:repeat(2,1fr);} }
|
|
188
|
+
.card { background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:16px 18px; }
|
|
189
|
+
.card .n { font-size:26px; font-weight:750; font-variant-numeric:tabular-nums; }
|
|
190
|
+
.card .l { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; margin-top:2px; }
|
|
191
|
+
.card.deny .n { color:var(--red); } .card.allow .n { color:var(--green); }
|
|
192
|
+
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.7px; color:var(--muted); margin:26px 0 10px; }
|
|
193
|
+
table { width:100%; border-collapse:collapse; }
|
|
194
|
+
th,td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--border); font-variant-numeric:tabular-nums; }
|
|
195
|
+
th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
|
|
196
|
+
tr.deny td { background:rgba(248,113,113,.05); }
|
|
197
|
+
.pill { display:inline-flex; align-items:center; gap:6px; font-weight:700; font-size:12px;
|
|
198
|
+
padding:3px 9px; border-radius:999px; }
|
|
199
|
+
.pill.allow { color:var(--green); background:rgba(52,211,153,.12); }
|
|
200
|
+
.pill.deny { color:var(--red); background:rgba(248,113,113,.12); }
|
|
201
|
+
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#cbd5e1; }
|
|
202
|
+
.empty { text-align:center; color:var(--muted); padding:52px 20px; border:1px dashed var(--border); border-radius:14px; }
|
|
203
|
+
.upsell { margin-top:30px; background:linear-gradient(180deg,rgba(251,191,36,.07),rgba(251,191,36,.02));
|
|
204
|
+
border:1px solid rgba(251,191,36,.22); border-radius:16px; padding:20px 22px; }
|
|
205
|
+
.upsell h3 { margin:0 0 6px; font-size:15px; }
|
|
206
|
+
.upsell p { margin:0 0 12px; color:var(--muted); }
|
|
207
|
+
.upsell ul { margin:0 0 14px; padding-left:18px; color:var(--muted); }
|
|
208
|
+
.cta { display:inline-block; background:var(--amber); color:#111; font-weight:700; padding:9px 16px; border-radius:10px; }
|
|
209
|
+
footer { text-align:center; color:var(--muted); font-size:12px; padding:24px; }
|
|
210
|
+
</style>
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<header>
|
|
214
|
+
<div class="logo">W</div>
|
|
215
|
+
<div>
|
|
216
|
+
<h1>Watchlight <span style="color:var(--amber)">·</span> dev</h1>
|
|
217
|
+
<div class="sub" id="audit-path">in-process engine · value-free audit</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="live"><span class="dot"></span> live</div>
|
|
220
|
+
</header>
|
|
221
|
+
<main>
|
|
222
|
+
<div class="cards">
|
|
223
|
+
<div class="card"><div class="n" id="c-total">0</div><div class="l">Decisions</div></div>
|
|
224
|
+
<div class="card allow"><div class="n" id="c-allow">0</div><div class="l">Allowed</div></div>
|
|
225
|
+
<div class="card deny"><div class="n" id="c-deny">0</div><div class="l">Denied before exec</div></div>
|
|
226
|
+
<div class="card"><div class="n" id="c-agents">0</div><div class="l">Agents</div></div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<h2>Decisions</h2>
|
|
230
|
+
<div id="feed"></div>
|
|
231
|
+
|
|
232
|
+
<div class="upsell">
|
|
233
|
+
<h3>Governing more than one agent — or more than one environment?</h3>
|
|
234
|
+
<p>The Developer Edition dashboard shows <em>this one process</em>. A fleet in production needs guarantees a single in-process engine structurally can't provide:</p>
|
|
235
|
+
<ul>
|
|
236
|
+
<li><b>Signed, tamper-evident audit & lineage</b> — court-defensible, KMS-backed.</li>
|
|
237
|
+
<li><b>Drift & anomaly detection → automatic quarantine</b> — stop a misbehaving agent before its next action.</li>
|
|
238
|
+
<li><b>Fleet-wide revocation</b> and one authority model across dev, staging, and prod.</li>
|
|
239
|
+
</ul>
|
|
240
|
+
<a class="cta" href="https://watchlight.ai" target="_blank" rel="noopener">Explore Enterprise →</a>
|
|
241
|
+
</div>
|
|
242
|
+
</main>
|
|
243
|
+
<footer>Watchlight Developer Edition · the same engine you ship to production, in-process.</footer>
|
|
244
|
+
|
|
245
|
+
<script>
|
|
246
|
+
function esc(s){ return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
247
|
+
function timefmt(ts){ if(!ts) return ''; const d=new Date(ts); return isNaN(d)? esc(ts) : d.toLocaleTimeString(); }
|
|
248
|
+
async function tick(){
|
|
249
|
+
let data; try { data = await (await fetch('/api/events')).json(); } catch(e){ return; }
|
|
250
|
+
const s = data.summary || {total:0,allowed:0,denied:0,agents:[]};
|
|
251
|
+
document.getElementById('c-total').textContent = s.total;
|
|
252
|
+
document.getElementById('c-allow').textContent = s.allowed;
|
|
253
|
+
document.getElementById('c-deny').textContent = s.denied;
|
|
254
|
+
document.getElementById('c-agents').textContent = (s.agents||[]).length;
|
|
255
|
+
if (data.audit_path) document.getElementById('audit-path').textContent = 'audit · ' + data.audit_path;
|
|
256
|
+
const feed = document.getElementById('feed');
|
|
257
|
+
const evs = (data.events||[]).slice().reverse(); // newest first
|
|
258
|
+
if (!evs.length){ feed.innerHTML = '<div class="empty">No decisions yet.<br>Run your governed agent — every ALLOW and DENY appears here, live.</div>'; return; }
|
|
259
|
+
let rows = '';
|
|
260
|
+
for (const e of evs){
|
|
261
|
+
const cls = e.allowed ? 'allow' : 'deny';
|
|
262
|
+
const label = e.allowed ? 'ALLOW' : 'DENY';
|
|
263
|
+
rows += `<tr class="${cls}"><td>${timefmt(e.ts)}</td><td class="mono">${esc(e.agent)}</td>`
|
|
264
|
+
+ `<td class="mono">${esc(e.action)}</td><td class="mono">${esc(e.resource)}</td>`
|
|
265
|
+
+ `<td><span class="pill ${cls}">${label}</span></td></tr>`;
|
|
266
|
+
}
|
|
267
|
+
feed.innerHTML = `<table><thead><tr><th>Time</th><th>Agent</th><th>Intent / action</th><th>Resource</th><th>Decision</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
268
|
+
}
|
|
269
|
+
tick(); setInterval(tick, 1500);
|
|
270
|
+
</script>
|
|
271
|
+
</body>
|
|
272
|
+
</html>"""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__": # pragma: no cover
|
|
276
|
+
sys.exit(main())
|
watchlight/inprocess.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""In-process governance backend for Watchlight framework plugins.
|
|
2
|
+
|
|
3
|
+
The Developer Edition runs the *same* framework plugins you ship to production
|
|
4
|
+
(``watchlight-langgraph``, ``watchlight-pydantic-ai``, ``watchlight-claude-agent``,
|
|
5
|
+
…) — but against the compiled authorization engine **in-process**, with zero
|
|
6
|
+
infrastructure. The seam is one object: a ``GovernanceBackend``.
|
|
7
|
+
|
|
8
|
+
- **Production**: the plugin talks to a running policy service over TLS
|
|
9
|
+
(``ApdpClient``).
|
|
10
|
+
- **Developer Edition**: the plugin talks to :func:`in_process_backend` — the
|
|
11
|
+
real ``watchlight-engine`` (the Watchlight authorization engine, Cedar)
|
|
12
|
+
embedded in your process, writing a local, value-free audit trail.
|
|
13
|
+
|
|
14
|
+
Same plugin, same agent code. Going to production is pointing at a policy
|
|
15
|
+
service — not a rewrite.
|
|
16
|
+
|
|
17
|
+
from watchlight.inprocess import in_process_backend
|
|
18
|
+
from watchlight_langgraph import WatchlightLangGraphPlugin
|
|
19
|
+
|
|
20
|
+
plugin = WatchlightLangGraphPlugin(
|
|
21
|
+
governance=in_process_backend("watchlight.policy.json")
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Most users never import this directly — the per-framework helpers
|
|
25
|
+
(``watchlight.langgraph.governed_plugin`` etc.) call it for you.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from typing import Any, Dict, List, Optional, Union
|
|
32
|
+
|
|
33
|
+
# A local policy source: a path to a JSON policy file, or an in-memory list of
|
|
34
|
+
# ``{"name", "code"}`` Cedar policy objects. ``None`` loads no policies —
|
|
35
|
+
# fail-closed, so every action is denied until a policy permits it.
|
|
36
|
+
Policies = Optional[Union[str, "os.PathLike[str]", List[Dict[str, Any]]]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def in_process_backend(
|
|
40
|
+
policies: Policies = None,
|
|
41
|
+
*,
|
|
42
|
+
audit_path: Optional[str] = ".watchlight/audit.jsonl",
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Build an in-process ``GovernanceBackend`` over the compiled engine.
|
|
45
|
+
|
|
46
|
+
:param policies: a path to a JSON policy file (a list of
|
|
47
|
+
``{"name", "code"}`` objects, or ``{"policies": [...]}``), or that list
|
|
48
|
+
in memory. ``None`` → no policies (fail-closed: everything denies).
|
|
49
|
+
:param audit_path: local JSONL lineage sink. Value-free — argument VALUES
|
|
50
|
+
never enter the trail, only the governance decision. Pass ``None`` to
|
|
51
|
+
disable the local audit file.
|
|
52
|
+
:returns: a ``watchlight_core.InProcessClient`` — pass it to any Watchlight
|
|
53
|
+
framework plugin via ``governance=``.
|
|
54
|
+
|
|
55
|
+
Requires the Watchlight SDK (installed transitively by any framework extra,
|
|
56
|
+
e.g. ``pip install 'watchlight[langgraph]'``).
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
from watchlight_core import InProcessClient
|
|
60
|
+
except ImportError as exc: # pragma: no cover - import-guard message
|
|
61
|
+
raise ImportError(
|
|
62
|
+
"in_process_backend requires the Watchlight SDK. Install a framework "
|
|
63
|
+
"extra, e.g. `pip install 'watchlight[langgraph]'`, or the SDK "
|
|
64
|
+
"directly: `pip install watchlight-agent-sdk`."
|
|
65
|
+
) from exc
|
|
66
|
+
return InProcessClient(policies, audit_path=audit_path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _select_backend_kwargs(
|
|
70
|
+
policies: Policies,
|
|
71
|
+
audit_path: Optional[str],
|
|
72
|
+
plugin_kwargs: Dict[str, Any],
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""Compute the constructor kwargs for a framework plugin: production when
|
|
75
|
+
``WATCHLIGHT_APDP_URL`` is set (networked APDP), in-process otherwise.
|
|
76
|
+
|
|
77
|
+
One environment variable flips dev↔prod with no code change — the shared
|
|
78
|
+
logic behind every ``watchlight.<framework>.governed_plugin`` helper.
|
|
79
|
+
"""
|
|
80
|
+
apdp_url = os.getenv("WATCHLIGHT_APDP_URL")
|
|
81
|
+
if apdp_url:
|
|
82
|
+
# Production: the plugin builds its own networked ApdpClient (which
|
|
83
|
+
# enforces channel safety). We only pass the URL through.
|
|
84
|
+
return {"apdp_url": apdp_url, **plugin_kwargs}
|
|
85
|
+
# Developer Edition: in-process engine, zero infra.
|
|
86
|
+
return {
|
|
87
|
+
"governance": in_process_backend(policies, audit_path=audit_path),
|
|
88
|
+
**plugin_kwargs,
|
|
89
|
+
}
|
watchlight/langgraph.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Govern a LangGraph agent in-process, with zero infrastructure.
|
|
2
|
+
|
|
3
|
+
from watchlight.langgraph import governed_plugin
|
|
4
|
+
|
|
5
|
+
plugin = governed_plugin("watchlight.policy.json")
|
|
6
|
+
async with await plugin.start_run("research-agent") as handle:
|
|
7
|
+
if not await handle.authorize_action("read", "tool/web_search"):
|
|
8
|
+
raise PermissionError("denied before it executed")
|
|
9
|
+
... # run the tool
|
|
10
|
+
|
|
11
|
+
The returned object is an ordinary ``WatchlightLangGraphPlugin`` — the SAME
|
|
12
|
+
plugin you ship to production. In the Developer Edition it is wired to the
|
|
13
|
+
in-process engine (local Cedar policies, local value-free audit). Set
|
|
14
|
+
``WATCHLIGHT_APDP_URL`` to a running policy service and the identical code runs
|
|
15
|
+
against production APDP — one environment variable, not a rewrite.
|
|
16
|
+
|
|
17
|
+
Requires the LangGraph extra: ``pip install 'watchlight[langgraph]'``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
from .inprocess import Policies, _select_backend_kwargs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def governed_plugin(
|
|
28
|
+
policies: Policies = None,
|
|
29
|
+
*,
|
|
30
|
+
audit_path: Optional[str] = ".watchlight/audit.jsonl",
|
|
31
|
+
**plugin_kwargs: Any,
|
|
32
|
+
) -> Any:
|
|
33
|
+
"""Return a governed ``WatchlightLangGraphPlugin``.
|
|
34
|
+
|
|
35
|
+
:param policies: local Cedar policies — a path to a JSON policy file or an
|
|
36
|
+
in-memory list of ``{"name", "code"}`` objects. ``None`` → fail-closed.
|
|
37
|
+
:param audit_path: local JSONL lineage sink (value-free). ``None`` disables.
|
|
38
|
+
:param plugin_kwargs: forwarded to ``WatchlightLangGraphPlugin`` (e.g.
|
|
39
|
+
``tenant_id``, ``log_decisions``).
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
from watchlight_langgraph import WatchlightLangGraphPlugin
|
|
43
|
+
except ImportError as exc: # pragma: no cover - import-guard message
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"governed LangGraph support requires the langgraph extra: "
|
|
46
|
+
"pip install 'watchlight[langgraph]'"
|
|
47
|
+
) from exc
|
|
48
|
+
return WatchlightLangGraphPlugin(
|
|
49
|
+
**_select_backend_kwargs(policies, audit_path, plugin_kwargs)
|
|
50
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Govern a Pydantic AI agent in-process, with zero infrastructure.
|
|
2
|
+
|
|
3
|
+
from watchlight.pydantic_ai import governed_plugin
|
|
4
|
+
|
|
5
|
+
plugin = governed_plugin("watchlight.policy.json")
|
|
6
|
+
async with await plugin.start_run("research-agent") as handle:
|
|
7
|
+
if not await handle.authorize_action("read", "tool/web_search"):
|
|
8
|
+
raise PermissionError("denied before it executed")
|
|
9
|
+
... # run the tool
|
|
10
|
+
|
|
11
|
+
The returned object is an ordinary ``WatchlightPydanticAIPlugin`` — the SAME
|
|
12
|
+
plugin you ship to production, wired here to the in-process engine (local Cedar
|
|
13
|
+
policies, local value-free audit). Set ``WATCHLIGHT_APDP_URL`` to a running
|
|
14
|
+
policy service and the identical code runs against production APDP — one
|
|
15
|
+
environment variable, not a rewrite.
|
|
16
|
+
|
|
17
|
+
Requires the Pydantic AI extra: ``pip install 'watchlight[pydantic-ai]'``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
from .inprocess import Policies, _select_backend_kwargs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def governed_plugin(
|
|
28
|
+
policies: Policies = None,
|
|
29
|
+
*,
|
|
30
|
+
audit_path: Optional[str] = ".watchlight/audit.jsonl",
|
|
31
|
+
**plugin_kwargs: Any,
|
|
32
|
+
) -> Any:
|
|
33
|
+
"""Return a governed ``WatchlightPydanticAIPlugin``.
|
|
34
|
+
|
|
35
|
+
:param policies: local Cedar policies — a path to a JSON policy file or an
|
|
36
|
+
in-memory list of ``{"name", "code"}`` objects. ``None`` → fail-closed.
|
|
37
|
+
:param audit_path: local JSONL lineage sink (value-free). ``None`` disables.
|
|
38
|
+
:param plugin_kwargs: forwarded to ``WatchlightPydanticAIPlugin`` (e.g.
|
|
39
|
+
``tenant_id``, ``auto_instrument``).
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
from watchlight_pydantic_ai import WatchlightPydanticAIPlugin
|
|
43
|
+
except ImportError as exc: # pragma: no cover - import-guard message
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"governed Pydantic AI support requires the pydantic-ai extra: "
|
|
46
|
+
"pip install 'watchlight[pydantic-ai]'"
|
|
47
|
+
) from exc
|
|
48
|
+
return WatchlightPydanticAIPlugin(
|
|
49
|
+
**_select_backend_kwargs(policies, audit_path, plugin_kwargs)
|
|
50
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchlight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Watchlight Developer Edition — govern AI agents in-process, with zero infrastructure.
|
|
5
|
+
Author: 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: authorization,ai-agents,cedar,policy,governance
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: watchlight-engine<0.2,>=0.1
|
|
13
|
+
Provides-Extra: langgraph
|
|
14
|
+
Requires-Dist: watchlight-langgraph>=0.4; extra == "langgraph"
|
|
15
|
+
Provides-Extra: pydantic-ai
|
|
16
|
+
Requires-Dist: watchlight-pydantic-ai>=0.3; extra == "pydantic-ai"
|
|
17
|
+
Provides-Extra: claude-agent
|
|
18
|
+
Requires-Dist: watchlight-claude-agent>=0.2; extra == "claude-agent"
|
|
19
|
+
Provides-Extra: mcp
|
|
20
|
+
Requires-Dist: watchlight-mcp>=0.1; extra == "mcp"
|
|
21
|
+
|
|
22
|
+
# Watchlight — Developer Edition
|
|
23
|
+
|
|
24
|
+
**Govern an AI agent in five minutes. One install, zero infrastructure, same API as production.**
|
|
25
|
+
|
|
26
|
+
Watchlight puts a policy decision point in front of every action your AI agents
|
|
27
|
+
take — authorizing tool calls, attenuating sub-agent authority to a strict
|
|
28
|
+
subset, and recording a tamper-evident, value-free audit trail. The Developer
|
|
29
|
+
Edition runs that *entire* authorization model **in-process**, so you can see it
|
|
30
|
+
work in your own terminal with no server, no database, and no signup.
|
|
31
|
+
|
|
32
|
+
The code you write here is the code you ship to production. Going to production
|
|
33
|
+
is pointing at a running policy service — not a rewrite.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
> **Status: in active development.** The target experience is below; see the
|
|
40
|
+
> [Developer Edition docs](https://docs.watchlight.ai/de) for the current state.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install watchlight
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from watchlight import govern
|
|
48
|
+
|
|
49
|
+
@govern.tool(intent="research")
|
|
50
|
+
def web_search(query: str) -> str:
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
@govern.tool(intent="transfer") # governed, but no policy permits it
|
|
54
|
+
def transfer_funds(to: str, amount: int) -> str:
|
|
55
|
+
...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
$ python agent.py
|
|
60
|
+
watchlight: governing 'my-agent' (dev mode, in-process engine)
|
|
61
|
+
watchlight: ALLOW read tool/web_search
|
|
62
|
+
watchlight: DENY execute tool/transfer_funds no matching policy
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**That `DENY` line — in your own terminal, in under five minutes, with no
|
|
66
|
+
account — is the product.**
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Already using a framework? Govern it in-process
|
|
71
|
+
|
|
72
|
+
Bring your existing **LangGraph**, **Pydantic AI**, or **Claude Agent SDK**
|
|
73
|
+
agent under governance with zero infrastructure — the *same* plugin you ship to
|
|
74
|
+
production, wired to the in-process engine:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install 'watchlight[langgraph]' # or [pydantic-ai], [claude-agent]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from watchlight.langgraph import governed_plugin # .pydantic_ai / .claude_agent
|
|
82
|
+
|
|
83
|
+
plugin = governed_plugin("watchlight.policy.json") # in-process, zero infra
|
|
84
|
+
|
|
85
|
+
async with await plugin.start_run("research-agent") as handle:
|
|
86
|
+
if not await handle.authorize_action("read", "tool/web_search"):
|
|
87
|
+
raise PermissionError("denied before it executed")
|
|
88
|
+
... # your tool runs, every action governed + recorded to .watchlight/audit.jsonl
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Going to production is one environment variable, not a rewrite — set
|
|
92
|
+
`WATCHLIGHT_APDP_URL` and the identical code authorizes against a running policy
|
|
93
|
+
service. Runnable examples for all three frameworks are in
|
|
94
|
+
[`examples/`](examples/).
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Watch every decision live — `watchlight dev`
|
|
99
|
+
|
|
100
|
+
A zero-dependency local dashboard that tails your value-free audit trail and
|
|
101
|
+
shows every governance decision as it happens — the ALLOWs, and the DENYs that
|
|
102
|
+
stopped a tool **before** it ran.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
watchlight dev # → http://127.0.0.1:7000
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Run your governed agent in another terminal and watch the decisions stream in.
|
|
109
|
+
It shows only *this* process — fleet-wide lineage, signed audit, and
|
|
110
|
+
drift→quarantine are the governed control plane (Enterprise).
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Govern an MCP server
|
|
115
|
+
|
|
116
|
+
Put a policy decision point in front of any [MCP](https://modelcontextprotocol.io)
|
|
117
|
+
server (spec `2026-07-28`). Every `tools/call` is authorized in-process **before**
|
|
118
|
+
it reaches the server — a denied call never executes.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install watchlight-mcp
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import watchlight_mcp
|
|
126
|
+
|
|
127
|
+
watchlight_mcp.serve(
|
|
128
|
+
listen_addr="127.0.0.1:9700",
|
|
129
|
+
upstream_url="http://localhost:3000/mcp", # the MCP server you're governing
|
|
130
|
+
upstream_server="github",
|
|
131
|
+
policy_files=["examples/mcp.policy.json"],
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Point your MCP client at `http://127.0.0.1:9700/mcp` instead of the server. A
|
|
136
|
+
self-contained, self-demonstrating example (it fires an allowed and a denied
|
|
137
|
+
call and proves the denied one never ran) is in
|
|
138
|
+
[`examples/governed_mcp_server.py`](examples/governed_mcp_server.py).
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## What runs locally
|
|
143
|
+
|
|
144
|
+
| Capability | Developer Edition (free / open) | Enterprise |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| Policy engine | in-process Cedar, policies from a local `.cedar` file | a running, scaled policy service |
|
|
147
|
+
| Sub-agent scope attenuation | engine-side strict-subset validation | same, server-side |
|
|
148
|
+
| Content / PII screening | policy-based, in-process | a running guardrails service |
|
|
149
|
+
| Audit | local JSONL, greppable, value-free | a signed, tamper-evident audit service |
|
|
150
|
+
| Dashboard | `watchlight dev` → `localhost:7000` (policies + execution lineage) | the full operator console |
|
|
151
|
+
|
|
152
|
+
Everything the Developer Edition removes is **infrastructure**, never a
|
|
153
|
+
**guarantee**. Fail-closed semantics, engine-side attenuation, explicit scopes,
|
|
154
|
+
and value-free audit are identical in every mode.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Progressive disclosure
|
|
159
|
+
|
|
160
|
+
Each level is one environment variable away from the next. **Nothing is
|
|
161
|
+
rewritten between levels.**
|
|
162
|
+
|
|
163
|
+
- **Level 0** — `pip install watchlight`. In-process engine, audit to stdout.
|
|
164
|
+
- **Level 1** — `watchlight dev`. Adds a local dashboard (decisions, denials, scope tree, execution lineage).
|
|
165
|
+
- **Level 2** — `docker compose up`. Real policy service + database; policies still from your local file.
|
|
166
|
+
- **Level 3** — Production. The full governed platform.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Developer Edition vs Enterprise
|
|
171
|
+
|
|
172
|
+
The Developer Edition is the **real engine** — free, open, and running
|
|
173
|
+
in-process so you can evaluate the entire authorization model on your laptop
|
|
174
|
+
with zero infrastructure. Enterprise is the **same code** pointed at the
|
|
175
|
+
governed control plane; it doesn't replace anything, it adds what a fleet in
|
|
176
|
+
production needs:
|
|
177
|
+
|
|
178
|
+
- **Signed, tamper-evident lineage & audit** — every decision and lineage event
|
|
179
|
+
cryptographically signed (KMS-backed), so the trail is court-defensible.
|
|
180
|
+
- **Multi-tenant isolation + roll-up administration** — tenant hierarchy, scoped
|
|
181
|
+
admins, and a cross-tenant authorization matrix.
|
|
182
|
+
- **Drift & anomaly detection → automatic quarantine** — behavioural,
|
|
183
|
+
goal-drift, and argument-shape detectors that quarantine a misbehaving agent
|
|
184
|
+
*before* the next action.
|
|
185
|
+
- **Full enforcement-effect taxonomy** — beyond allow/deny: block, terminate,
|
|
186
|
+
quarantine, sever-subtree, and revoke, enforced at runtime across the plane.
|
|
187
|
+
- **Fleet-wide revocation & cross-environment governance** — revoke authority
|
|
188
|
+
across every agent at once, and govern dev, staging, and prod under one
|
|
189
|
+
authority model (including **sovereign / air-gapped deployment**).
|
|
190
|
+
- **Content / PII guardrails service** and **global execution-graph lineage**
|
|
191
|
+
with the full **operator console**.
|
|
192
|
+
- **SSO / RBAC / enterprise audit**, high availability, support, and SLAs.
|
|
193
|
+
|
|
194
|
+
### You've outgrown the Developer Edition when…
|
|
195
|
+
|
|
196
|
+
- Compliance asks *"prove who authorized this in production"* → you need
|
|
197
|
+
**signed, tamper-evident lineage**.
|
|
198
|
+
- You're governing **more than one agent, or more than one environment** →
|
|
199
|
+
central policy lifecycle + the **global execution graph**.
|
|
200
|
+
- Security wants a misbehaving agent **stopped before its next action** →
|
|
201
|
+
**drift/anomaly detection → automatic quarantine**.
|
|
202
|
+
- You need to **revoke authority fleet-wide**, not process-by-process.
|
|
203
|
+
- Procurement needs **SSO, RBAC, HA, SLAs, or sovereign/air-gapped deployment**.
|
|
204
|
+
|
|
205
|
+
Each of these is a governance guarantee a single in-process engine structurally
|
|
206
|
+
cannot provide — it needs the control plane.
|
|
207
|
+
|
|
208
|
+
**Migrating is one environment variable — never a rewrite.** The tools you
|
|
209
|
+
decorate, the policies you write, and the guarantees you rely on
|
|
210
|
+
(fail-closed, engine-side attenuation, explicit scopes, value-free audit) are
|
|
211
|
+
identical in every mode. Enterprise simply points the same code at a running
|
|
212
|
+
plane.
|
|
213
|
+
|
|
214
|
+
> The engine ships as a compiled wheel and the Developer Edition is a deliberate
|
|
215
|
+
> *subset* of the platform — the governed control plane (signing, multi-tenant,
|
|
216
|
+
> guardrails, drift, execution-graph) is the enterprise product, never bundled
|
|
217
|
+
> here.
|
|
218
|
+
|
|
219
|
+
→ **[Talk to us about Enterprise](mailto:enterprise@watchlight.ai)** when you're
|
|
220
|
+
ready for production.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
Apache-2.0.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
watchlight/__init__.py,sha256=pvEVYBT1MluRWDBU6fd6nbkOX6vwlckwR7BJcPAGnW4,7001
|
|
2
|
+
watchlight/claude_agent.py,sha256=oZXBT9jJ8JJn3KKt2rmdGM6V06n1gzFe_PX-XJL_K8s,2025
|
|
3
|
+
watchlight/cli.py,sha256=-mBOTOByiCEMCmPzwXuArRVSGQIWbX5YlSghE493JYE,13145
|
|
4
|
+
watchlight/inprocess.py,sha256=L1bt_-QgkXMLT1NL6A5imVeVlxxAtfXY3GaOkxMxb0M,3809
|
|
5
|
+
watchlight/langgraph.py,sha256=ymJvg1zbJRTQRhEbObGpqPto0hLXNO2zXOFBjLGNQ4k,1998
|
|
6
|
+
watchlight/pydantic_ai.py,sha256=t1ixCUjzeRSHiCnl2cMdp0BTQTcY_I6cUQnimNu2DeQ,1995
|
|
7
|
+
watchlight-0.1.0.dist-info/METADATA,sha256=HvLXuJemBIMj2JDxQrJy662jIFIc3FuLMG8QkiXhDbQ,8755
|
|
8
|
+
watchlight-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
watchlight-0.1.0.dist-info/entry_points.txt,sha256=Kt25AFPFZcp5Z6w6YfbE7nqu_ougsi38bJqWK_2JM2Q,51
|
|
10
|
+
watchlight-0.1.0.dist-info/top_level.txt,sha256=BxOOJbx57PaqZINnHdkQshTYtpjIvtUl5vGULmVcUkg,11
|
|
11
|
+
watchlight-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
watchlight
|