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.
- accountable_surface/__init__.py +105 -0
- accountable_surface/action_receipt.py +226 -0
- accountable_surface/api_effector.py +287 -0
- accountable_surface/api_transport.py +34 -0
- accountable_surface/authority_cli.py +81 -0
- accountable_surface/authority_state.py +278 -0
- accountable_surface/authority_store.py +147 -0
- accountable_surface/authorized_actuation.py +108 -0
- accountable_surface/bounds.py +102 -0
- accountable_surface/browser_effector.py +280 -0
- accountable_surface/certify.py +54 -0
- accountable_surface/credentials.py +44 -0
- accountable_surface/effector.py +162 -0
- accountable_surface/escalator.py +241 -0
- accountable_surface/grant.py +43 -0
- accountable_surface/http_driver.py +151 -0
- accountable_surface/interop_mcp.py +283 -0
- accountable_surface/interop_runtime.py +291 -0
- accountable_surface/journal_chain.py +64 -0
- accountable_surface/mcp.py +174 -0
- accountable_surface/native_control_effector.py +294 -0
- accountable_surface/os_effector.py +115 -0
- accountable_surface/playwright_driver.py +153 -0
- accountable_surface/preconditions.py +69 -0
- accountable_surface/protected_paths.py +130 -0
- accountable_surface/read_authority.py +260 -0
- accountable_surface/read_scopes.py +161 -0
- accountable_surface/reference.py +145 -0
- accountable_surface/registry.py +246 -0
- accountable_surface/remote_actuation.py +145 -0
- accountable_surface/remote_durable.py +209 -0
- accountable_surface/server.py +229 -0
- accountable_surface/surface.py +495 -0
- accountable_surface/uia.py +284 -0
- accountable_surface/uia_effector.py +260 -0
- accountable_surface/uia_transport.py +71 -0
- accountable_surface/web_effector.py +198 -0
- accountable_surface/world/__init__.py +9 -0
- accountable_surface/world/pilot.py +235 -0
- accountable_surface/world/prompts.py +100 -0
- accountable_surface/world/reel.py +31 -0
- accountable_surface/world/screen.py +55 -0
- accountable_surface/world/server.py +404 -0
- accountable_surface/world/session.py +144 -0
- accountable_surface/world/sight.py +176 -0
- accountable_surface/world/structure.py +81 -0
- accountable_surface-0.3.1.dist-info/METADATA +350 -0
- accountable_surface-0.3.1.dist-info/RECORD +51 -0
- accountable_surface-0.3.1.dist-info/WHEEL +4 -0
- accountable_surface-0.3.1.dist-info/entry_points.txt +4 -0
- 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()}
|