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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ bollard = bollard.cli:main
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")