bollard-ai 0.3.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.
- bollard_ai-0.3.0/PKG-INFO +75 -0
- bollard_ai-0.3.0/README.md +64 -0
- bollard_ai-0.3.0/bollard/__init__.py +114 -0
- bollard_ai-0.3.0/bollard/_apps.py +85 -0
- bollard_ai-0.3.0/bollard/_bootstrap/sitecustomize.py +32 -0
- bollard_ai-0.3.0/bollard/_context.py +36 -0
- bollard_ai-0.3.0/bollard/_patch.py +184 -0
- bollard_ai-0.3.0/bollard/_policy.py +126 -0
- bollard_ai-0.3.0/bollard/_reporter.py +94 -0
- bollard_ai-0.3.0/bollard/cli.py +78 -0
- bollard_ai-0.3.0/bollard_ai.egg-info/PKG-INFO +75 -0
- bollard_ai-0.3.0/bollard_ai.egg-info/SOURCES.txt +16 -0
- bollard_ai-0.3.0/bollard_ai.egg-info/dependency_links.txt +1 -0
- bollard_ai-0.3.0/bollard_ai.egg-info/entry_points.txt +2 -0
- bollard_ai-0.3.0/bollard_ai.egg-info/top_level.txt +1 -0
- bollard_ai-0.3.0/pyproject.toml +31 -0
- bollard_ai-0.3.0/setup.cfg +4 -0
- bollard_ai-0.3.0/tests/test_bollard.py +187 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bollard-ai
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: See, then govern, your AI agents — watch which apps they use, and enforce what they're allowed to do.
|
|
5
|
+
Author: Bollard AI
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://bollardai.com
|
|
8
|
+
Keywords: bollard,ai agents,governance,observability,security
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# bollard
|
|
13
|
+
|
|
14
|
+
The one line that lets Bollard **see, then govern** your AI agents.
|
|
15
|
+
|
|
16
|
+
Add it where your agents start up. By default it's **watch-only** — it notes which
|
|
17
|
+
apps your agents use (Notion, Slack, Gmail, Xero, Drive, paid AI models), never what
|
|
18
|
+
they read or write, and reports it to Bollard. When you protect an app in Bollard, the
|
|
19
|
+
same install starts routing *that app's* calls through Bollard so each agent can only
|
|
20
|
+
do what you allow — no code change. Every other app stays watch-only.
|
|
21
|
+
|
|
22
|
+
Three promises, enforced in code:
|
|
23
|
+
|
|
24
|
+
- **It can't break your agents.** Watch-only observes and, if anything goes wrong,
|
|
25
|
+
falls straight through to the real call. Enforcement is opt-in, per app, reversible.
|
|
26
|
+
- **It can't slow them down.** It reports in the background on its own thread.
|
|
27
|
+
- **It never sees your data or code.** It reads only the *envelope* of each call — the
|
|
28
|
+
destination and operation name. There is no code path that reads a request or
|
|
29
|
+
response body (see `bollard/_apps.py`).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install bollard-ai
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use it
|
|
38
|
+
|
|
39
|
+
**One shared codebase** — wrap the command, no code change:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bollard watch --key BWATCH-XXXX-XXXX-XXXX -- python your_agents.py
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Separate projects** — add one line where each agent starts up:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import bollard
|
|
49
|
+
bollard.start(key="BWATCH-XXXX-XXXX-XXXX", agent="sales-follow-up")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Enforcement (opt-in)
|
|
53
|
+
|
|
54
|
+
Watching needs only your workspace key. To let Bollard *enforce* an agent (once you've
|
|
55
|
+
protected an app in the dashboard), give that agent its own identity:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
bollard.start(key="BWATCH-...", agent="sales-follow-up", agent_token="boll_agt_...")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
With an `agent_token`, calls to any **protected** app are routed through Bollard, which
|
|
62
|
+
allows or blocks them against that agent's permissions. Apps you haven't protected stay
|
|
63
|
+
watch-only. No `agent_token` → always watch-only. If Bollard can't be reached to confirm
|
|
64
|
+
the policy, the agent stays watch-only — it never starts blocking off an unconfirmed rule.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
`start()` reads these when the arguments are omitted:
|
|
69
|
+
|
|
70
|
+
| Variable | Meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `BOLLARD_KEY` | your workspace key (`BWATCH-...`) |
|
|
73
|
+
| `BOLLARD_AGENT` | optional name for this agent |
|
|
74
|
+
| `BOLLARD_AGENT_TOKEN` | this agent's identity (`boll_agt_...`), for enforcement |
|
|
75
|
+
| `BOLLARD_ENDPOINT` | override Bollard's base address (self-hosting / testing) |
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# bollard
|
|
2
|
+
|
|
3
|
+
The one line that lets Bollard **see, then govern** your AI agents.
|
|
4
|
+
|
|
5
|
+
Add it where your agents start up. By default it's **watch-only** — it notes which
|
|
6
|
+
apps your agents use (Notion, Slack, Gmail, Xero, Drive, paid AI models), never what
|
|
7
|
+
they read or write, and reports it to Bollard. When you protect an app in Bollard, the
|
|
8
|
+
same install starts routing *that app's* calls through Bollard so each agent can only
|
|
9
|
+
do what you allow — no code change. Every other app stays watch-only.
|
|
10
|
+
|
|
11
|
+
Three promises, enforced in code:
|
|
12
|
+
|
|
13
|
+
- **It can't break your agents.** Watch-only observes and, if anything goes wrong,
|
|
14
|
+
falls straight through to the real call. Enforcement is opt-in, per app, reversible.
|
|
15
|
+
- **It can't slow them down.** It reports in the background on its own thread.
|
|
16
|
+
- **It never sees your data or code.** It reads only the *envelope* of each call — the
|
|
17
|
+
destination and operation name. There is no code path that reads a request or
|
|
18
|
+
response body (see `bollard/_apps.py`).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install bollard-ai
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Use it
|
|
27
|
+
|
|
28
|
+
**One shared codebase** — wrap the command, no code change:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bollard watch --key BWATCH-XXXX-XXXX-XXXX -- python your_agents.py
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Separate projects** — add one line where each agent starts up:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import bollard
|
|
38
|
+
bollard.start(key="BWATCH-XXXX-XXXX-XXXX", agent="sales-follow-up")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Enforcement (opt-in)
|
|
42
|
+
|
|
43
|
+
Watching needs only your workspace key. To let Bollard *enforce* an agent (once you've
|
|
44
|
+
protected an app in the dashboard), give that agent its own identity:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
bollard.start(key="BWATCH-...", agent="sales-follow-up", agent_token="boll_agt_...")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
With an `agent_token`, calls to any **protected** app are routed through Bollard, which
|
|
51
|
+
allows or blocks them against that agent's permissions. Apps you haven't protected stay
|
|
52
|
+
watch-only. No `agent_token` → always watch-only. If Bollard can't be reached to confirm
|
|
53
|
+
the policy, the agent stays watch-only — it never starts blocking off an unconfirmed rule.
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
`start()` reads these when the arguments are omitted:
|
|
58
|
+
|
|
59
|
+
| Variable | Meaning |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `BOLLARD_KEY` | your workspace key (`BWATCH-...`) |
|
|
62
|
+
| `BOLLARD_AGENT` | optional name for this agent |
|
|
63
|
+
| `BOLLARD_AGENT_TOKEN` | this agent's identity (`boll_agt_...`), for enforcement |
|
|
64
|
+
| `BOLLARD_ENDPOINT` | override Bollard's base address (self-hosting / testing) |
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""bollard — the one line that lets Bollard see, then govern, your AI agents.
|
|
2
|
+
|
|
3
|
+
import bollard
|
|
4
|
+
bollard.start(key="BWATCH-...")
|
|
5
|
+
|
|
6
|
+
By default it's **watch-only**: it notes which apps your agents use (never what they
|
|
7
|
+
read or write) and reports them to Bollard, changing nothing. It runs alongside your
|
|
8
|
+
agent and fails silently, so it can't break your agents or slow them down.
|
|
9
|
+
|
|
10
|
+
When you protect an app in Bollard *and* give an agent its own identity
|
|
11
|
+
(`agent_token`), the same install starts routing that app's calls through Bollard so
|
|
12
|
+
that agent can only do what you allow — no code change. Every other app, and every
|
|
13
|
+
other agent, stays watch-only. Protection is opt-in, per agent, per app, and reversible
|
|
14
|
+
from the dashboard.
|
|
15
|
+
|
|
16
|
+
bollard.start(key="BWATCH-...", agent="sales-bot", agent_token="boll_agt_...")
|
|
17
|
+
|
|
18
|
+
If several agents run in the *same* process (a shared codebase), the package can't tell
|
|
19
|
+
them apart on its own — mark each agent's run so calls are attributed (and enforced) to
|
|
20
|
+
the right one:
|
|
21
|
+
|
|
22
|
+
with bollard.agent("sales-follow-up", token="boll_agt_..."):
|
|
23
|
+
run_the_agent()
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from ._context import agent # noqa: F401 (public: the per-agent marker)
|
|
31
|
+
from ._patch import install as _install, uninstall as _uninstall
|
|
32
|
+
from ._policy import Policy
|
|
33
|
+
from ._reporter import Reporter
|
|
34
|
+
|
|
35
|
+
__version__ = "0.3.0"
|
|
36
|
+
|
|
37
|
+
_DEFAULT_ENDPOINT = "https://api.bollardai.com"
|
|
38
|
+
|
|
39
|
+
_reporter: Optional[Reporter] = None
|
|
40
|
+
_policy: Optional[Policy] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def start(key: Optional[str] = None, *, agent: Optional[str] = None,
|
|
44
|
+
agent_token: Optional[str] = None, endpoint: Optional[str] = None,
|
|
45
|
+
enforce: bool = True) -> None:
|
|
46
|
+
"""Switch Bollard on. Safe to call once at startup; a second call is a no-op.
|
|
47
|
+
|
|
48
|
+
key: your workspace key (starts with "BWATCH-"). Falls back to BOLLARD_KEY.
|
|
49
|
+
agent: optional name for this agent (sharpens attribution). Falls back to
|
|
50
|
+
BOLLARD_AGENT.
|
|
51
|
+
agent_token: this agent's own identity (starts with "boll_agt_"). Required for
|
|
52
|
+
enforcement; omit it and the agent stays watch-only. Falls back to
|
|
53
|
+
BOLLARD_AGENT_TOKEN.
|
|
54
|
+
endpoint: override Bollard's base address (self-hosting / testing). Falls back
|
|
55
|
+
to BOLLARD_ENDPOINT.
|
|
56
|
+
enforce: set False to force watch-only even where an app is protected.
|
|
57
|
+
"""
|
|
58
|
+
global _reporter, _policy
|
|
59
|
+
if _reporter is not None:
|
|
60
|
+
return # already on
|
|
61
|
+
|
|
62
|
+
key = key or os.environ.get("BOLLARD_KEY") or os.environ.get("BOLLARD_WATCH_KEY")
|
|
63
|
+
if not key:
|
|
64
|
+
return # nothing to do — never raise a misconfigured watcher into the agent
|
|
65
|
+
agent = agent or os.environ.get("BOLLARD_AGENT") or _default_agent_hint()
|
|
66
|
+
agent_token = agent_token or os.environ.get("BOLLARD_AGENT_TOKEN")
|
|
67
|
+
base = (endpoint or os.environ.get("BOLLARD_ENDPOINT")
|
|
68
|
+
or os.environ.get("BOLLARD_WATCH_ENDPOINT") or _DEFAULT_ENDPOINT).rstrip("/")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
reporter = Reporter(key=key, signal_url=base + "/watch/signal", agent=agent)
|
|
72
|
+
reporter.start()
|
|
73
|
+
policy = Policy(policy_url=base + "/watch/policy", gateway_base=base)
|
|
74
|
+
token = agent_token if enforce else None
|
|
75
|
+
if enforce:
|
|
76
|
+
# Fetch the process agent's policy up front (live from the first call), then
|
|
77
|
+
# run the background refresher — which also lazily fetches any agents marked
|
|
78
|
+
# mid-run with bollard.agent(name, token=...). Purely-watch installs (no token,
|
|
79
|
+
# no markers) make no policy calls: nothing is ever registered to fetch.
|
|
80
|
+
policy.prime(token)
|
|
81
|
+
policy.start()
|
|
82
|
+
_install(reporter, policy, agent, token)
|
|
83
|
+
_reporter, _policy = reporter, policy
|
|
84
|
+
except Exception:
|
|
85
|
+
_reporter = _policy = None # any setup failure = stay out of the way entirely
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def stop() -> None:
|
|
89
|
+
"""Stop and remove the hooks. Rarely needed; mainly for tests."""
|
|
90
|
+
global _reporter, _policy
|
|
91
|
+
_uninstall()
|
|
92
|
+
if _policy is not None:
|
|
93
|
+
try:
|
|
94
|
+
_policy.stop()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
_policy = None
|
|
98
|
+
if _reporter is not None:
|
|
99
|
+
try:
|
|
100
|
+
_reporter.flush_and_stop()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
_reporter = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _default_agent_hint() -> Optional[str]:
|
|
107
|
+
try:
|
|
108
|
+
import sys
|
|
109
|
+
base = os.path.basename(sys.argv[0] or "") or "agent"
|
|
110
|
+
base = base.rsplit(".py", 1)[0]
|
|
111
|
+
host = os.environ.get("HOSTNAME") or ""
|
|
112
|
+
return f"{base}@{host}" if host else base or None
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Envelope reading — turn a call's *destination* into "which app, which operation".
|
|
2
|
+
|
|
3
|
+
We look at three things and nothing else: the destination host, the request method,
|
|
4
|
+
and the URL path. We NEVER read the request body, the response body, headers, query
|
|
5
|
+
strings, or any credential. "Envelope, never the letter." Pure and unit-tested, so
|
|
6
|
+
the safety rule is verifiable — there is no code path that reads a payload.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
_HOST_APP: list[tuple[str, str]] = [
|
|
13
|
+
("api.notion.com", "notion"),
|
|
14
|
+
("slack.com", "slack"),
|
|
15
|
+
("gmail.googleapis.com", "gmail"),
|
|
16
|
+
("api.xero.com", "xero"),
|
|
17
|
+
("identity.xero.com", "xero"),
|
|
18
|
+
("www.googleapis.com", "google"),
|
|
19
|
+
("googleapis.com", "google"),
|
|
20
|
+
("api.anthropic.com", "ai"),
|
|
21
|
+
("api.openai.com", "ai"),
|
|
22
|
+
("api.mistral.ai", "ai"),
|
|
23
|
+
("generativelanguage.googleapis.com", "ai"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def app_for_host(host: Optional[str], path: str = "") -> Optional[str]:
|
|
28
|
+
"""The app slug for a destination host, or None if we don't recognise it."""
|
|
29
|
+
if not host:
|
|
30
|
+
return None
|
|
31
|
+
host = host.lower().strip(".")
|
|
32
|
+
app = None
|
|
33
|
+
for suffix, slug in _HOST_APP:
|
|
34
|
+
if host == suffix or host.endswith("." + suffix):
|
|
35
|
+
app = slug
|
|
36
|
+
break
|
|
37
|
+
if app is None:
|
|
38
|
+
return None
|
|
39
|
+
if app == "google":
|
|
40
|
+
p = path.lower()
|
|
41
|
+
if "/drive/" in p or p.startswith("/upload/drive"):
|
|
42
|
+
return "drive"
|
|
43
|
+
if "/gmail/" in p:
|
|
44
|
+
return "gmail"
|
|
45
|
+
if "/calendar/" in p:
|
|
46
|
+
return "calendar"
|
|
47
|
+
return "google"
|
|
48
|
+
return app
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def object_for(app: str, method: str, path: str) -> Optional[str]:
|
|
52
|
+
"""A short operation label taken from the PATH only. Never reads the body."""
|
|
53
|
+
segs = [s for s in path.strip("/").split("/") if s]
|
|
54
|
+
if not segs:
|
|
55
|
+
return None
|
|
56
|
+
if app == "slack":
|
|
57
|
+
for s in segs:
|
|
58
|
+
if "." in s and s not in ("api",):
|
|
59
|
+
return s
|
|
60
|
+
return None
|
|
61
|
+
if app == "notion":
|
|
62
|
+
kinds = {"databases", "pages", "blocks", "users", "comments", "search", "data_sources"}
|
|
63
|
+
for s in segs:
|
|
64
|
+
if s in kinds:
|
|
65
|
+
return s
|
|
66
|
+
return None
|
|
67
|
+
if app in ("gmail", "google", "calendar"):
|
|
68
|
+
tail = [s for s in segs if s not in ("v1", "v3", "gmail", "users", "me")]
|
|
69
|
+
if tail:
|
|
70
|
+
return ".".join(tail[-2:]) if len(tail) >= 2 else tail[-1]
|
|
71
|
+
return None
|
|
72
|
+
if app == "drive":
|
|
73
|
+
for s in segs:
|
|
74
|
+
if s == "files":
|
|
75
|
+
return "files"
|
|
76
|
+
return None
|
|
77
|
+
if app == "xero":
|
|
78
|
+
tail = [s for s in segs if s not in ("api.xro", "2.0", "connections")]
|
|
79
|
+
return tail[-1] if tail else None
|
|
80
|
+
if app == "ai":
|
|
81
|
+
tail = [s for s in segs if s != "v1"]
|
|
82
|
+
if tail:
|
|
83
|
+
return ".".join(tail[-2:]) if len(tail) >= 2 else tail[-1]
|
|
84
|
+
return None
|
|
85
|
+
return None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Auto-start shim for the shared-codebase install (`bollard watch --key ... -- cmd`).
|
|
2
|
+
|
|
3
|
+
Python imports a module named `sitecustomize` automatically at interpreter start-up
|
|
4
|
+
if one is importable. The `bollard watch` CLI puts this directory on PYTHONPATH for
|
|
5
|
+
the child process, so the agent starts up under Bollard without a code change.
|
|
6
|
+
|
|
7
|
+
If the child already had its own `sitecustomize` elsewhere on the path, we run that
|
|
8
|
+
one too (by file, without touching sys.modules), so we never disable someone else's
|
|
9
|
+
startup hook.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
try: # pragma: no cover - environment dependent
|
|
15
|
+
_here = os.path.abspath(os.path.dirname(__file__))
|
|
16
|
+
for _dir in sys.path:
|
|
17
|
+
try:
|
|
18
|
+
_cand = os.path.join(_dir, "sitecustomize.py")
|
|
19
|
+
if os.path.abspath(_dir) != _here and os.path.isfile(_cand):
|
|
20
|
+
with open(_cand, "r", encoding="utf-8") as _f:
|
|
21
|
+
exec(compile(_f.read(), _cand, "exec"), {"__name__": "sitecustomize", "__file__": _cand})
|
|
22
|
+
break
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
try: # pragma: no cover - exercised via the CLI, not unit tests
|
|
29
|
+
import bollard
|
|
30
|
+
bollard.start()
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Which agent is acting right now.
|
|
2
|
+
|
|
3
|
+
In a shared codebase, many agents run in the same process, so the package can't tell
|
|
4
|
+
them apart just by watching calls. The library marks each agent's run:
|
|
5
|
+
|
|
6
|
+
with bollard.agent("sales-follow-up", token="boll_agt_..."):
|
|
7
|
+
run_the_agent()
|
|
8
|
+
|
|
9
|
+
Every call made inside that block is attributed to (and, if the app is protected,
|
|
10
|
+
enforced as) that agent. Outside any marker, the package falls back to the
|
|
11
|
+
process-level identity passed to `start()`.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextvars
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import Iterator, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
# (name, token) for the agent currently acting on this call path, or None.
|
|
20
|
+
_current: "contextvars.ContextVar[Optional[Tuple[Optional[str], Optional[str]]]]" = \
|
|
21
|
+
contextvars.ContextVar("bollard_agent", default=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def current() -> Optional[Tuple[Optional[str], Optional[str]]]:
|
|
25
|
+
return _current.get()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def agent(name: str, token: Optional[str] = None) -> Iterator[None]:
|
|
30
|
+
"""Mark the enclosed work as done by `name`. Pass `token` (the agent's identity)
|
|
31
|
+
to make it enforceable. Nestable and safe across threads/async tasks."""
|
|
32
|
+
reset = _current.set((name, token))
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
_current.reset(reset)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""HTTP interception — watch always, enforce when told to, per acting agent.
|
|
2
|
+
|
|
3
|
+
For every outbound call we read only the method + URL (envelope), work out *which agent
|
|
4
|
+
is acting* (a `bollard.agent(...)` marker if one is set, otherwise the process-level
|
|
5
|
+
identity), report it (watch), and then:
|
|
6
|
+
|
|
7
|
+
* if the app is **watched** (the default): let the call go straight through, untouched.
|
|
8
|
+
* if the app is **enforced for this agent** (Bollard's policy says so) AND this agent
|
|
9
|
+
has an identity token: rewrite the call to go through Bollard's checkpoint (the
|
|
10
|
+
gateway) instead of straight to the app, so Bollard can allow or block it against
|
|
11
|
+
that agent's permissions.
|
|
12
|
+
|
|
13
|
+
Two hard safety rules:
|
|
14
|
+
1. If anything in our wrapper raises, we fall through to the ORIGINAL call — the watcher
|
|
15
|
+
can never break what the agent was going to do.
|
|
16
|
+
2. We only ever re-route when the policy positively says "enforce" for the acting agent.
|
|
17
|
+
Absent/again-unsure/unidentified agent = watch = untouched. We never enforce on a
|
|
18
|
+
guessed identity.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Optional, Tuple
|
|
23
|
+
from urllib.parse import urlsplit
|
|
24
|
+
|
|
25
|
+
from ._apps import app_for_host, object_for
|
|
26
|
+
from ._context import current
|
|
27
|
+
from ._policy import Policy
|
|
28
|
+
from ._reporter import Reporter
|
|
29
|
+
|
|
30
|
+
_installed = False
|
|
31
|
+
_originals: dict = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _acting(default_agent: Optional[str], default_token: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
35
|
+
"""Who is acting on this call: the marked agent if inside a `bollard.agent(...)`
|
|
36
|
+
block, else the process-level identity. A marker with no token is watch-only for
|
|
37
|
+
that agent (we won't enforce it under someone else's identity)."""
|
|
38
|
+
ctx = current()
|
|
39
|
+
if ctx is not None:
|
|
40
|
+
name, token = ctx
|
|
41
|
+
return (name or default_agent), token
|
|
42
|
+
return default_agent, default_token
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _report(reporter: Reporter, app: str, method: str, path: str, agent: Optional[str]) -> None:
|
|
46
|
+
try:
|
|
47
|
+
reporter.record(app=app, object=object_for(app, method, path), method=method, agent=agent)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _gateway_url(gateway_base: str, app: str, path: str, strip_prefix: str, query: str) -> str:
|
|
53
|
+
tail = path
|
|
54
|
+
if strip_prefix and tail.startswith(strip_prefix):
|
|
55
|
+
tail = tail[len(strip_prefix):]
|
|
56
|
+
if not tail.startswith("/"):
|
|
57
|
+
tail = "/" + tail
|
|
58
|
+
url = f"{gateway_base}/gw/{app}{tail}"
|
|
59
|
+
return url + ("?" + query if query else "")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install(reporter: Reporter, policy: Policy,
|
|
63
|
+
default_agent: Optional[str], default_token: Optional[str]) -> None:
|
|
64
|
+
global _installed
|
|
65
|
+
if _installed:
|
|
66
|
+
return
|
|
67
|
+
_installed = True
|
|
68
|
+
_patch_httpx(reporter, policy, default_agent, default_token)
|
|
69
|
+
_patch_requests(reporter, policy, default_agent, default_token)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def uninstall() -> None:
|
|
73
|
+
global _installed
|
|
74
|
+
if not _installed:
|
|
75
|
+
return
|
|
76
|
+
for (owner, name), original in _originals.items():
|
|
77
|
+
try:
|
|
78
|
+
setattr(owner, name, original)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
_originals.clear()
|
|
82
|
+
_installed = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _enforced_app(policy: Policy, token: Optional[str], host: Optional[str], path: str):
|
|
86
|
+
"""Return (app, rule) if this call should be enforced for the acting agent, else
|
|
87
|
+
(app, None). Registers the agent's token so its policy gets fetched."""
|
|
88
|
+
app = app_for_host(host, path)
|
|
89
|
+
if not app:
|
|
90
|
+
return None, None
|
|
91
|
+
if not token:
|
|
92
|
+
return app, None
|
|
93
|
+
policy.ensure(token)
|
|
94
|
+
rule = policy.rule_for(app, token)
|
|
95
|
+
return app, (rule if rule.mode == "enforce" else None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _patch_httpx(reporter: Reporter, policy: Policy,
|
|
99
|
+
default_agent: Optional[str], default_token: Optional[str]) -> None:
|
|
100
|
+
try:
|
|
101
|
+
import httpx
|
|
102
|
+
except Exception:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
def reroute_request(request):
|
|
106
|
+
"""Build a new httpx.Request pointed at the gateway, or None to leave as-is."""
|
|
107
|
+
try:
|
|
108
|
+
agent, token = _acting(default_agent, default_token)
|
|
109
|
+
url = request.url
|
|
110
|
+
app, rule = _enforced_app(policy, token, url.host, url.path)
|
|
111
|
+
if app:
|
|
112
|
+
_report(reporter, app, request.method, url.path, agent)
|
|
113
|
+
if not (app and rule and token):
|
|
114
|
+
return None
|
|
115
|
+
new_url = _gateway_url(policy.gateway_base, app, url.path, rule.strip_prefix,
|
|
116
|
+
url.query.decode() if isinstance(url.query, bytes) else (url.query or ""))
|
|
117
|
+
headers = [(k, v) for k, v in request.headers.raw
|
|
118
|
+
if k.lower() not in (b"authorization", b"host", b"content-length")]
|
|
119
|
+
headers.append((b"authorization", ("Bearer " + token).encode())) # type: ignore[arg-type]
|
|
120
|
+
return httpx.Request(request.method, new_url, headers=headers, content=request.content)
|
|
121
|
+
except Exception:
|
|
122
|
+
return None # fail-open: never break the call
|
|
123
|
+
|
|
124
|
+
def wrap(cls, is_async: bool):
|
|
125
|
+
original = cls.send
|
|
126
|
+
_originals[(cls, "send")] = original
|
|
127
|
+
if is_async:
|
|
128
|
+
async def send(self, request, *args, **kwargs):
|
|
129
|
+
new = None
|
|
130
|
+
try:
|
|
131
|
+
new = reroute_request(request)
|
|
132
|
+
except Exception:
|
|
133
|
+
new = None
|
|
134
|
+
return await original(self, new or request, *args, **kwargs)
|
|
135
|
+
else:
|
|
136
|
+
def send(self, request, *args, **kwargs):
|
|
137
|
+
new = None
|
|
138
|
+
try:
|
|
139
|
+
new = reroute_request(request)
|
|
140
|
+
except Exception:
|
|
141
|
+
new = None
|
|
142
|
+
return original(self, new or request, *args, **kwargs)
|
|
143
|
+
try:
|
|
144
|
+
cls.send = send
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
wrap(httpx.Client, is_async=False)
|
|
149
|
+
wrap(httpx.AsyncClient, is_async=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _patch_requests(reporter: Reporter, policy: Policy,
|
|
153
|
+
default_agent: Optional[str], default_token: Optional[str]) -> None:
|
|
154
|
+
try:
|
|
155
|
+
import requests
|
|
156
|
+
except Exception:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
session = requests.sessions.Session
|
|
160
|
+
original = session.request
|
|
161
|
+
_originals[(session, "request")] = original
|
|
162
|
+
|
|
163
|
+
def request(self, method, url, *args, **kwargs):
|
|
164
|
+
try:
|
|
165
|
+
agent, token = _acting(default_agent, default_token)
|
|
166
|
+
parts = urlsplit(url)
|
|
167
|
+
app, rule = _enforced_app(policy, token, parts.hostname, parts.path)
|
|
168
|
+
if app:
|
|
169
|
+
_report(reporter, app, str(method).upper(), parts.path, agent)
|
|
170
|
+
if app and rule and token:
|
|
171
|
+
new_url = _gateway_url(policy.gateway_base, app, parts.path, rule.strip_prefix, parts.query)
|
|
172
|
+
headers = dict(kwargs.get("headers") or {})
|
|
173
|
+
headers = {k: v for k, v in headers.items() if k.lower() not in ("authorization", "host", "content-length")}
|
|
174
|
+
headers["Authorization"] = "Bearer " + token
|
|
175
|
+
kwargs["headers"] = headers
|
|
176
|
+
url = new_url
|
|
177
|
+
except Exception:
|
|
178
|
+
pass # fail-open
|
|
179
|
+
return original(self, method, url, *args, **kwargs)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
session.request = request
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Enforcement policy — which apps are protected, *for a given agent*.
|
|
2
|
+
|
|
3
|
+
Protection is per-agent-per-app: "the sales-follow-up agent is protected in Notion"
|
|
4
|
+
is a different fact from "the research agent is protected in Notion". So the policy is
|
|
5
|
+
keyed by the agent's own identity token: the package asks Bollard, for each agent
|
|
6
|
+
identity it sees act, "which apps should I route through you for this agent?"
|
|
7
|
+
|
|
8
|
+
* The process-level agent (passed to `start(agent_token=...)`) is fetched up front,
|
|
9
|
+
so its enforcement is live from the first call.
|
|
10
|
+
* Agents marked mid-run with `bollard.agent(name, token=...)` are fetched lazily the
|
|
11
|
+
first time they act, then refreshed on the same cycle.
|
|
12
|
+
|
|
13
|
+
Safety rule: if we can't reach Bollard, or anything looks off, we DEFAULT TO WATCH.
|
|
14
|
+
We never start blocking an agent's calls off the back of a policy we couldn't confirm —
|
|
15
|
+
enforcement only kicks in when Bollard positively tells us an app is protected for that
|
|
16
|
+
agent. An agent we've never managed to fetch a policy for stays watch-only.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import Dict, Optional
|
|
24
|
+
|
|
25
|
+
_TIMEOUT = 4.0
|
|
26
|
+
_REFRESH_SECONDS = 60.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AppRule:
|
|
30
|
+
__slots__ = ("mode", "strip_prefix")
|
|
31
|
+
|
|
32
|
+
def __init__(self, mode: str, strip_prefix: str) -> None:
|
|
33
|
+
self.mode = mode # "watch" | "enforce"
|
|
34
|
+
self.strip_prefix = strip_prefix # path prefix the gateway already re-adds (e.g. "/v1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_WATCH = AppRule("watch", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Policy:
|
|
41
|
+
def __init__(self, *, policy_url: str, gateway_base: str) -> None:
|
|
42
|
+
self._url = policy_url
|
|
43
|
+
self._gateway_base = gateway_base.rstrip("/")
|
|
44
|
+
self._rules: Dict[str, Dict[str, AppRule]] = {} # agent_token -> {app: AppRule}
|
|
45
|
+
self._pending: set = set() # tokens awaiting a first fetch
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
self._stop = threading.Event()
|
|
48
|
+
self._wake = threading.Event()
|
|
49
|
+
self._thread: Optional[threading.Thread] = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def gateway_base(self) -> str:
|
|
53
|
+
return self._gateway_base
|
|
54
|
+
|
|
55
|
+
def ensure(self, token: Optional[str]) -> None:
|
|
56
|
+
"""Register an agent token so its policy gets fetched (lazily, off the hot path).
|
|
57
|
+
Cheap and idempotent — safe to call on every request."""
|
|
58
|
+
if not token:
|
|
59
|
+
return
|
|
60
|
+
with self._lock:
|
|
61
|
+
if token in self._rules or token in self._pending:
|
|
62
|
+
return
|
|
63
|
+
self._pending.add(token)
|
|
64
|
+
self._wake.set()
|
|
65
|
+
|
|
66
|
+
def rule_for(self, app: str, token: Optional[str]) -> AppRule:
|
|
67
|
+
"""The rule for this app *for this agent*. Unknown agent/app = watch (safe)."""
|
|
68
|
+
if not token:
|
|
69
|
+
return _WATCH
|
|
70
|
+
with self._lock:
|
|
71
|
+
return self._rules.get(token, {}).get(app, _WATCH)
|
|
72
|
+
|
|
73
|
+
def prime(self, token: Optional[str]) -> None:
|
|
74
|
+
"""Fetch one agent's policy synchronously (used for the process-level agent so its
|
|
75
|
+
enforcement is live before the first call)."""
|
|
76
|
+
if token:
|
|
77
|
+
self._fetch(token)
|
|
78
|
+
|
|
79
|
+
def start(self) -> None:
|
|
80
|
+
self._thread = threading.Thread(target=self._loop, name="bollard-policy", daemon=True)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
|
|
83
|
+
def stop(self) -> None:
|
|
84
|
+
self._stop.set()
|
|
85
|
+
self._wake.set()
|
|
86
|
+
|
|
87
|
+
def _loop(self) -> None:
|
|
88
|
+
while not self._stop.is_set():
|
|
89
|
+
# 1) fetch any newly-seen agents promptly
|
|
90
|
+
with self._lock:
|
|
91
|
+
pending = list(self._pending)
|
|
92
|
+
self._pending.clear()
|
|
93
|
+
for token in pending:
|
|
94
|
+
if self._stop.is_set():
|
|
95
|
+
return
|
|
96
|
+
self._fetch(token)
|
|
97
|
+
# 2) wait for the refresh interval, but wake early if a new agent appears
|
|
98
|
+
if self._wake.wait(_REFRESH_SECONDS):
|
|
99
|
+
self._wake.clear()
|
|
100
|
+
continue
|
|
101
|
+
# 3) interval elapsed with nothing new → refresh everyone we know
|
|
102
|
+
with self._lock:
|
|
103
|
+
known = list(self._rules.keys())
|
|
104
|
+
for token in known:
|
|
105
|
+
if self._stop.is_set():
|
|
106
|
+
return
|
|
107
|
+
self._fetch(token)
|
|
108
|
+
|
|
109
|
+
def _fetch(self, token: str) -> None:
|
|
110
|
+
try:
|
|
111
|
+
req = urllib.request.Request(
|
|
112
|
+
self._url, headers={"User-Agent": "bollard", "Authorization": "Bearer " + token})
|
|
113
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
|
|
114
|
+
data = json.loads(r.read().decode())
|
|
115
|
+
gateway = (data.get("gateway_base") or self._gateway_base).rstrip("/")
|
|
116
|
+
rules: Dict[str, AppRule] = {}
|
|
117
|
+
for app, spec in (data.get("apps") or {}).items():
|
|
118
|
+
if isinstance(spec, dict) and spec.get("mode") == "enforce":
|
|
119
|
+
rules[app] = AppRule("enforce", str(spec.get("strip_prefix") or ""))
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._gateway_base = gateway
|
|
122
|
+
self._rules[token] = rules
|
|
123
|
+
self._pending.discard(token)
|
|
124
|
+
except Exception:
|
|
125
|
+
# Unreachable / malformed → leave this agent as-is (unknown = watch).
|
|
126
|
+
pass
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Background reporter — ships watch signals to Bollard without making the agent wait.
|
|
2
|
+
|
|
3
|
+
The hot path only does `reporter.record(...)`, which drops a small tuple on a queue
|
|
4
|
+
and returns. A daemon thread drains it, batches, and POSTs on its own time with a
|
|
5
|
+
short timeout. Unreachable Bollard = signals dropped, never retried forever, never
|
|
6
|
+
surfaced. Envelope facts only — app slug, operation label, method, agent hint.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import json
|
|
12
|
+
import queue
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
_MAX_QUEUE = 1000
|
|
20
|
+
_BATCH = 50
|
|
21
|
+
_FLUSH_SECONDS = 2.0
|
|
22
|
+
_TIMEOUT = 3.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Reporter:
|
|
26
|
+
def __init__(self, *, key: str, signal_url: str, agent: Optional[str]) -> None:
|
|
27
|
+
self._key = key
|
|
28
|
+
self._url = signal_url
|
|
29
|
+
self._agent = agent
|
|
30
|
+
self._q: "queue.Queue[dict]" = queue.Queue(maxsize=_MAX_QUEUE)
|
|
31
|
+
self._stop = threading.Event()
|
|
32
|
+
self._thread = threading.Thread(target=self._run, name="bollard", daemon=True)
|
|
33
|
+
self._started = False
|
|
34
|
+
self._seen: dict[tuple, int] = {}
|
|
35
|
+
|
|
36
|
+
def start(self) -> None:
|
|
37
|
+
if self._started:
|
|
38
|
+
return
|
|
39
|
+
self._started = True
|
|
40
|
+
self._thread.start()
|
|
41
|
+
atexit.register(self.flush_and_stop)
|
|
42
|
+
|
|
43
|
+
def record(self, *, app: str, object: Optional[str], method: str,
|
|
44
|
+
agent: Optional[str] = None) -> None:
|
|
45
|
+
try:
|
|
46
|
+
eff_agent = agent or self._agent
|
|
47
|
+
k = (eff_agent, app, object, method)
|
|
48
|
+
n = self._seen.get(k, 0)
|
|
49
|
+
self._seen[k] = n + 1
|
|
50
|
+
if n >= 5:
|
|
51
|
+
return
|
|
52
|
+
self._q.put_nowait({"app": app, "object": object, "method": method,
|
|
53
|
+
"agent": eff_agent, "ts": time.time()})
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def _run(self) -> None:
|
|
58
|
+
batch: list[dict] = []
|
|
59
|
+
last = time.time()
|
|
60
|
+
while not self._stop.is_set():
|
|
61
|
+
timeout = max(0.05, _FLUSH_SECONDS - (time.time() - last))
|
|
62
|
+
try:
|
|
63
|
+
batch.append(self._q.get(timeout=timeout))
|
|
64
|
+
except queue.Empty:
|
|
65
|
+
pass
|
|
66
|
+
if batch and (len(batch) >= _BATCH or (time.time() - last) >= _FLUSH_SECONDS):
|
|
67
|
+
self._send(batch)
|
|
68
|
+
batch = []
|
|
69
|
+
last = time.time()
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
batch.append(self._q.get_nowait())
|
|
73
|
+
except queue.Empty:
|
|
74
|
+
break
|
|
75
|
+
if batch:
|
|
76
|
+
self._send(batch)
|
|
77
|
+
|
|
78
|
+
def _send(self, batch: list[dict]) -> None:
|
|
79
|
+
payload = {"key": self._key, "agent": self._agent, "signals": batch}
|
|
80
|
+
try:
|
|
81
|
+
req = urllib.request.Request(
|
|
82
|
+
self._url, data=json.dumps(payload).encode(), method="POST",
|
|
83
|
+
headers={"Content-Type": "application/json", "User-Agent": "bollard"},
|
|
84
|
+
)
|
|
85
|
+
urllib.request.urlopen(req, timeout=_TIMEOUT).close()
|
|
86
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
87
|
+
pass
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def flush_and_stop(self) -> None:
|
|
92
|
+
self._stop.set()
|
|
93
|
+
if self._started and self._thread.is_alive():
|
|
94
|
+
self._thread.join(timeout=_TIMEOUT + 1)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""`bollard` command-line entry point.
|
|
2
|
+
|
|
3
|
+
bollard watch --key BWATCH-... -- python my_agents.py
|
|
4
|
+
|
|
5
|
+
runs your program with Bollard switched on, without editing a line of its code — the
|
|
6
|
+
shared-codebase path. It sets a few environment variables and puts a tiny auto-start
|
|
7
|
+
shim on the child's import path (see _bootstrap/sitecustomize.py). Add --agent-token
|
|
8
|
+
to make an agent enforceable; without it, it's watch-only.
|
|
9
|
+
|
|
10
|
+
With no command it prints the one-line instructions instead of doing anything.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _bootstrap_dir() -> str:
|
|
21
|
+
return os.path.join(os.path.dirname(__file__), "_bootstrap")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run_child(args, cmd: list[str]) -> int:
|
|
25
|
+
env = dict(os.environ)
|
|
26
|
+
env["BOLLARD_KEY"] = args.key
|
|
27
|
+
if args.agent:
|
|
28
|
+
env["BOLLARD_AGENT"] = args.agent
|
|
29
|
+
if args.agent_token:
|
|
30
|
+
env["BOLLARD_AGENT_TOKEN"] = args.agent_token
|
|
31
|
+
if args.endpoint:
|
|
32
|
+
env["BOLLARD_ENDPOINT"] = args.endpoint
|
|
33
|
+
boot = _bootstrap_dir()
|
|
34
|
+
existing = env.get("PYTHONPATH", "")
|
|
35
|
+
env["PYTHONPATH"] = boot + (os.pathsep + existing if existing else "")
|
|
36
|
+
import subprocess
|
|
37
|
+
try:
|
|
38
|
+
return subprocess.call(cmd, env=env)
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
print(f"bollard: couldn't run '{cmd[0]}' — is it installed and on your PATH?", file=sys.stderr)
|
|
41
|
+
return 127
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _print_instructions(key: str) -> None:
|
|
45
|
+
print(
|
|
46
|
+
"Bollard is ready.\n\n"
|
|
47
|
+
"Run your agents through this command so Bollard can see which apps they use:\n\n"
|
|
48
|
+
f" bollard watch --key {key} -- python your_agent.py\n\n"
|
|
49
|
+
"It only watches — it can't slow your agents down or change what they do.\n\n"
|
|
50
|
+
"Prefer not to wrap the command? Add one line where each agent starts up:\n\n"
|
|
51
|
+
f' import bollard; bollard.start(key="{key}")\n'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
56
|
+
parser = argparse.ArgumentParser(prog="bollard", description="Bollard — see, then govern, your AI agents")
|
|
57
|
+
sub = parser.add_subparsers(dest="command")
|
|
58
|
+
watch = sub.add_parser("watch", help="run your agents through Bollard")
|
|
59
|
+
watch.add_argument("--key", required=True, help="your workspace key (BWATCH-...)")
|
|
60
|
+
watch.add_argument("--agent", default=None, help="optional name for this agent")
|
|
61
|
+
watch.add_argument("--agent-token", dest="agent_token", default=None,
|
|
62
|
+
help="this agent's identity (boll_agt_...) — needed to enforce; watch-only without it")
|
|
63
|
+
watch.add_argument("--endpoint", default=None, help="override Bollard's base address")
|
|
64
|
+
watch.add_argument("cmd", nargs=argparse.REMAINDER, help="the command to run, after '--'")
|
|
65
|
+
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
if args.command != "watch":
|
|
68
|
+
parser.print_help()
|
|
69
|
+
return 0
|
|
70
|
+
cmd = args.cmd[1:] if args.cmd and args.cmd[0] == "--" else args.cmd
|
|
71
|
+
if not cmd:
|
|
72
|
+
_print_instructions(args.key)
|
|
73
|
+
return 0
|
|
74
|
+
return _run_child(args, cmd)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bollard-ai
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: See, then govern, your AI agents — watch which apps they use, and enforce what they're allowed to do.
|
|
5
|
+
Author: Bollard AI
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://bollardai.com
|
|
8
|
+
Keywords: bollard,ai agents,governance,observability,security
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# bollard
|
|
13
|
+
|
|
14
|
+
The one line that lets Bollard **see, then govern** your AI agents.
|
|
15
|
+
|
|
16
|
+
Add it where your agents start up. By default it's **watch-only** — it notes which
|
|
17
|
+
apps your agents use (Notion, Slack, Gmail, Xero, Drive, paid AI models), never what
|
|
18
|
+
they read or write, and reports it to Bollard. When you protect an app in Bollard, the
|
|
19
|
+
same install starts routing *that app's* calls through Bollard so each agent can only
|
|
20
|
+
do what you allow — no code change. Every other app stays watch-only.
|
|
21
|
+
|
|
22
|
+
Three promises, enforced in code:
|
|
23
|
+
|
|
24
|
+
- **It can't break your agents.** Watch-only observes and, if anything goes wrong,
|
|
25
|
+
falls straight through to the real call. Enforcement is opt-in, per app, reversible.
|
|
26
|
+
- **It can't slow them down.** It reports in the background on its own thread.
|
|
27
|
+
- **It never sees your data or code.** It reads only the *envelope* of each call — the
|
|
28
|
+
destination and operation name. There is no code path that reads a request or
|
|
29
|
+
response body (see `bollard/_apps.py`).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install bollard-ai
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use it
|
|
38
|
+
|
|
39
|
+
**One shared codebase** — wrap the command, no code change:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bollard watch --key BWATCH-XXXX-XXXX-XXXX -- python your_agents.py
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Separate projects** — add one line where each agent starts up:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import bollard
|
|
49
|
+
bollard.start(key="BWATCH-XXXX-XXXX-XXXX", agent="sales-follow-up")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Enforcement (opt-in)
|
|
53
|
+
|
|
54
|
+
Watching needs only your workspace key. To let Bollard *enforce* an agent (once you've
|
|
55
|
+
protected an app in the dashboard), give that agent its own identity:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
bollard.start(key="BWATCH-...", agent="sales-follow-up", agent_token="boll_agt_...")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
With an `agent_token`, calls to any **protected** app are routed through Bollard, which
|
|
62
|
+
allows or blocks them against that agent's permissions. Apps you haven't protected stay
|
|
63
|
+
watch-only. No `agent_token` → always watch-only. If Bollard can't be reached to confirm
|
|
64
|
+
the policy, the agent stays watch-only — it never starts blocking off an unconfirmed rule.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
`start()` reads these when the arguments are omitted:
|
|
69
|
+
|
|
70
|
+
| Variable | Meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `BOLLARD_KEY` | your workspace key (`BWATCH-...`) |
|
|
73
|
+
| `BOLLARD_AGENT` | optional name for this agent |
|
|
74
|
+
| `BOLLARD_AGENT_TOKEN` | this agent's identity (`boll_agt_...`), for enforcement |
|
|
75
|
+
| `BOLLARD_ENDPOINT` | override Bollard's base address (self-hosting / testing) |
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
bollard/__init__.py
|
|
4
|
+
bollard/_apps.py
|
|
5
|
+
bollard/_context.py
|
|
6
|
+
bollard/_patch.py
|
|
7
|
+
bollard/_policy.py
|
|
8
|
+
bollard/_reporter.py
|
|
9
|
+
bollard/cli.py
|
|
10
|
+
bollard/_bootstrap/sitecustomize.py
|
|
11
|
+
bollard_ai.egg-info/PKG-INFO
|
|
12
|
+
bollard_ai.egg-info/SOURCES.txt
|
|
13
|
+
bollard_ai.egg-info/dependency_links.txt
|
|
14
|
+
bollard_ai.egg-info/entry_points.txt
|
|
15
|
+
bollard_ai.egg-info/top_level.txt
|
|
16
|
+
tests/test_bollard.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bollard
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
# Distribution name is "bollard-ai" (the plain "bollard" name is taken on PyPI). The
|
|
6
|
+
# import name stays `bollard` and the CLI command stays `bollard` — only the
|
|
7
|
+
# `pip install` line differs: `pip install bollard-ai`, then `import bollard`.
|
|
8
|
+
[project]
|
|
9
|
+
name = "bollard-ai"
|
|
10
|
+
version = "0.3.0"
|
|
11
|
+
description = "See, then govern, your AI agents — watch which apps they use, and enforce what they're allowed to do."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
license = { text = "Proprietary" }
|
|
15
|
+
authors = [{ name = "Bollard AI" }]
|
|
16
|
+
keywords = ["bollard", "ai agents", "governance", "observability", "security"]
|
|
17
|
+
# No runtime dependencies: it hooks httpx/requests only if the agent already uses
|
|
18
|
+
# them, and talks to Bollard over the standard library.
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://bollardai.com"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
bollard = "bollard.cli:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["bollard"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
bollard = ["_bootstrap/sitecustomize.py"]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Tests for the unified bollard package.
|
|
2
|
+
|
|
3
|
+
Run: `python tests/test_bollard.py` (or pytest) from sdk/bollard.
|
|
4
|
+
|
|
5
|
+
Covers the envelope reader, the watch path (captures a signal, reads no body, leaves
|
|
6
|
+
the call untouched), the enforce path (a protected app is rerouted through the gateway
|
|
7
|
+
with the agent's identity; an unprotected app is left alone), and the per-agent marker
|
|
8
|
+
(`bollard.agent(...)`) that attributes and enforces the right agent when several share
|
|
9
|
+
one process.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
15
|
+
|
|
16
|
+
from bollard._apps import app_for_host, object_for # noqa: E402
|
|
17
|
+
from bollard._patch import _gateway_url # noqa: E402
|
|
18
|
+
from bollard._policy import AppRule, Policy # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeReporter:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.seen = []
|
|
24
|
+
|
|
25
|
+
def record(self, *, app, object, method, agent=None):
|
|
26
|
+
self.seen.append((app, object, method, agent))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _watch_policy():
|
|
30
|
+
return Policy(policy_url="", gateway_base="https://api.bollardai.com")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _enforce_policy(token, **rules):
|
|
34
|
+
"""A policy that enforces `rules` (app -> strip_prefix) for one agent token."""
|
|
35
|
+
p = _watch_policy()
|
|
36
|
+
p._rules = {token: {app: AppRule("enforce", strip) for app, strip in rules.items()}}
|
|
37
|
+
return p
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- envelope reader ---------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def test_host_and_object():
|
|
43
|
+
assert app_for_host("api.notion.com", "/v1/pages/x") == "notion"
|
|
44
|
+
assert app_for_host("slack.com", "/api/chat.postMessage") == "slack"
|
|
45
|
+
assert app_for_host("example.com", "/") is None
|
|
46
|
+
assert object_for("slack", "POST", "/api/chat.postMessage") == "chat.postMessage"
|
|
47
|
+
assert object_for("notion", "GET", "/v1/databases/x/query") == "databases"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_gateway_url_strips_service_prefix():
|
|
51
|
+
assert _gateway_url("https://api.bollardai.com", "notion", "/v1/pages/abc", "/v1", "") \
|
|
52
|
+
== "https://api.bollardai.com/gw/notion/pages/abc"
|
|
53
|
+
assert _gateway_url("https://api.bollardai.com", "slack", "/api/chat.postMessage", "/api", "a=1") \
|
|
54
|
+
== "https://api.bollardai.com/gw/slack/chat.postMessage?a=1"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- watch path (default) ----------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def test_watch_captures_and_never_reroutes_or_reads_body():
|
|
60
|
+
import httpx
|
|
61
|
+
from bollard import _patch
|
|
62
|
+
|
|
63
|
+
reads = {"n": 0}
|
|
64
|
+
captured = []
|
|
65
|
+
|
|
66
|
+
class Spy(httpx.Request):
|
|
67
|
+
@property
|
|
68
|
+
def content(self): # pragma: no cover - fires only if we misbehave
|
|
69
|
+
reads["n"] += 1
|
|
70
|
+
return super().content
|
|
71
|
+
|
|
72
|
+
def handler(request):
|
|
73
|
+
captured.append(str(request.url))
|
|
74
|
+
return httpx.Response(200, json={"ok": True})
|
|
75
|
+
|
|
76
|
+
rep = FakeReporter()
|
|
77
|
+
_patch.install(rep, _watch_policy(), None, None) # no identity → watch-only
|
|
78
|
+
try:
|
|
79
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as c:
|
|
80
|
+
resp = c.send(Spy("POST", "https://api.notion.com/v1/pages/abc", json={"secret": "x"}))
|
|
81
|
+
finally:
|
|
82
|
+
_patch.uninstall()
|
|
83
|
+
|
|
84
|
+
assert resp.status_code == 200
|
|
85
|
+
assert ("notion", "pages", "POST", None) in rep.seen # watched
|
|
86
|
+
assert captured == ["https://api.notion.com/v1/pages/abc"] # went straight to Notion
|
|
87
|
+
assert reads["n"] == 0 # body never read
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- enforce path (protected app) --------------------------------------------
|
|
91
|
+
|
|
92
|
+
def test_enforce_reroutes_protected_app_through_gateway():
|
|
93
|
+
import httpx
|
|
94
|
+
from bollard import _patch
|
|
95
|
+
|
|
96
|
+
seen = []
|
|
97
|
+
|
|
98
|
+
def handler(request):
|
|
99
|
+
seen.append((str(request.url), request.headers.get("authorization")))
|
|
100
|
+
return httpx.Response(200, json={"ok": True})
|
|
101
|
+
|
|
102
|
+
policy = _enforce_policy("boll_agt_TEST", notion="/v1") # notion protected; slack not
|
|
103
|
+
_patch.install(FakeReporter(), policy, None, "boll_agt_TEST")
|
|
104
|
+
try:
|
|
105
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as c:
|
|
106
|
+
c.send(httpx.Request("GET", "https://api.notion.com/v1/pages/abc")) # protected → reroute
|
|
107
|
+
c.send(httpx.Request("POST", "https://slack.com/api/chat.postMessage")) # not protected → direct
|
|
108
|
+
finally:
|
|
109
|
+
_patch.uninstall()
|
|
110
|
+
|
|
111
|
+
assert seen[0] == ("https://api.bollardai.com/gw/notion/pages/abc", "Bearer boll_agt_TEST")
|
|
112
|
+
assert seen[1][0] == "https://slack.com/api/chat.postMessage" # slack untouched
|
|
113
|
+
assert seen[1][1] is None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_enforce_needs_a_token():
|
|
117
|
+
import httpx
|
|
118
|
+
from bollard import _patch
|
|
119
|
+
|
|
120
|
+
seen = []
|
|
121
|
+
policy = _enforce_policy("boll_agt_TEST", notion="/v1")
|
|
122
|
+
_patch.install(FakeReporter(), policy, None, None) # protected app but no identity → watch
|
|
123
|
+
try:
|
|
124
|
+
with httpx.Client(transport=httpx.MockTransport(lambda r: (seen.append(str(r.url)) or httpx.Response(200)))) as c:
|
|
125
|
+
c.send(httpx.Request("GET", "https://api.notion.com/v1/pages/abc"))
|
|
126
|
+
finally:
|
|
127
|
+
_patch.uninstall()
|
|
128
|
+
assert seen == ["https://api.notion.com/v1/pages/abc"] # stayed direct (watch-only)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- per-agent marker (shared process) ---------------------------------------
|
|
132
|
+
|
|
133
|
+
def test_agent_marker_enforces_the_right_agent():
|
|
134
|
+
"""Two agents in one process. Notion is protected for 'A' only. Each agent's calls
|
|
135
|
+
are routed by *its* identity, not the process's."""
|
|
136
|
+
import httpx
|
|
137
|
+
from bollard import _patch, agent
|
|
138
|
+
|
|
139
|
+
seen = []
|
|
140
|
+
|
|
141
|
+
def handler(request):
|
|
142
|
+
seen.append((str(request.url), request.headers.get("authorization")))
|
|
143
|
+
return httpx.Response(200, json={"ok": True})
|
|
144
|
+
|
|
145
|
+
policy = _watch_policy()
|
|
146
|
+
policy._rules = {"boll_agt_A": {"notion": AppRule("enforce", "/v1")}} # A protected; B not
|
|
147
|
+
_patch.install(FakeReporter(), policy, None, None) # no process-level identity
|
|
148
|
+
try:
|
|
149
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as c:
|
|
150
|
+
with agent("sales", token="boll_agt_A"):
|
|
151
|
+
c.send(httpx.Request("GET", "https://api.notion.com/v1/pages/abc")) # A → reroute
|
|
152
|
+
with agent("research", token="boll_agt_B"):
|
|
153
|
+
c.send(httpx.Request("GET", "https://api.notion.com/v1/pages/xyz")) # B → direct
|
|
154
|
+
c.send(httpx.Request("GET", "https://api.notion.com/v1/pages/zzz")) # unmarked → direct
|
|
155
|
+
finally:
|
|
156
|
+
_patch.uninstall()
|
|
157
|
+
|
|
158
|
+
assert seen[0] == ("https://api.bollardai.com/gw/notion/pages/abc", "Bearer boll_agt_A")
|
|
159
|
+
assert seen[1] == ("https://api.notion.com/v1/pages/xyz", None)
|
|
160
|
+
assert seen[2] == ("https://api.notion.com/v1/pages/zzz", None)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_agent_marker_attributes_signals():
|
|
164
|
+
"""The marker sets who a watched call is attributed to; outside it, the process default."""
|
|
165
|
+
import httpx
|
|
166
|
+
from bollard import _patch, agent
|
|
167
|
+
|
|
168
|
+
rep = FakeReporter()
|
|
169
|
+
_patch.install(rep, _watch_policy(), "proc-default", None)
|
|
170
|
+
try:
|
|
171
|
+
with httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(200))) as c:
|
|
172
|
+
with agent("sales"):
|
|
173
|
+
c.send(httpx.Request("POST", "https://api.notion.com/v1/pages/abc"))
|
|
174
|
+
c.send(httpx.Request("POST", "https://slack.com/api/chat.postMessage"))
|
|
175
|
+
finally:
|
|
176
|
+
_patch.uninstall()
|
|
177
|
+
|
|
178
|
+
assert ("notion", "pages", "POST", "sales") in rep.seen # marked → 'sales'
|
|
179
|
+
assert ("slack", "chat.postMessage", "POST", "proc-default") in rep.seen # unmarked → default
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
|
184
|
+
for fn in fns:
|
|
185
|
+
fn()
|
|
186
|
+
print(f"ok {fn.__name__}")
|
|
187
|
+
print(f"\n{len(fns)} passed")
|