agentprov-capture 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """agentprov_capture: the capture/0.1 raw-evidence event log shared by the AgentPROV core (reader and
2
+ verifier) and the isolated live backends (writer). Stdlib + rfc8785 only, Python >= 3.12."""
3
+
4
+ FORMAT = "capture/0.1"
5
+ __version__ = "0.1.1" # equals capture/pyproject.toml (tests/unit/test_versions_source.py)
@@ -0,0 +1,252 @@
1
+ """The endpoint registry: ``endpoints.toml`` as validated data (MN M.1.10 item 2; N.2.1, N.2.1a; G.9.2).
2
+
3
+ Stdlib only (``tomllib``; Python >= 3.12). The registry names environment variables, never values. Rules enforced here:
4
+
5
+ * a base URL is https, except loopback (``127.0.0.1``, ``[::1]``, ``localhost``);
6
+ * a base URL has no userinfo, query or fragment (a key could hide in them; G.6.4);
7
+ * a ``local`` entry is loopback and names its ``server`` kind (D-690). Its ``api_key_env`` is optional: a server set up
8
+ with an API key (oMLX's, D-827) gets it as a bearer header when the key is set, and none otherwise. A non-local entry
9
+ names ``api_key_env`` and has no ``server``;
10
+ * a custom base URL's host must equal the registered host unless the caller passes ``allow_custom=True``
11
+ (the CLI's ``--allow-custom-endpoint``, recorded in the manifest);
12
+ * ``api`` names the wire format the endpoint speaks: ``openai_chat`` (the default; OpenAI-compatible chat completions)
13
+ or ``anthropic_messages`` (the Anthropic Messages API; D-656).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ipaddress
19
+ import re
20
+ import tomllib
21
+ from dataclasses import dataclass
22
+ from importlib import resources
23
+ from typing import Any, Final
24
+ from urllib.parse import urlsplit
25
+
26
+ __all__ = [
27
+ "API_KINDS",
28
+ "LOCAL_SERVERS",
29
+ "LOOPBACK_HOSTS",
30
+ "Endpoint",
31
+ "EndpointError",
32
+ "check_base_url",
33
+ "get_endpoint",
34
+ "is_loopback_host",
35
+ "is_private_lan_host",
36
+ "load_endpoints",
37
+ "parse_registry",
38
+ "validate_base_url",
39
+ ]
40
+
41
+ #: The loopback hosts of G.9.2, as ``urllib.parse`` reports them (brackets removed from the IPv6 literal).
42
+ LOOPBACK_HOSTS: Final = frozenset({"127.0.0.1", "::1", "localhost"})
43
+
44
+ #: The wire formats an endpoint may speak (``api``); the first is the default.
45
+ API_KINDS: Final = ("openai_chat", "anthropic_messages")
46
+ #: The local server kinds of D-690. ``server`` selects the discovery and fingerprint probes of ``agentprov models``.
47
+ LOCAL_SERVERS: Final = frozenset({"ollama", "omlx", "lmstudio", "mlx_lm", "llamacpp", "vllm"})
48
+
49
+ _ENV_NAME: Final = re.compile(r"[A-Z][A-Z0-9_]{0,63}")
50
+ _ENDPOINT_NAME: Final = re.compile(r"[a-z][a-z0-9_-]{0,31}")
51
+ _HEADER_NAME: Final = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
52
+ _ENTRY_KEYS: Final = frozenset(
53
+ {
54
+ "base_url",
55
+ "base_url_env",
56
+ "api_key_env",
57
+ "model_env",
58
+ "local",
59
+ "server_max_concurrency",
60
+ "request_headers",
61
+ "response_headers",
62
+ "api",
63
+ "server",
64
+ }
65
+ )
66
+
67
+
68
+ class EndpointError(ValueError):
69
+ """A registry or base-URL violation. ``code`` is stable; messages never carry values from the environment."""
70
+
71
+ def __init__(self, code: str, message: str) -> None:
72
+ super().__init__(f"{code}: {message}")
73
+ self.code = code
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class Endpoint:
78
+ """One registry entry. ``*_env`` fields are environment-variable NAMES (or None), never values."""
79
+
80
+ name: str
81
+ base_url: str
82
+ host: str
83
+ base_url_env: str | None
84
+ api_key_env: str | None
85
+ model_env: str | None
86
+ local: bool
87
+ server_max_concurrency: int | None
88
+ #: header NAMES (lower case) whose values wire-capture redaction may record besides the global allowlist
89
+ #: (``redact_allowlist.toml``): for example an API-version header (request) or a request-id header (response).
90
+ #: The hard denylist of G.6.4 wins over them.
91
+ request_headers: tuple[str, ...] = ()
92
+ response_headers: tuple[str, ...] = ()
93
+ #: the wire format (one of :data:`API_KINDS`): which LLM element and recorder the harness uses (D-656)
94
+ api: str = API_KINDS[0]
95
+ #: the local server kind (one of :data:`LOCAL_SERVERS`) for a ``local`` entry; ``None`` for a remote one (D-690)
96
+ server: str | None = None
97
+
98
+
99
+ def is_loopback_host(host: str) -> bool:
100
+ return host.lower() in LOOPBACK_HOSTS
101
+
102
+
103
+ #: The private ranges a LAN endpoint may use (D-698): RFC 1918, the shared address space of RFC 6598 (CGNAT, used by
104
+ #: Tailscale-style overlays), and IPv6 unique local addresses. Link-local, loopback and every public range are not.
105
+ LAN_NETWORKS: Final = tuple(ipaddress.ip_network(n) for n in (
106
+ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "fc00::/7"))
107
+
108
+
109
+ def is_private_lan_host(host: str) -> bool:
110
+ """A literal IP address in :data:`LAN_NETWORKS` (never a name: a name can be made to resolve anywhere), without an
111
+ IPv6 zone id (``%en0`` names an interface of this host, not a LAN peer)."""
112
+ if "%" in host:
113
+ return False
114
+ try:
115
+ address = ipaddress.ip_address(host)
116
+ except ValueError:
117
+ return False
118
+ return any(address in net for net in LAN_NETWORKS if net.version == address.version)
119
+
120
+
121
+ def validate_base_url(base_url: str, *, allow_lan: bool = False) -> str:
122
+ """Check the G.9.2 / G.6.4 rules on ``base_url`` and return its host (lower case).
123
+
124
+ http is allowed on a loopback host, and, with ``allow_lan`` (the CLI's ``--allow-lan-endpoint``, D-698), on a
125
+ literal private address. Raises :class:`EndpointError` (``endpoint.url.*``). Nothing is fetched or resolved.
126
+ """
127
+ if not isinstance(base_url, str) or not base_url or any(ord(c) < 0x21 or ord(c) == 0x7F for c in base_url):
128
+ raise EndpointError("endpoint.url.malformed", "base URL is empty or contains whitespace or control characters")
129
+ try:
130
+ parts = urlsplit(base_url)
131
+ host = parts.hostname
132
+ parts.port # noqa: B018 - raises ValueError on a bad port
133
+ except ValueError:
134
+ raise EndpointError("endpoint.url.malformed", "base URL cannot be parsed") from None
135
+ if parts.scheme not in ("http", "https") or not host:
136
+ raise EndpointError("endpoint.url.malformed", "base URL needs an http(s) scheme and a host")
137
+ if parts.username is not None or parts.password is not None or "@" in parts.netloc:
138
+ raise EndpointError("endpoint.url.userinfo", "base URL must not carry userinfo")
139
+ if parts.query or "?" in base_url:
140
+ raise EndpointError("endpoint.url.query", "base URL must not carry a query string")
141
+ if parts.fragment or "#" in base_url:
142
+ raise EndpointError("endpoint.url.fragment", "base URL must not carry a fragment")
143
+ host = host.lower()
144
+ if parts.scheme != "https" and not is_loopback_host(host) and not (allow_lan and is_private_lan_host(host)):
145
+ raise EndpointError("endpoint.url.not_https", "base URL must be https unless the host is loopback")
146
+ return host
147
+
148
+
149
+ def check_base_url(endpoint: Endpoint, base_url: str, *, allow_custom: bool = False) -> bool:
150
+ """Validate a base URL given for ``endpoint`` (e.g. from its ``base_url_env``). Returns True when it is a custom
151
+ endpoint (its host differs from the registered one), which the caller must record in the manifest.
152
+
153
+ The https/loopback/userinfo/query rules always apply. A host mismatch raises ``endpoint.url.host_mismatch``
154
+ unless ``allow_custom`` (the CLI's ``--allow-custom-endpoint``, R-39).
155
+ """
156
+ host = validate_base_url(base_url)
157
+ if host == endpoint.host:
158
+ return False
159
+ if not allow_custom:
160
+ raise EndpointError("endpoint.url.host_mismatch", f"host does not match the registered host of {endpoint.name!r}")
161
+ return True
162
+
163
+
164
+ def _opt_env(entry: dict[str, Any], key: str, name: str) -> str | None:
165
+ value = entry.get(key)
166
+ if value is None:
167
+ return None
168
+ if not isinstance(value, str) or not _ENV_NAME.fullmatch(value):
169
+ raise EndpointError("endpoint.registry.env_name", f"endpoint {name!r}: {key} must be an upper-case variable name")
170
+ return value
171
+
172
+
173
+ def _opt_headers(entry: dict[str, Any], key: str, name: str) -> tuple[str, ...]:
174
+ value = entry.get(key, [])
175
+ if not isinstance(value, list) or not all(isinstance(h, str) and _HEADER_NAME.fullmatch(h) for h in value):
176
+ raise EndpointError("endpoint.registry.header", f"endpoint {name!r}: {key} must be a list of lower-case header names")
177
+ return tuple(sorted(set(value)))
178
+
179
+
180
+ def parse_registry(data: dict[str, Any]) -> dict[str, Endpoint]:
181
+ """Validate a parsed ``endpoints.toml`` document and return the entries by name, sorted."""
182
+ if data.get("version") != 1:
183
+ raise EndpointError("endpoint.registry.version", "unsupported registry version")
184
+ entries = data.get("endpoints")
185
+ if not isinstance(entries, dict) or not entries:
186
+ raise EndpointError("endpoint.registry.empty", "registry has no [endpoints.*] tables")
187
+ out: dict[str, Endpoint] = {}
188
+ for name in sorted(entries):
189
+ entry = entries[name]
190
+ if not _ENDPOINT_NAME.fullmatch(name) or not isinstance(entry, dict):
191
+ raise EndpointError("endpoint.registry.name", "bad endpoint name or entry")
192
+ unknown = set(entry) - _ENTRY_KEYS
193
+ if unknown:
194
+ raise EndpointError("endpoint.registry.key", f"endpoint {name!r}: unknown key {sorted(unknown)[0]!r}")
195
+ base_url = entry.get("base_url")
196
+ if not isinstance(base_url, str):
197
+ raise EndpointError("endpoint.registry.base_url", f"endpoint {name!r}: base_url is required")
198
+ host = validate_base_url(base_url)
199
+ local = entry.get("local", False)
200
+ if not isinstance(local, bool):
201
+ raise EndpointError("endpoint.registry.local", f"endpoint {name!r}: local must be a boolean")
202
+ if local and not is_loopback_host(host):
203
+ raise EndpointError("endpoint.registry.local", f"endpoint {name!r}: a local entry must be loopback")
204
+ api_key_env = _opt_env(entry, "api_key_env", name)
205
+ if not local and api_key_env is None:
206
+ raise EndpointError("endpoint.registry.api_key_env", f"endpoint {name!r}: api_key_env is required")
207
+ server = entry.get("server")
208
+ if local and (not isinstance(server, str) or server not in LOCAL_SERVERS):
209
+ raise EndpointError("endpoint.registry.server", f"endpoint {name!r}: a local entry needs server, one of "
210
+ f"{sorted(LOCAL_SERVERS)}")
211
+ if not local and server is not None:
212
+ raise EndpointError("endpoint.registry.server", f"endpoint {name!r}: server belongs to local entries")
213
+ conc = entry.get("server_max_concurrency")
214
+ if conc is not None and (isinstance(conc, bool) or not isinstance(conc, int) or conc < 1):
215
+ raise EndpointError(
216
+ "endpoint.registry.concurrency", f"endpoint {name!r}: server_max_concurrency must be an integer >= 1"
217
+ )
218
+ api = entry.get("api", API_KINDS[0])
219
+ if api not in API_KINDS:
220
+ raise EndpointError("endpoint.registry.api", f"endpoint {name!r}: api must be one of {list(API_KINDS)}")
221
+ if local and api != API_KINDS[0]: # every local server kind of D-690 speaks the OpenAI chat wire
222
+ raise EndpointError("endpoint.registry.local_api", f"endpoint {name!r}: a local entry speaks {API_KINDS[0]}")
223
+ out[name] = Endpoint(
224
+ name=name,
225
+ base_url=base_url,
226
+ host=host,
227
+ base_url_env=_opt_env(entry, "base_url_env", name),
228
+ api_key_env=api_key_env,
229
+ model_env=_opt_env(entry, "model_env", name),
230
+ local=local,
231
+ server_max_concurrency=conc,
232
+ request_headers=_opt_headers(entry, "request_headers", name),
233
+ response_headers=_opt_headers(entry, "response_headers", name),
234
+ api=api,
235
+ server=server,
236
+ )
237
+ return out
238
+
239
+
240
+ def load_endpoints() -> dict[str, Endpoint]:
241
+ """Load and validate the packaged ``endpoints.toml`` (read through ``importlib.resources``)."""
242
+ raw = resources.files("agentprov_capture").joinpath("endpoints.toml").read_bytes()
243
+ return parse_registry(tomllib.loads(raw.decode("utf-8")))
244
+
245
+
246
+ def get_endpoint(name: str) -> Endpoint:
247
+ """The registry entry ``name`` (``EndpointError("endpoint.unknown")`` if absent)."""
248
+ registry = load_endpoints()
249
+ try:
250
+ return registry[name]
251
+ except KeyError:
252
+ raise EndpointError("endpoint.unknown", f"unknown endpoint shorthand; known: {sorted(registry)}") from None
@@ -0,0 +1,156 @@
1
+ # AgentPROV endpoint registry (MN M.1.10 item 2; N.2.1 `--model`; N.2.1a; G.9.2).
2
+ # Data, not code: read through importlib.resources by agentprov_capture.endpoints (no __init__.py needed here).
3
+ #
4
+ # Names only: `base_url_env`, `api_key_env` and `model_env` name environment variables (the .env keys); no value is
5
+ # ever stored here. There are no default model ids (Q5, closed): a model id comes from `--model-id` or `model_env`.
6
+ # `base_url` is the public default. Its host is the registered host that any other base URL must match unless
7
+ # `--allow-custom-endpoint` is given (R-39). A base URL MUST be https, except loopback (127.0.0.1, [::1], localhost),
8
+ # and MUST carry no userinfo, query or fragment (G.6.4).
9
+ #
10
+ # `request_headers` / `response_headers` (optional, lower-case header NAMES) extend the global header allowlist of
11
+ # redact_allowlist.toml for that endpoint (G.6.4: "per-endpoint names declared in endpoints.toml"); the hard denylist
12
+ # wins over them. Only request-id style headers are declared; add a name here, with a test, when a provider needs it.
13
+ #
14
+ # `api` (optional) is the wire format: `openai_chat` (default) or `anthropic_messages` (D-656). It selects the LLM
15
+ # element and the wire recorder's decoding; the CLI sets the agent's provider from it.
16
+ #
17
+ # Baseline set from AgentDojo-PROV `runtime.py` (build_* functions and _LLM_BUILDERS); `omlx` is new (D-10); `lmstudio`,
18
+ # `mlx_lm`, `llamacpp` and `vllm` are new (D-690).
19
+
20
+ version = 1
21
+
22
+ [endpoints.deepseek]
23
+ base_url = "https://api.deepseek.com/v1"
24
+ base_url_env = "DEEPSEEK_BASE_URL"
25
+ api_key_env = "DEEPSEEK_API_KEY"
26
+ model_env = "DEEPSEEK_MODEL"
27
+ local = false
28
+
29
+ [endpoints.openai]
30
+ base_url = "https://api.openai.com/v1"
31
+ base_url_env = "OPENAI_BASE_URL"
32
+ api_key_env = "OPENAI_API_KEY"
33
+ model_env = "OPENAI_MODEL"
34
+ local = false
35
+ response_headers = ["x-request-id", "openai-version"]
36
+
37
+ # One OpenRouter key and base URL serve openrouter, gemini, llama, qwen and glm (baseline `_build_openrouter_llm`).
38
+ [endpoints.openrouter]
39
+ base_url = "https://openrouter.ai/api/v1"
40
+ base_url_env = "OPENROUTER_BASE_URL"
41
+ api_key_env = "OPENROUTER_API_KEY"
42
+ model_env = "OPENROUTER_MODEL"
43
+ local = false
44
+ response_headers = ["x-request-id"]
45
+
46
+ [endpoints.gemini]
47
+ base_url = "https://openrouter.ai/api/v1"
48
+ base_url_env = "OPENROUTER_BASE_URL"
49
+ api_key_env = "OPENROUTER_API_KEY"
50
+ model_env = "GEMINI_MODEL"
51
+ local = false
52
+ response_headers = ["x-request-id"]
53
+
54
+ [endpoints.llama]
55
+ base_url = "https://openrouter.ai/api/v1"
56
+ base_url_env = "OPENROUTER_BASE_URL"
57
+ api_key_env = "OPENROUTER_API_KEY"
58
+ model_env = "LLAMA_MODEL"
59
+ local = false
60
+ response_headers = ["x-request-id"]
61
+
62
+ [endpoints.qwen]
63
+ base_url = "https://openrouter.ai/api/v1"
64
+ base_url_env = "OPENROUTER_BASE_URL"
65
+ api_key_env = "OPENROUTER_API_KEY"
66
+ model_env = "QWEN_MODEL"
67
+ local = false
68
+ response_headers = ["x-request-id"]
69
+
70
+ [endpoints.glm]
71
+ base_url = "https://openrouter.ai/api/v1"
72
+ base_url_env = "OPENROUTER_BASE_URL"
73
+ api_key_env = "OPENROUTER_API_KEY"
74
+ model_env = "GLM_MODEL"
75
+ local = false
76
+ response_headers = ["x-request-id"]
77
+
78
+ # The Anthropic Messages API, direct (D-656). The SDK appends /v1/messages to the base URL. Model ids are the bare API
79
+ # ids (claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5, ...), never date-suffixed. Recorded header values: the
80
+ # API version and beta flags sent, and the request id and the two retry headers the SDK's retry decision reads. The
81
+ # organisation id and the rate-limit headers keep their names only; x-api-key is on the hard denylist.
82
+ [endpoints.anthropic]
83
+ base_url = "https://api.anthropic.com"
84
+ base_url_env = "ANTHROPIC_BASE_URL"
85
+ api_key_env = "ANTHROPIC_API_KEY"
86
+ model_env = "ANTHROPIC_MODEL"
87
+ local = false
88
+ api = "anthropic_messages"
89
+ request_headers = ["anthropic-beta", "anthropic-version"]
90
+ response_headers = ["request-id", "retry-after", "x-should-retry"]
91
+
92
+ # Local OpenAI-compatible servers (N.2.1a; D-690): http on loopback is allowed and no key is needed (an optional
93
+ # `api_key_env` sends one when it is set, D-827). `server` names the server kind; `agentprov models` uses it to
94
+ # fingerprint the server that answers (two kinds share a default port: 8000 omlx/vllm, 8080 mlx_lm/llamacpp) and to
95
+ # read the model identity it exposes. Base URLs are pinned to 127.0.0.1, not `localhost`, which may resolve to ::1.
96
+ # Tool calls: whether a model's replies carry structured `tool_calls` depends on the server, its flags and the model's
97
+ # template; the preflight probe decides (D.10.9 A4), and text-encoded calls need
98
+ # `--allow-text-tool-calls`. `server_max_concurrency` is set only where the server's default is fixed; LM Studio and
99
+ # llama.cpp report their own value, and `--server-max-concurrency` overrides.
100
+
101
+ # Ollama. Structured tool calls for models whose capabilities include "tools"; others are refused by the server (400).
102
+ # OLLAMA_NUM_PARALLEL depends on the version and memory, and is not reported, so no concurrency default is set.
103
+ [endpoints.ollama]
104
+ base_url = "http://127.0.0.1:11434/v1"
105
+ base_url_env = "LOCAL_LLM_BASE_URL"
106
+ model_env = "LOCAL_MODEL_ID"
107
+ local = true
108
+ server = "ollama"
109
+
110
+ # oMLX (MLX inference server, Apple Silicon). The model id is the oMLX model directory name or alias (`--model-id`).
111
+ # `server_max_concurrency` is oMLX's `--max-concurrent-requests` default. Structured tool calls through oMLX's parser.
112
+ # `api_key_env` is optional on a local entry (D-827): oMLX can be set up with an API key. When `OMLX_API_KEY` is set
113
+ # (keychain first, then .env), it is sent as a bearer header to the loopback server, and never to a LAN address; when
114
+ # it is not set, no key is sent, as for every other local server. `model_env` (D-827 addendum): `OMLX_MODEL` in .env
115
+ # names the served model id (oMLX's own id, e.g. its folder name, not a Hugging Face repo id) when `--model-id` is not
116
+ # given; without either, a terminal offers a pick.
117
+ [endpoints.omlx]
118
+ base_url = "http://127.0.0.1:8000/v1"
119
+ base_url_env = "OMLX_BASE_URL"
120
+ api_key_env = "OMLX_API_KEY"
121
+ model_env = "OMLX_MODEL"
122
+ local = true
123
+ server = "omlx"
124
+ server_max_concurrency = 8
125
+
126
+ # LM Studio's server (`lms server start`). Native tool calls for models with `trained_for_tool_use`; for other models LM
127
+ # Studio adds its own tool-use system prompt on the server (invisible to the wire capture), which `run` refuses unless
128
+ # `--allow-text-tool-calls`. The parallel-request limit is read from the loaded instance (`/api/v1/models`).
129
+ [endpoints.lmstudio]
130
+ base_url = "http://127.0.0.1:1234/v1"
131
+ base_url_env = "LMSTUDIO_BASE_URL"
132
+ local = true
133
+ server = "lmstudio"
134
+
135
+ # mlx_lm.server (MLX LM). The model id is a Hugging Face repo id or a local path. Requests are served one at a time by
136
+ # default. Tool calls depend on the mlx-lm version and the model's chat template; text-encoded calls are common.
137
+ [endpoints.mlx_lm]
138
+ base_url = "http://127.0.0.1:8080/v1"
139
+ base_url_env = "MLX_LM_BASE_URL"
140
+ local = true
141
+ server = "mlx_lm"
142
+ server_max_concurrency = 1
143
+
144
+ # llama.cpp `llama-server`. Structured tool calls only when started with `--jinja`; the slot count is read from /props.
145
+ [endpoints.llamacpp]
146
+ base_url = "http://127.0.0.1:8080/v1"
147
+ base_url_env = "LLAMACPP_BASE_URL"
148
+ local = true
149
+ server = "llamacpp"
150
+
151
+ # vLLM's OpenAI-compatible server. Structured tool calls only with `--enable-auto-tool-choice --tool-call-parser <p>`.
152
+ [endpoints.vllm]
153
+ base_url = "http://127.0.0.1:8000/v1"
154
+ base_url_env = "VLLM_BASE_URL"
155
+ local = true
156
+ server = "vllm"
@@ -0,0 +1,182 @@
1
+ """Environment-state digests, snapshots and diffs (C.13.3 ``pass.start``/``pass.end``/``state``; B CAP-18, F-12).
2
+
3
+ Extracted from the baseline ``agentdojo_prov/envstate.py`` (rules E-1...E-5 of the audit) and changed in exactly the
4
+ ways the specification decided:
5
+
6
+ * **E-1** ``dump_state``: a duck-typed ``model_dump(mode="json")`` (pydantic), else a JSON round trip with ``default=str``.
7
+ No benchmark import.
8
+ * **E-2 (changed)** the canonical form is **JCS** (RFC 8785) of the dump, and the digest is a mapping
9
+ ``{"sha256": <hex, no prefix>, "canon": "jcs-rfc8785"}`` that is the ``env`` member of ``pass.start`` as is
10
+ (C.13.8). The baseline's ``sort_keys``/``ensure_ascii=False`` form is *not* reproduced; baseline digests are
11
+ imported with ``canon: "adprov.envstate@1"`` (C.3) and never recomputed. Values JCS cannot carry are encoded as in
12
+ C.11.2-C.11.3 before hashing: integers beyond +-(2^53-1) as ``{"$int": "<decimal>"}``, ``NaN``/``Infinity``/
13
+ ``-0.0`` as ``{"$float": ...}``, lone surrogates as ``{"$utf16": <base64>}``, objects whose first-level member
14
+ starts with ``$`` as ``{"$lit": <object>}``.
15
+ * **E-3** keys are walked in sorted order; list elements are matched by canonical content (multiset), and a
16
+ same-multiset different-order list gives one ``reordered`` entry.
17
+ * **E-4 (F-12 fixed)** values are cut at ``VALUE_CAP`` characters and entries at ``ENTRY_CAP``; ``truncated`` is
18
+ true if **any** entry or value was cut (the baseline ignored value cuts).
19
+ * **E-5** the end record carries ``changed`` and a ``diff`` only when the state changed.
20
+
21
+ Paths in diff entries are RFC 6901 JSON Pointers (the baseline used dotted paths, ambiguous for keys containing a
22
+ dot); an element added to or removed from a list is reported at the pointer of the *list* with ``"element": true``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import hashlib
29
+ import json
30
+ import math
31
+ from typing import Any, Final
32
+
33
+ import rfc8785
34
+
35
+ __all__ = [
36
+ "CANON",
37
+ "DIFF_FORMAT",
38
+ "ENTRY_CAP",
39
+ "VALUE_CAP",
40
+ "diff",
41
+ "digest",
42
+ "dump_state",
43
+ "end_env",
44
+ "normalise",
45
+ "snapshot",
46
+ ]
47
+
48
+ CANON: Final = "jcs-rfc8785"
49
+ DIFF_FORMAT: Final = "agentprov.envdiff@1"
50
+ VALUE_CAP: Final = 400
51
+ ENTRY_CAP: Final = 200
52
+ _MAX_SAFE_INT: Final = 2**53 - 1
53
+
54
+
55
+ def dump_state(env: Any) -> Any:
56
+ """The environment as plain JSON-able data (E-1): a pydantic-style ``model_dump(mode="json")`` or a JSON round trip
57
+ with ``default=str``."""
58
+ if hasattr(env, "model_dump"):
59
+ return env.model_dump(mode="json")
60
+ return json.loads(json.dumps(env, default=str))
61
+
62
+
63
+ def normalise(value: Any) -> Any:
64
+ """Encode what JCS cannot carry (C.11.2-C.11.3) so ``rfc8785`` accepts any plain JSON-able value."""
65
+ if isinstance(value, bool) or value is None:
66
+ return value
67
+ if isinstance(value, int):
68
+ return value if abs(value) <= _MAX_SAFE_INT else {"$int": str(value)}
69
+ if isinstance(value, float):
70
+ if math.isnan(value):
71
+ return {"$float": "NaN"}
72
+ if math.isinf(value):
73
+ return {"$float": "Infinity" if value > 0 else "-Infinity"}
74
+ if value == 0.0 and math.copysign(1.0, value) < 0:
75
+ return {"$float": "-0"}
76
+ return value
77
+ if isinstance(value, str):
78
+ try:
79
+ value.encode("utf-8")
80
+ except UnicodeEncodeError:
81
+ units = value.encode("utf-16-be", "surrogatepass")
82
+ return {"$utf16": base64.b64encode(units).decode("ascii")}
83
+ return value
84
+ if isinstance(value, dict):
85
+ out = {normalise_key(k): normalise(v) for k, v in value.items()}
86
+ if any(k.startswith("$") for k in out):
87
+ return {"$lit": out}
88
+ return out
89
+ if isinstance(value, (list, tuple)):
90
+ return [normalise(v) for v in value]
91
+ raise TypeError(f"cannot canonicalise a {type(value).__name__}; dump_state() the environment first")
92
+
93
+
94
+ def normalise_key(key: Any) -> str:
95
+ if not isinstance(key, str):
96
+ raise TypeError("object keys must be strings; dump_state() the environment first")
97
+ return key
98
+
99
+
100
+ def snapshot(dump: Any) -> bytes:
101
+ """The canonical bytes of an environment dump: JCS of :func:`normalise` (store as a blob for a full snapshot)."""
102
+ return rfc8785.dumps(normalise(dump))
103
+
104
+
105
+ def digest(dump: Any) -> dict[str, str]:
106
+ """``{"sha256": <hex>, "canon": "jcs-rfc8785"}`` of the dump (E-2): the ``env`` of ``pass.start``."""
107
+ return {"sha256": hashlib.sha256(snapshot(dump)).hexdigest(), "canon": CANON}
108
+
109
+
110
+ def _canon_text(value: Any) -> str:
111
+ """JCS text of an already normalised value."""
112
+ return rfc8785.dumps(value).decode("utf-8")
113
+
114
+
115
+ def _ptr(parts: list[str]) -> str:
116
+ return "".join("/" + p.replace("~", "~0").replace("/", "~1") for p in parts)
117
+
118
+
119
+ def _cut(value: Any, cuts: list[bool]) -> str:
120
+ text = _canon_text(value)
121
+ if len(text) > VALUE_CAP:
122
+ cuts[0] = True
123
+ return text[:VALUE_CAP] + "…"
124
+ return text
125
+
126
+
127
+ def _walk(before: Any, after: Any, path: list[str], out: list[dict[str, Any]], cuts: list[bool]) -> None:
128
+ if isinstance(before, dict) and isinstance(after, dict):
129
+ for key in sorted(set(before) | set(after), key=str):
130
+ sub = [*path, str(key)]
131
+ if key not in after:
132
+ out.append({"op": "removed", "path": _ptr(sub), "before": _cut(before[key], cuts)})
133
+ elif key not in before:
134
+ out.append({"op": "added", "path": _ptr(sub), "after": _cut(after[key], cuts)})
135
+ else:
136
+ _walk(before[key], after[key], sub, out, cuts)
137
+ elif isinstance(before, list) and isinstance(after, list):
138
+ # elements are matched by content, not position: deleting the first element must not report every later one
139
+ canon_before = [_canon_text(x) for x in before]
140
+ canon_after = [_canon_text(x) for x in after]
141
+ remaining = list(canon_after)
142
+ gone = []
143
+ for item, c in zip(before, canon_before, strict=True):
144
+ if c in remaining:
145
+ remaining.remove(c)
146
+ else:
147
+ gone.append(item)
148
+ if not gone and not remaining and canon_before != canon_after:
149
+ out.append({"op": "reordered", "path": _ptr(path)})
150
+ for item in gone:
151
+ out.append({"op": "removed", "path": _ptr(path), "element": True, "before": _cut(item, cuts)})
152
+ for c in remaining:
153
+ out.append({"op": "added", "path": _ptr(path), "element": True, "after": _cut(json.loads(c), cuts)})
154
+ elif _canon_text(before) != _canon_text(after):
155
+ out.append({"op": "changed", "path": _ptr(path), "before": _cut(before, cuts), "after": _cut(after, cuts)})
156
+
157
+
158
+ def diff(before: Any, after: Any) -> dict[str, Any]:
159
+ """What a pass changed, as the ``env.diff`` mapping ``{"format", "value": {"n_changes", "entries"}, "truncated"}``.
160
+
161
+ Lossy by design (value and entry caps); the digests, not the diff, decide whether two states are equal.
162
+ """
163
+ entries: list[dict[str, Any]] = []
164
+ cuts = [False]
165
+ _walk(normalise(before), normalise(after), [], entries, cuts)
166
+ return {
167
+ "format": DIFF_FORMAT,
168
+ "value": {"n_changes": len(entries), "entries": entries[:ENTRY_CAP]},
169
+ "truncated": cuts[0] or len(entries) > ENTRY_CAP,
170
+ }
171
+
172
+
173
+ def end_env(before: Any, after: Any, *, snapshot_ref: dict[str, Any] | None = None, with_diff: bool = True) -> dict[str, Any]:
174
+ """The ``env`` member of ``pass.end`` (E-5): the digest of ``after``, ``changed`` (digest differs from ``before``),
175
+ a ``diff`` only when changed (and ``with_diff``), and an optional ``snapshot`` blob reference."""
176
+ after_digest = digest(after)
177
+ out: dict[str, Any] = {**after_digest, "changed": digest(before) != after_digest}
178
+ if out["changed"] and with_diff:
179
+ out["diff"] = diff(before, after)
180
+ if snapshot_ref is not None:
181
+ out["snapshot"] = snapshot_ref
182
+ return out
@@ -0,0 +1,37 @@
1
+ """Exceptions of ``agentprov_capture``. Each carries a stable dotted ``code``; messages never carry secret values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["CaptureError", "CaptureLogError", "CaptureWriteError", "RedactionError"]
6
+
7
+
8
+ class CaptureError(Exception):
9
+ """Base class. ``code`` is stable and dotted (``cap.<area>.<what>``)."""
10
+
11
+ code = "cap.error"
12
+
13
+ def __init__(self, message: str, *, code: str | None = None) -> None:
14
+ super().__init__(message)
15
+ if code is not None:
16
+ self.code = code
17
+
18
+
19
+ class CaptureLogError(CaptureError):
20
+ """The log file or its state does not allow the operation (exists, closed, poisoned, too large, ...)."""
21
+
22
+ code = "cap.log.error"
23
+
24
+
25
+ class CaptureWriteError(CaptureError):
26
+ """An event, blob or call violates the capture/0.1 model; nothing was written."""
27
+
28
+ code = "cap.write.invalid"
29
+
30
+
31
+ class RedactionError(CaptureError):
32
+ """Redaction failed or its configuration is unusable (G.6.4, fail closed): nothing was written.
33
+
34
+ The message never includes the content being scanned; the original exception is deliberately not chained.
35
+ """
36
+
37
+ code = "cap.redact.failed"