accountable-surface 0.3.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.
Files changed (51) hide show
  1. accountable_surface/__init__.py +105 -0
  2. accountable_surface/action_receipt.py +226 -0
  3. accountable_surface/api_effector.py +287 -0
  4. accountable_surface/api_transport.py +34 -0
  5. accountable_surface/authority_cli.py +81 -0
  6. accountable_surface/authority_state.py +278 -0
  7. accountable_surface/authority_store.py +147 -0
  8. accountable_surface/authorized_actuation.py +108 -0
  9. accountable_surface/bounds.py +102 -0
  10. accountable_surface/browser_effector.py +280 -0
  11. accountable_surface/certify.py +54 -0
  12. accountable_surface/credentials.py +44 -0
  13. accountable_surface/effector.py +162 -0
  14. accountable_surface/escalator.py +241 -0
  15. accountable_surface/grant.py +43 -0
  16. accountable_surface/http_driver.py +151 -0
  17. accountable_surface/interop_mcp.py +283 -0
  18. accountable_surface/interop_runtime.py +291 -0
  19. accountable_surface/journal_chain.py +64 -0
  20. accountable_surface/mcp.py +174 -0
  21. accountable_surface/native_control_effector.py +294 -0
  22. accountable_surface/os_effector.py +115 -0
  23. accountable_surface/playwright_driver.py +153 -0
  24. accountable_surface/preconditions.py +69 -0
  25. accountable_surface/protected_paths.py +130 -0
  26. accountable_surface/read_authority.py +260 -0
  27. accountable_surface/read_scopes.py +161 -0
  28. accountable_surface/reference.py +145 -0
  29. accountable_surface/registry.py +246 -0
  30. accountable_surface/remote_actuation.py +145 -0
  31. accountable_surface/remote_durable.py +209 -0
  32. accountable_surface/server.py +229 -0
  33. accountable_surface/surface.py +495 -0
  34. accountable_surface/uia.py +284 -0
  35. accountable_surface/uia_effector.py +260 -0
  36. accountable_surface/uia_transport.py +71 -0
  37. accountable_surface/web_effector.py +198 -0
  38. accountable_surface/world/__init__.py +9 -0
  39. accountable_surface/world/pilot.py +235 -0
  40. accountable_surface/world/prompts.py +100 -0
  41. accountable_surface/world/reel.py +31 -0
  42. accountable_surface/world/screen.py +55 -0
  43. accountable_surface/world/server.py +404 -0
  44. accountable_surface/world/session.py +144 -0
  45. accountable_surface/world/sight.py +176 -0
  46. accountable_surface/world/structure.py +81 -0
  47. accountable_surface-0.3.1.dist-info/METADATA +350 -0
  48. accountable_surface-0.3.1.dist-info/RECORD +51 -0
  49. accountable_surface-0.3.1.dist-info/WHEEL +4 -0
  50. accountable_surface-0.3.1.dist-info/entry_points.txt +4 -0
  51. accountable_surface-0.3.1.dist-info/licenses/LICENSE +110 -0
@@ -0,0 +1,105 @@
1
+ """The Accountable Surface -- a live seam where a model perceives and acts only
2
+ through accountability: witnessed perception, a pre-execution gate, and a
3
+ tamper-evident, durable memory, under human stewardship.
4
+
5
+ Mission: "Senses and sensibility are what lead to the new frontier. Machines
6
+ learning to hold themselves accountable."
7
+
8
+ The core (`surface`) is stdlib + coherence-membrane + proof-surface. The live
9
+ MCP server (`server`) additionally needs `mcp` (the `[server]` extra).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .api_effector import (
15
+ GITHUB_ISSUE_COMMENTS,
16
+ ApiCall,
17
+ ApiEffector,
18
+ ApiOperation,
19
+ ApiService,
20
+ FakeApiDriver,
21
+ )
22
+ from .action_receipt import (
23
+ ActionReceiptReceptor,
24
+ receipt_from_outcome,
25
+ verify_receipts,
26
+ )
27
+ from .credentials import MissingCredential, has_secret, require_secret
28
+ from .effector import FilesystemEffector, Plan, RefusedActuation, Verdict
29
+ from .native_control_effector import (
30
+ SAFE_READ_VERBS,
31
+ FakeNativeControlRunner,
32
+ NativeControlListEffector,
33
+ NativeControlRunner,
34
+ NativeControlWriteEffector,
35
+ )
36
+ from .http_driver import HttpDriver, parse_html
37
+ from .os_effector import CommandEffector, SubprocessRunner
38
+ from .registry import EffectorRegistry, Exposed, load_effectors
39
+ from .reference import ArxivSource, FakeSource, Grounding, Reference, ReferenceCortex, parse_arxiv_atom
40
+ from .surface import (
41
+ AccountableSurface,
42
+ ActionOutcome,
43
+ ActuationOutcome,
44
+ GoalOutcome,
45
+ JournalEntry,
46
+ Step,
47
+ )
48
+ from .browser_effector import (
49
+ BrowserAction,
50
+ BrowserDriver,
51
+ BrowserEffector,
52
+ FakeBrowserDriver,
53
+ )
54
+ from .web_effector import FakePageDriver, WebAction, WebEffector
55
+
56
+ __all__ = [
57
+ "AccountableSurface",
58
+ "ActionOutcome",
59
+ "ActuationOutcome",
60
+ "GoalOutcome",
61
+ "Step",
62
+ "JournalEntry",
63
+ "FilesystemEffector",
64
+ "Plan",
65
+ "RefusedActuation",
66
+ "Verdict",
67
+ "WebEffector",
68
+ "WebAction",
69
+ "FakePageDriver",
70
+ "BrowserEffector",
71
+ "BrowserAction",
72
+ "BrowserDriver",
73
+ "FakeBrowserDriver",
74
+ "HttpDriver",
75
+ "parse_html",
76
+ "CommandEffector",
77
+ "SubprocessRunner",
78
+ "ApiEffector",
79
+ "ApiCall",
80
+ "ApiOperation",
81
+ "ApiService",
82
+ "FakeApiDriver",
83
+ "GITHUB_ISSUE_COMMENTS",
84
+ "MissingCredential",
85
+ "require_secret",
86
+ "has_secret",
87
+ "EffectorRegistry",
88
+ "Exposed",
89
+ "load_effectors",
90
+ "ReferenceCortex",
91
+ "Reference",
92
+ "Grounding",
93
+ "FakeSource",
94
+ "ArxivSource",
95
+ "parse_arxiv_atom",
96
+ "NativeControlListEffector",
97
+ "NativeControlWriteEffector",
98
+ "NativeControlRunner",
99
+ "FakeNativeControlRunner",
100
+ "SAFE_READ_VERBS",
101
+ "ActionReceiptReceptor",
102
+ "receipt_from_outcome",
103
+ "verify_receipts",
104
+ ]
105
+ __version__ = "0.3.1"
@@ -0,0 +1,226 @@
1
+ """The action-receipt receptor -- the missing writer for `project-telos.action-receipt/v1`.
2
+
3
+ The action-receipt contract (telos/demo/integrations/action-receipt-conventions.json)
4
+ defines a durable, independently auditable event for agent work: proposed, admitted,
5
+ executed, failed, and compensated actions, each carrying a verification verdict
6
+ (MATCH / DRIFT / UNVERIFIABLE) and an append-only persistence rule. Until now nothing
7
+ on the runtime path emitted one. This module is that receptor: it turns an
8
+ `AccountableSurface.actuate` outcome into a conformant event and appends it to a
9
+ hash-chained, append-only store, so a stranger holding only the file can re-derive
10
+ the seal offline (`verify_receipts`).
11
+
12
+ Two independent seals, both re-derivable with stdlib only:
13
+ * per-event content hash -- `receipts[].hash` over the event's semantic fields, so
14
+ editing any field (target, verdict, decision, ...) breaks it.
15
+ * chain hash -- `_hash = sha256(_prev | canonical(event))`, so deleting or reordering
16
+ an event breaks the linkage. This is the same construction as the surface journal
17
+ and `verify_journal.py`, applied to the exportable action-receipt stream.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from coherence_membrane.observation import sha256_hex
27
+
28
+ SCHEMA = "project-telos.action-receipt/v1"
29
+ RECEPTOR = {"name": "accountable-surface.native-control-receptor", "version": "0.1"}
30
+ GENESIS = ""
31
+
32
+
33
+ def canonical(value: Any) -> str:
34
+ """Deterministic JSON: sorted keys, tight separators. The re-derivable form."""
35
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
36
+
37
+
38
+ def _digest(value: Any) -> str:
39
+ return "sha256:" + sha256_hex(canonical(value).encode("utf-8"))
40
+
41
+
42
+ def _config_hash() -> str:
43
+ return _digest({"schema": SCHEMA, "receptor": RECEPTOR})
44
+
45
+
46
+ def _outcome_terms(outcome: Any) -> tuple[str, str, str, str]:
47
+ """Map an ActuationOutcome onto (event_type, result_state, stop_reason, verdict),
48
+ using only the contract's typed vocabularies. Documented in docs/native-control-bridge.md."""
49
+ acted = bool(getattr(outcome, "acted", False))
50
+ verified = bool(getattr(outcome, "verified", False))
51
+ decision = str(getattr(outcome, "decision", "deny"))
52
+ verdict_status = str(getattr(outcome, "verdict", ""))
53
+ if acted and verified:
54
+ return "execution_completed", "completed", "completed", "MATCH"
55
+ if acted and not verified:
56
+ return "execution_failed", "failed", "tool_error", "DRIFT"
57
+ if verdict_status == "refused-by-effector":
58
+ return "execution_failed", "failed", "binding_failed", "UNVERIFIABLE"
59
+ if decision == "deny":
60
+ return "execution_failed", "cancelled", "policy_denied", "UNVERIFIABLE"
61
+ if decision == "needs-human":
62
+ return "execution_failed", "cancelled", "verification_unverifiable", "UNVERIFIABLE"
63
+ return "execution_failed", "failed", "error", "UNVERIFIABLE"
64
+
65
+
66
+ def _external_request_id(nc: dict) -> str | None:
67
+ if not nc:
68
+ return None
69
+ return f"native-control:{nc.get('at', '')}:{sha256_hex(canonical(nc).encode('utf-8'))[:16]}"
70
+
71
+
72
+ def _identity_block(base_id: str, idempotency_key: str, created_at: str, principal: str,
73
+ action_kind: str, args_hash: str, side_effect_class: str,
74
+ intent_ref: str, authority_ref: str | None, decision: str) -> dict:
75
+ """The identity, component, action, and authority fields -- everything about WHO
76
+ acted and under WHAT permission, before the execution facts are joined in."""
77
+ return {
78
+ "schema": SCHEMA,
79
+ "event_id": f"evt_{base_id}",
80
+ "action_id": f"act_{base_id}",
81
+ "action_intent_id": f"intent_{base_id}",
82
+ "idempotency_key": idempotency_key,
83
+ "created_at": created_at,
84
+ "agent": {"principal": principal},
85
+ "component": {"name": RECEPTOR["name"], "version": RECEPTOR["version"], "config_hash": _config_hash()},
86
+ "action": {"kind": action_kind, "side_effect_class": side_effect_class, "args_hash": args_hash},
87
+ "intent_ref": intent_ref,
88
+ "authority_ref": authority_ref,
89
+ "policy": {"decision": decision, "ref": "policy:native-control-read-v1"},
90
+ }
91
+
92
+
93
+ def _execution_block(state: str, idempotency_key: str, outcome: Any, nc: dict) -> dict:
94
+ """The joinable execution facts: a durable external id, the idempotency key, the
95
+ redacted before/after digests, and the native-control receipt's own digest."""
96
+ return {
97
+ "terminal_status": state,
98
+ "external_request_id": _external_request_id(nc),
99
+ "idempotency_key": idempotency_key,
100
+ "redacted_before_ref": str(getattr(outcome, "before_digest", "")),
101
+ "redacted_after_ref": str(getattr(outcome, "after_digest", "") or ""),
102
+ "native_control_schema": nc.get("schema"),
103
+ "native_control_receipt_digest": _digest(nc) if nc else None,
104
+ }
105
+
106
+
107
+ def receipt_from_outcome(
108
+ outcome: Any,
109
+ *,
110
+ action_kind: str,
111
+ target: str,
112
+ args_hash: str,
113
+ native_control_receipt: dict | None = None,
114
+ principal: str = "agent:accountable-surface.native-control",
115
+ intent_ref: str = "intent:native-control-read",
116
+ authority_ref: str | None = None,
117
+ idempotency_key: str,
118
+ created_at: str,
119
+ side_effect_class: str = "read",
120
+ reversible: bool = True,
121
+ ) -> dict:
122
+ """Build a `project-telos.action-receipt/v1` event from an actuation outcome.
123
+
124
+ IDs are derived from the invocation and the injected `created_at`, so a fixed clock
125
+ yields a byte-stable event. `receipts[].hash` is computed last, over every field
126
+ except itself, so it seals the event's semantic content."""
127
+ event_type, state, stop_reason, verdict = _outcome_terms(outcome)
128
+ nc = native_control_receipt or {}
129
+ base_id = sha256_hex(f"{action_kind}|{target}|{args_hash}|{created_at}".encode("utf-8"))[:24]
130
+ before = str(getattr(outcome, "before_digest", ""))
131
+ core = {
132
+ **_identity_block(base_id, idempotency_key, created_at, principal, action_kind,
133
+ args_hash, side_effect_class, intent_ref, authority_ref,
134
+ str(getattr(outcome, "decision", "deny"))),
135
+ "event_type": event_type,
136
+ "input_materials": [{"ref": f"dir://{target}", "digest": before}],
137
+ "side_effect": {"class": side_effect_class, "reversible": reversible},
138
+ "execution": _execution_block(state, idempotency_key, outcome, nc),
139
+ "evidence_ref": _digest({"before": before, "after": getattr(outcome, "after_digest", "")}),
140
+ "verification": {"verdict": verdict, "ref": "accountable-surface:native-control-list"},
141
+ "result": {"state": state, "stop_reason": stop_reason},
142
+ "retry": {"attempt": 1, "max_attempts": 1},
143
+ "compensation_ref": None,
144
+ "persistence": {"append_only": True, "storage_ref": f"receipt:{action_kind}/evt_{base_id}"},
145
+ "certificate": dict(getattr(outcome, "certificate", {}) or {}),
146
+ }
147
+ core["receipts"] = [{"kind": "content", "hash": _digest(core)}]
148
+ return core
149
+
150
+
151
+ class ActionReceiptReceptor:
152
+ """Append-only, hash-chained writer for action-receipt/v1 events. `emit` returns the
153
+ persistence receipt the contract's receptor adapter promises: event_id, action_id,
154
+ write_hash, and storage_ref."""
155
+
156
+ def __init__(self, store_path: str | Path) -> None:
157
+ self._path = Path(store_path)
158
+ self._head = _chain_head(self._path)
159
+
160
+ def emit(self, event: dict) -> dict:
161
+ prev = self._head
162
+ line_hash = sha256_hex(f"{prev}|{canonical(event)}".encode("utf-8"))
163
+ record = {**event, "_prev": prev, "_hash": line_hash}
164
+ self._path.parent.mkdir(parents=True, exist_ok=True)
165
+ with self._path.open("a", encoding="utf-8") as handle:
166
+ handle.write(canonical(record) + "\n")
167
+ self._head = line_hash
168
+ return {
169
+ "event_id": event["event_id"],
170
+ "action_id": event["action_id"],
171
+ "write_hash": line_hash,
172
+ "storage_ref": event["persistence"]["storage_ref"],
173
+ }
174
+
175
+
176
+ def _chain_head(path: str | Path) -> str:
177
+ p = Path(path)
178
+ if not p.exists():
179
+ return GENESIS
180
+ head = GENESIS
181
+ for line in p.read_text(encoding="utf-8").splitlines():
182
+ stripped = line.strip()
183
+ if not stripped:
184
+ continue
185
+ try:
186
+ rec = json.loads(stripped)
187
+ head = rec.get("_hash", head)
188
+ except ValueError:
189
+ continue
190
+ return head
191
+
192
+
193
+ def verify_receipts(text: str) -> tuple[str, str]:
194
+ """Re-derive the receipt stream offline. Returns (label, detail):
195
+ MATCH -- every content hash and chain link re-derives.
196
+ DRIFT -- a content hash or chain linkage does not re-derive (edit/reorder/delete).
197
+ UNVERIFIABLE -- a line will not parse or is missing the fields to re-derive.
198
+ Depends only on stdlib + the shared canonical form; a stranger with the file runs it."""
199
+ head = GENESIS
200
+ seq = 0
201
+ for line in text.splitlines():
202
+ stripped = line.strip()
203
+ if not stripped:
204
+ continue
205
+ try:
206
+ rec = json.loads(stripped)
207
+ except ValueError:
208
+ return "UNVERIFIABLE", f"line {seq} is not valid JSON"
209
+ if not isinstance(rec, dict) or "_hash" not in rec or "receipts" not in rec:
210
+ return "UNVERIFIABLE", f"entry {seq} is missing chain or receipt fields"
211
+ event = {k: v for k, v in rec.items() if k not in ("_prev", "_hash")}
212
+ core = {k: v for k, v in event.items() if k != "receipts"}
213
+ try:
214
+ stored_content = rec["receipts"][0]["hash"]
215
+ except (KeyError, IndexError, TypeError):
216
+ return "UNVERIFIABLE", f"entry {seq} carries no content hash to re-derive"
217
+ if _digest(core) != stored_content:
218
+ return "DRIFT", f"entry {seq} content hash does not re-derive from its fields"
219
+ expected = sha256_hex(f"{head}|{canonical(event)}".encode("utf-8"))
220
+ if rec.get("_prev") != head:
221
+ return "DRIFT", f"entry {seq} _prev does not link to the running chain head"
222
+ if rec.get("_hash") != expected:
223
+ return "DRIFT", f"entry {seq} _hash does not re-derive from its fields"
224
+ head = rec["_hash"]
225
+ seq += 1
226
+ return "MATCH", f"chain intact over {seq} receipts"
@@ -0,0 +1,287 @@
1
+ """API actuation for the efferent arm -- write through a third party's OFFICIAL API,
2
+ under the same Effector contract as the other four.
3
+
4
+ The answer to "the agents operate our API, and our API operates theirs". What a
5
+ remote agent hands in is an intent: `post_comment` and a body. It cannot name a
6
+ credential, cannot set a header, cannot choose a host, and cannot read a token
7
+ back, because the secret is resolved inside `act` from an environment variable the
8
+ agent has no way to address.
9
+
10
+ Three bounds, each enforced at the moment of the call rather than trusted from the
11
+ plan:
12
+
13
+ * the service allowlist -- method, host, and path SHAPE. An intent the service
14
+ does not declare is refused; a target path the intent's shape does not match is
15
+ refused, so `post_comment` cannot reach an admin route.
16
+ * the gate allow -- the receipt has to be bound to this exact plan.
17
+ * the credential -- drawn through `require_secret` at call time and sent in a
18
+ header. The token is checked against the URL first, because the URL is what the
19
+ surface witnesses into the journal and a secret must never land there.
20
+
21
+ Verification reads the RESOURCE, never the response. A service that answers 201 and
22
+ drops the write is the obvious way a passing verify could accept a wrong result, and
23
+ re-reading the collection is the only thing that catches it. `tests/test_false_success.py`
24
+ holds that case.
25
+
26
+ `FakeApiDriver` makes the whole contract testable offline with no network and no real
27
+ credential. The stdlib transport lives in `api_transport.py`, deliberately apart.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from dataclasses import dataclass, field
35
+ from typing import Any
36
+
37
+ from coherence_membrane.observation import Observation, Provenance, Status, sha256_hex
38
+
39
+ from accountable_surface.credentials import require_secret
40
+ from accountable_surface.effector import Plan, RefusedActuation, Verdict
41
+
42
+
43
+ def _canon(obj: Any) -> bytes:
44
+ return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ApiOperation:
49
+ """One thing a remote agent may ask for, named by intent rather than by route."""
50
+
51
+ intent: str # what the agent names: "post_comment"
52
+ action_kind: str # what the gate authorizes: "api.post"
53
+ method: str
54
+ path_shape: str # anchored regex the target path must match; named groups feed the undo
55
+ undo_method: str = "" # empty when the service offers no undo -> the plan is irreversible
56
+ undo_shape: str = "" # e.g. "/repos/{owner}/{repo}/issues/comments/{id}"
57
+
58
+ @property
59
+ def reversible(self) -> bool:
60
+ return bool(self.undo_method)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ApiService:
65
+ """A single third-party API, and the complete set of writes allowed against it."""
66
+
67
+ name: str
68
+ host: str
69
+ auth_env: str
70
+ operations: tuple[ApiOperation, ...]
71
+ auth_scheme: str = "Bearer"
72
+ scheme: str = "https"
73
+
74
+ @property
75
+ def origin(self) -> str:
76
+ return f"{self.scheme}://{self.host}"
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ApiCall:
81
+ """What the agent hands in. No token, no header, no host, no method."""
82
+
83
+ intent: str
84
+ body: dict = field(default_factory=dict)
85
+
86
+
87
+ GITHUB_ISSUE_COMMENTS = ApiService(
88
+ name="github",
89
+ host="api.github.com",
90
+ auth_env="ACCOUNTABLE_SURFACE_GITHUB_TOKEN",
91
+ operations=(
92
+ ApiOperation(
93
+ intent="post_comment",
94
+ action_kind="api.post",
95
+ method="POST",
96
+ path_shape=r"/repos/(?P<owner>[^/]+)/(?P<repo>[^/]+)/issues/(?P<number>\d+)/comments",
97
+ undo_method="DELETE",
98
+ undo_shape="/repos/{owner}/{repo}/issues/comments/{id}",
99
+ ),
100
+ ),
101
+ )
102
+
103
+
104
+ class FakeApiDriver:
105
+ """Deterministic in-memory API for tests and offline demos.
106
+
107
+ Holds collections keyed by path. A POST appends a member and assigns an id; a
108
+ DELETE removes one by id. Records every request it was handed, so a test can
109
+ assert what did and did not travel (a credential, above all).
110
+ """
111
+
112
+ def __init__(self, collections: dict[str, list[dict]] | None = None) -> None:
113
+ self._collections: dict[str, list[dict]] = collections or {}
114
+ self._next_id = 1
115
+ self.requests: list[dict] = []
116
+
117
+ def request(self, method: str, url: str, headers: dict, body: bytes | None) -> dict:
118
+ self.requests.append({"method": method, "url": url, "headers": dict(headers), "body": body})
119
+ path = url.split("//", 1)[-1].split("/", 1)[-1]
120
+ path = "/" + path if not path.startswith("/") else path
121
+ if method == "GET":
122
+ return {"status": 200, "body": _canon(self._collections.get(path, []))}
123
+ if method == "POST":
124
+ member = dict(json.loads(body or b"{}"))
125
+ member["id"] = self._next_id
126
+ self._next_id += 1
127
+ self._collections.setdefault(path, []).append(member)
128
+ return {"status": 201, "body": _canon(member)}
129
+ if method == "DELETE":
130
+ wanted = path.rsplit("/", 1)[-1]
131
+ for members in self._collections.values():
132
+ members[:] = [m for m in members if str(m.get("id")) != wanted]
133
+ return {"status": 204, "body": b""}
134
+ return {"status": 405, "body": b""}
135
+
136
+
137
+ class ApiEffector:
138
+ """Writes through one service's official API, bounded by that service's declared
139
+ operations, acting only on a gate allow for the exact plan, verified by re-reading
140
+ the resource rather than by believing the response."""
141
+
142
+ name = "api-effector"
143
+
144
+ def __init__(self, driver: Any, service: ApiService) -> None:
145
+ self._driver = driver
146
+ self._service = service
147
+ self._planned: dict[str, tuple[ApiOperation, dict]] = {} # plan.digest -> intent (no secret)
148
+ self._prior: dict[str, str] = {} # plan.digest -> the undo path, resolved at act time
149
+
150
+ def bound(self) -> dict:
151
+ """Origin AND intents: both decide how far a gate allow can travel."""
152
+ return {
153
+ "kind": "api",
154
+ "origins": [self._service.origin],
155
+ "intents": sorted(op.intent for op in self._service.operations),
156
+ }
157
+
158
+ # --- perception ----------------------------------------------------------
159
+
160
+ def perceive(self, target: str) -> Observation:
161
+ """A witnessed read of the resource the action will change. This is the
162
+ before-state, and it is what makes verification possible at all."""
163
+ url = self._url(target)
164
+ response = self._send("GET", url, None)
165
+ members = self._members(response.get("body") or b"")
166
+ return Observation(
167
+ organ=self.name,
168
+ subject=url,
169
+ summary=f"{self._service.name} {target}: {len(members)} members",
170
+ # a read that did not land establishes nothing about the resource
171
+ status=Status.PASS if response.get("status") == 200 else Status.UNVERIFIED,
172
+ provenance=Provenance.witness_bytes(url, _canon(members), "high"),
173
+ data={"url": url, "status": response.get("status"), "members": members,
174
+ "sha256": sha256_hex(_canon(members))},
175
+ )
176
+
177
+ # --- the efferent contract ----------------------------------------------
178
+
179
+ def preview(self, target: str, call: ApiCall, before: Observation | None = None) -> Plan:
180
+ """Resolve the intent against the service allowlist and content-address the
181
+ request. No side effect, no credential read, no network."""
182
+ op = self._operation(call.intent)
183
+ if re.fullmatch(op.path_shape, target) is None:
184
+ raise RefusedActuation(
185
+ f"target {target!r} does not match the path shape for intent {call.intent!r}")
186
+ body = _canon(call.body)
187
+ url = self._url(target)
188
+ content_sha = sha256_hex(body)
189
+ digest = "sha256:" + sha256_hex(f"{op.action_kind}|{url}|{content_sha}".encode("utf-8"))
190
+ self._planned[digest] = (op, dict(call.body))
191
+ return Plan(op.action_kind, url, content_sha, op.reversible, False, digest)
192
+
193
+ def act(self, plan: Plan, allow_receipt: Any, call: ApiCall) -> Observation:
194
+ """Send the request. Refuses without a gate allow bound to this plan, and
195
+ resolves the credential only here, at the moment of the call."""
196
+ if getattr(allow_receipt, "decision", None) != "allow":
197
+ raise RefusedActuation("no gate allow -- the effector will not call anything")
198
+ request = getattr(allow_receipt, "request", {}) or {}
199
+ planned = request.get("planned_action", {}) if isinstance(request, dict) else {}
200
+ if planned.get("action_kind") != plan.action_kind or planned.get("target") != plan.target:
201
+ raise RefusedActuation("allow receipt does not match the plan's action/target")
202
+ op, _ = self._planned.get(plan.digest, (None, None))
203
+ if op is None or op.intent != call.intent:
204
+ raise RefusedActuation("call does not match the previewed (authorized) plan")
205
+ body = _canon(call.body)
206
+ if sha256_hex(body) != plan.content_sha256:
207
+ raise RefusedActuation("request body does not match the previewed (authorized) plan")
208
+ response = self._send(op.method, plan.target, body)
209
+ if op.reversible:
210
+ self._prior[plan.digest] = self._undo_path(op, plan.target, response)
211
+ return self.perceive(self._path(plan.target))
212
+
213
+ def verify(self, plan: Plan, after: Observation) -> Verdict:
214
+ """Does the RESOURCE now carry the intent? Re-reads the collection; a 201 on
215
+ the write is not evidence and is never consulted here."""
216
+ op, intent = self._planned.get(plan.digest, (None, None))
217
+ if intent is None:
218
+ return Verdict("failed", "no previewed intent for this plan")
219
+ for member in after.data.get("members", []):
220
+ projection = {k: member.get(k) for k in intent if k in member}
221
+ if projection == intent:
222
+ return Verdict("pass", "resource carries the intent")
223
+ return Verdict("failed", "resource does NOT carry the intent (the write did not land)")
224
+
225
+ def rollback(self, plan: Plan) -> Observation:
226
+ """The service's own undo, where it declares one."""
227
+ path = self._prior.get(plan.digest)
228
+ if path is None:
229
+ raise RefusedActuation("no undo recorded for this plan -- the call is irreversible")
230
+ op, _ = self._planned[plan.digest]
231
+ self._send(op.undo_method, self._url(path), None)
232
+ return self.perceive(self._path(plan.target))
233
+
234
+ def selftest(self) -> bool:
235
+ """Falsifiable: an act without a gate allow must raise and send nothing."""
236
+ driver = FakeApiDriver({"/repos/o/r/issues/1/comments": []})
237
+ effector = ApiEffector(driver, GITHUB_ISSUE_COMMENTS)
238
+ call = ApiCall("post_comment", {"body": "hi"})
239
+ plan = effector.preview("/repos/o/r/issues/1/comments", call)
240
+ try:
241
+ effector.act(plan, allow_receipt=None, call=call)
242
+ return False
243
+ except RefusedActuation:
244
+ return not driver.requests
245
+
246
+ # --- internals -----------------------------------------------------------
247
+
248
+ def _operation(self, intent: str) -> ApiOperation:
249
+ for op in self._service.operations:
250
+ if op.intent == intent:
251
+ return op
252
+ declared = sorted(o.intent for o in self._service.operations)
253
+ raise RefusedActuation(f"intent {intent!r} is not declared by {self._service.name}: {declared}")
254
+
255
+ def _url(self, target: str) -> str:
256
+ return target if target.startswith(self._service.origin) else self._service.origin + target
257
+
258
+ def _path(self, url: str) -> str:
259
+ return url[len(self._service.origin):] if url.startswith(self._service.origin) else url
260
+
261
+ def _members(self, payload: bytes) -> list[dict]:
262
+ try:
263
+ data = json.loads(payload or b"[]")
264
+ except json.JSONDecodeError:
265
+ return []
266
+ if isinstance(data, dict):
267
+ return [data]
268
+ return [m for m in data if isinstance(m, dict)] if isinstance(data, list) else []
269
+
270
+ def _undo_path(self, op: ApiOperation, url: str, response: dict) -> str:
271
+ parts = re.fullmatch(op.path_shape, self._path(url))
272
+ created = self._members(response.get("body") or b"")
273
+ ident = created[0].get("id") if created else None
274
+ return op.undo_shape.format(id=ident, **(parts.groupdict() if parts else {}))
275
+
276
+ def _send(self, method: str, url: str, body: bytes | None) -> dict:
277
+ """The single place a credential is read, and the single place one leaves."""
278
+ token = require_secret(self._service.auth_env)
279
+ if token in url:
280
+ # the URL is witnessed into the journal; the secret travels in a header only
281
+ raise RefusedActuation("the credential must not appear in the URL; it is sent as a header")
282
+ if not url.startswith(self._service.origin + "/"):
283
+ raise RefusedActuation(f"url {url!r} is outside the service origin {self._service.origin}")
284
+ headers = {"Authorization": f"{self._service.auth_scheme} {token}", "Accept": "application/json"}
285
+ if body is not None:
286
+ headers["Content-Type"] = "application/json"
287
+ return self._driver.request(method, url, headers, body)
@@ -0,0 +1,34 @@
1
+ """The stdlib transport behind `ApiEffector`. Kept apart from the effector on purpose.
2
+
3
+ Zero dependencies: `urllib.request` and nothing else, matching the rest of this
4
+ repository's native posture. It carries no policy. Every bound that matters (the
5
+ service allowlist, the path shape, the gate allow, the credential door) is enforced
6
+ in `ApiEffector` before a request reaches here, so this file is a socket and not a
7
+ gate.
8
+
9
+ Honest null: this driver has no test coverage. Exercising it needs a network and a
10
+ real credential, and the suite has neither by design, so `FakeApiDriver` is what the
11
+ tests drive. Treat a first real call as unproven and watch it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+
20
+ class UrllibApiDriver:
21
+ """Sends one request and returns `{"status", "body"}`. Raises nothing on a 4xx or
22
+ 5xx: an error status is a fact the effector's verify has to see, not an exception
23
+ that hides what the service said."""
24
+
25
+ def __init__(self, timeout: float = 20.0) -> None:
26
+ self._timeout = timeout
27
+
28
+ def request(self, method: str, url: str, headers: dict, body: bytes | None) -> dict:
29
+ request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
30
+ try:
31
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
32
+ return {"status": response.status, "body": response.read()}
33
+ except urllib.error.HTTPError as exc:
34
+ return {"status": exc.code, "body": exc.read()}