attestlayer 0.1.0__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.
- attest/README.md +230 -0
- attest/__init__.py +61 -0
- attest/adapters/__init__.py +2 -0
- attest/adapters/claude_agent_sdk.py +131 -0
- attest/adapters/crewai.py +100 -0
- attest/adapters/deerflow.py +15 -0
- attest/adapters/langgraph.py +259 -0
- attest/adapters/openai_agents.py +92 -0
- attest/cli.py +215 -0
- attest/cloud.py +297 -0
- attest/core.py +496 -0
- attest/descriptor.py +94 -0
- attest/digest.py +107 -0
- attest/exceptions.py +31 -0
- attest/gate/__init__.py +168 -0
- attest/gate/console.py +76 -0
- attest/gate/email.py +56 -0
- attest/gate/interrupt.py +42 -0
- attest/gate/links.py +23 -0
- attest/gate/slack.py +153 -0
- attest/gate/store.py +177 -0
- attest/gate/teams.py +42 -0
- attest/gate/webhook.py +53 -0
- attest/gateway/__init__.py +1 -0
- attest/gateway/server.py +368 -0
- attest/ledger/__init__.py +6 -0
- attest/ledger/anchor.py +148 -0
- attest/ledger/checkpoints.py +73 -0
- attest/ledger/exports.py +178 -0
- attest/ledger/hashchain.py +53 -0
- attest/ledger/local_sqlite.py +220 -0
- attest/ledger/models.py +101 -0
- attest/ledger/signing.py +56 -0
- attest/mcp/__init__.py +1 -0
- attest/mcp/__main__.py +3 -0
- attest/mcp/launcher.py +41 -0
- attest/mcp/proxy.py +354 -0
- attest/mcp/server.py +202 -0
- attest/policy/__init__.py +14 -0
- attest/policy/engine.py +121 -0
- attest/policy/rules.py +130 -0
- attest/policy/yaml_loader.py +174 -0
- attest/registry/__init__.py +74 -0
- attest/registry/data.json +592 -0
- attest/registry/mcp_names.py +63 -0
- attest/registry/sdk_names.py +25 -0
- attest/registry/systems.py +125 -0
- attest/registry/url_patterns.py +93 -0
- attest/registry/verbs.py +125 -0
- attest/server.py +247 -0
- attest/telemetry.py +59 -0
- attest/verify/__init__.py +3 -0
- attest/verify/drivers/__init__.py +0 -0
- attest/verify/drivers/ack.py +76 -0
- attest/verify/drivers/convention.py +76 -0
- attest/verify/drivers/custom.py +19 -0
- attest/verify/drivers/openapi.py +126 -0
- attest/verify/drivers/recipe.py +46 -0
- attest/verify/ladder.py +152 -0
- attest/verify/match.py +115 -0
- attest/verify/readers.py +418 -0
- attest/verify/recipes/__init__.py +59 -0
- attest/verify/recipes/declarative.py +124 -0
- attest/verify/recipes/generate.py +140 -0
- attest/verify/recipes/gmail.py +117 -0
- attest/verify/recipes/google.py +151 -0
- attest/verify/recipes/hubspot.py +79 -0
- attest/verify/recipes/linear.py +84 -0
- attest/verify/recipes/m365.py +125 -0
- attest/verify/recipes/notion.py +96 -0
- attest/verify/recipes/slack.py +66 -0
- attest/verify/refs.py +70 -0
- attestlayer-0.1.0.dist-info/METADATA +137 -0
- attestlayer-0.1.0.dist-info/RECORD +77 -0
- attestlayer-0.1.0.dist-info/WHEEL +4 -0
- attestlayer-0.1.0.dist-info/entry_points.txt +6 -0
- attestlayer-0.1.0.dist-info/licenses/LICENSE +21 -0
attest/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# attest (Python SDK)
|
|
2
|
+
|
|
3
|
+
Decide → Gate → Verify → Attest, for any function that makes an agent act.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install -e ".[dev]" # from the repo root
|
|
7
|
+
pytest -q
|
|
8
|
+
ATTEST_AUTO_APPROVE=1 python examples/unknown_app.py
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import attest
|
|
15
|
+
|
|
16
|
+
@attest.action(system="gmail", verb="send", target="to")
|
|
17
|
+
def send_email(to, subject, body):
|
|
18
|
+
return gmail.users().messages().send(userId="me", body=...).execute()
|
|
19
|
+
|
|
20
|
+
send_email("arun@newco.com", "Follow-up", "…")
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
What happens on that call:
|
|
24
|
+
|
|
25
|
+
1. **Decide** — the call becomes an Action Descriptor (`gmail.send` → `arun@newco.com`). Built-in rules
|
|
26
|
+
classify the recipient as external and the default policy says `ask`.
|
|
27
|
+
2. **Gate** — the console prompts `y / n / e`. `ATTEST_AUTO_APPROVE=1` approves in CI; a non-interactive
|
|
28
|
+
stdin rejects. Slack and web inbox arrive in P1.3.
|
|
29
|
+
3. **Execute** — your function runs. Attest never calls the vendor to write.
|
|
30
|
+
4. **Verify** — the response carried an id ⇒ `acknowledged` (L1). Pass `verify=` for your own check (L2).
|
|
31
|
+
Read-back recipes (L3) arrive in P1.2.
|
|
32
|
+
5. **Attest** — one hash-chained row in `.attest/ledger.sqlite` (or `$ATTEST_LEDGER`), with params and result
|
|
33
|
+
stored as hashes plus an allow-listed preview.
|
|
34
|
+
|
|
35
|
+
Nothing about how you built the agent changed.
|
|
36
|
+
|
|
37
|
+
## Detection without labels
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
@attest.action # send_email ⇒ unknown / send
|
|
41
|
+
def send_email(to, body): ...
|
|
42
|
+
|
|
43
|
+
@attest.action(method="POST", url="https://api.someweirdcrm.io/v2/leads") # ⇒ someweirdcrm / create
|
|
44
|
+
def create_lead(name, email): ...
|
|
45
|
+
|
|
46
|
+
@attest.action(tool_name="hubspot_update_deal") # ⇒ hubspot / update
|
|
47
|
+
def update_deal(deal_id, **props): ...
|
|
48
|
+
|
|
49
|
+
attest.register("acme_internal_tool", system="acme", verb="pay") # your own override
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Policy (YAML)
|
|
53
|
+
|
|
54
|
+
`$ATTEST_POLICY` or `./attest.yaml`; first match wins; built-in rules R0–R4 run first.
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
policies:
|
|
58
|
+
- match: { verb: [send, share], target: external }
|
|
59
|
+
decision: ask
|
|
60
|
+
- match: { verb: [delete, pay] }
|
|
61
|
+
decision: ask
|
|
62
|
+
approvers: [finance-leads]
|
|
63
|
+
- match: { system: hubspot, verb: update }
|
|
64
|
+
decision: act
|
|
65
|
+
- match: { target_domain: [competitor.com] }
|
|
66
|
+
decision: refuse
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Match keys: `system`, `verb`, `target` (internal | known | external | none), `target_domain`, `actor`, `agent`,
|
|
70
|
+
`risk`, `action` (`gmail.send`, globs allowed). Decisions: `act`, `ask`, `refuse`.
|
|
71
|
+
|
|
72
|
+
## Read-back (L3) with your own credentials
|
|
73
|
+
|
|
74
|
+
Give Attest the client or token the agent already holds; it reads the system of record in-process and
|
|
75
|
+
compares intent with what is there. Nothing leaves your process.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
at = Attest(readers={"gmail": gmail_service, # googleapiclient resource, or an OAuth token string
|
|
79
|
+
"slack": slack_web_client, # slack_sdk WebClient, or an xoxb token
|
|
80
|
+
"hubspot": hubspot_client}, # hubspot Client, or a private-app token
|
|
81
|
+
http_get=lambda url, params=None: session.get(url, params=params).json()) # any REST API
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| system | write | read-back | compares |
|
|
85
|
+
| --- | --- | --- | --- |
|
|
86
|
+
| gmail | send / reply | `messages.get` | SENT label, every intended recipient in To/Cc, subject, reply thread |
|
|
87
|
+
| gmail | create draft | `drafts.get` | exists, recipients, subject |
|
|
88
|
+
| gmail | update labels | `messages.get` | added ⊂ labels, removed ∩ labels = ∅ |
|
|
89
|
+
| slack | send | `conversations.history` (or `.replies`) | ts, text, thread_ts |
|
|
90
|
+
| slack | create channel | `conversations.info` | exists, name, is_private |
|
|
91
|
+
| hubspot | create / update any object | `GET crm/v3/objects/{type}/{id}` | id, every intended property |
|
|
92
|
+
| calendar | create / update event | `events.get` | confirmed, summary, start / end, attendees |
|
|
93
|
+
| drive | create / upload / update file | `files.get` | name, mimeType, parents, not trashed |
|
|
94
|
+
| drive | share | `permissions.list` | every intended email present, role |
|
|
95
|
+
| docs | create / append | `documents.get` | title, appended text present |
|
|
96
|
+
| sheets | create / write values | `spreadsheets.get` / `values.get` | title, every written row present |
|
|
97
|
+
| notion | create / update page | `pages.retrieve` | title, status, select, text, number… properties, parent |
|
|
98
|
+
| notion | create database | `databases.retrieve` | exists, title |
|
|
99
|
+
| linear | create / update issue, project, comment | GraphQL `issue` / `project` / `comment` | title, priority, state, assignee, team, body |
|
|
100
|
+
| outlook | send / reply | sent-items search | found, every recipient, subject |
|
|
101
|
+
| outlook | create / update event | `me/events/{id}` | subject, start / end, attendees, not cancelled |
|
|
102
|
+
| teams | send | channel / chat message | text |
|
|
103
|
+
| *anything REST* | create / update | convention: `GET <url>/<returned id>` | id, every intended field the record carries |
|
|
104
|
+
| *anything with an OpenAPI spec* | create / update | `OpenApiDriver(spec, http_get)` — spec-derived GET path, nested collections, never guesses | id, every intended field |
|
|
105
|
+
|
|
106
|
+
Per action: `@at.action(..., reader=gmail_service)` or `http_get=…`. A read-back that finds the record but
|
|
107
|
+
nothing to compare is `acknowledged` with `exists: true`, not `verified`.
|
|
108
|
+
|
|
109
|
+
## LangGraph / LangChain
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from attest.adapters import langgraph as attest_lg
|
|
113
|
+
|
|
114
|
+
mapping = {"send_email": {"system": "gmail", "verb": "send", "target": "to"},
|
|
115
|
+
"update_deal": {"system": "hubspot", "verb": "update", "target": "deal_id"}}
|
|
116
|
+
tools = attest_lg.wrap_tools([send_email, update_deal], at, mapping=mapping) # before building the graph
|
|
117
|
+
attest_lg.wrap(graph, at, mapping=mapping) # or patch an existing graph's ToolNode
|
|
118
|
+
create_agent(model, tools, middleware=[attest_lg.AttestMiddleware(at, mapping=mapping)]) # langchain ≥ 1
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Refusals and rejections come back to the model as an error ToolMessage; the ledger records them either way.
|
|
122
|
+
Unmapped tools are inferred from their name. `pip install "attestlayer[langgraph]"`.
|
|
123
|
+
|
|
124
|
+
## Gate modes: Slack, web inbox, webhook, LangGraph interrupt, MCP pending
|
|
125
|
+
|
|
126
|
+
One contract, several ways to pause (doc 03 §6):
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from attest import Attest, StoreGate, PendingStore
|
|
130
|
+
from attest.gate.slack import SlackNotifier
|
|
131
|
+
from attest.gate.webhook import WebhookNotifier
|
|
132
|
+
|
|
133
|
+
store = PendingStore(".attest/ledger.sqlite")
|
|
134
|
+
at = Attest(gate=StoreGate(store, wait=True, timeout_s=900, notifiers=[
|
|
135
|
+
SlackNotifier("xoxb-…", "#agent-approvals", inbox_url="http://localhost:8321"), # card with Approve / Reject
|
|
136
|
+
WebhookNotifier("https://your.app/attest", secret="…", confirm_url="http://localhost:8321"),
|
|
137
|
+
]))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
| mode | gate | what happens on `ask` |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| sync-block | `ConsoleGate()` | terminal prompt `y / n / e` |
|
|
143
|
+
| sync-block | `StoreGate(wait=True, notifiers=…)` | request persisted, Slack card / webhook / inbox notified, call blocks until a human decides (or `timeout_s` ⇒ expired ⇒ rejected) |
|
|
144
|
+
| pending | `StoreGate(wait=False)` | raises `ActionPending(resume_token)`; later `fn.resume(token)` / `at.resume(token)` executes once approved |
|
|
145
|
+
| async-interrupt | `InterruptGate()` | LangGraph `interrupt()` with the request as payload; `Command(resume={"status": "approved"})` continues |
|
|
146
|
+
|
|
147
|
+
Decisions can come from anywhere that reaches the store: the Slack buttons (`POST /slack/interact` on the inbox
|
|
148
|
+
server, signature-verified), the web inbox, `POST /confirm/{id}`, or `attest confirm <id> approve --edits '{…}'`.
|
|
149
|
+
Every decision records the approver's identity and channel in the ledger. Edits at confirm time change what
|
|
150
|
+
runs. Cross-process resume passes `execute=`; the same process remembers it.
|
|
151
|
+
|
|
152
|
+
### Web inbox + API
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
attest serve --port 8321 # http://127.0.0.1:8321 — approve / reject / edit pending requests
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`GET /api/pending` · `GET /api/requests/{id}` · `POST /confirm/{id}` `{"status","approver","edits","note"}` ·
|
|
159
|
+
`GET /api/ledger` · `GET /api/ledger/verify` · `POST /slack/interact`. Set `ATTEST_SERVER_TOKEN` to require a
|
|
160
|
+
bearer token.
|
|
161
|
+
|
|
162
|
+
### CLI
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
attest ledger [--limit 20] [--run RUN] [--json] attest verify attest export --format csv
|
|
166
|
+
attest pending attest confirm <id|token> approve|reject [--edits '{…}']
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## MCP proxy (zero code)
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{ "mcpServers": { "gmail": { "command": "attest-mcp",
|
|
173
|
+
"args": ["--upstream", "npx -y @modelcontextprotocol/server-gmail", "--server", "gmail", "--mode", "block"] } } }
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Every `tools/call` is normalized by tool name, decided, gated, forwarded, verified and recorded; `tools/list`
|
|
177
|
+
gains `attest_resume`. Read-back uses MCP tool pairs (`create_issue` ⇒ `get_issue`) and compares fields.
|
|
178
|
+
`--mode block` waits for a decision in the inbox / Slack (`--timeout`); `--mode pending` returns
|
|
179
|
+
`{"status": "pending_confirmation", "resume_token"}` and the agent calls `attest_resume` after approval;
|
|
180
|
+
`--mode auto` approves everything (dev). Slack / webhook via `--slack-token --slack-channel` / `--webhook`.
|
|
181
|
+
|
|
182
|
+
## OpenAI Agents SDK
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from attest.adapters import openai_agents as attest_oa
|
|
186
|
+
tools = attest_oa.wrap_tools([send_email, update_deal], at, mapping=mapping)
|
|
187
|
+
agent = Agent(name="followup", tools=tools)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Same behaviour as LangGraph: refusals / rejections return as tool errors; with a pending gate the tool returns a
|
|
191
|
+
resume token and `await attest_oa.resume(at, token)` executes after approval. `pip install "attestlayer[openai]"`.
|
|
192
|
+
|
|
193
|
+
## Verification levels
|
|
194
|
+
|
|
195
|
+
| level | meaning |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `verified` | read-back matched: a reviewed recipe or the REST convention driver compared intent with the record |
|
|
198
|
+
| `verified-custom` | your `verify=` returned true |
|
|
199
|
+
| `acknowledged` | response carried an id / success — the API said yes, nothing was read back |
|
|
200
|
+
| `attested-only` | recorded; nothing checkable |
|
|
201
|
+
| `unverified` | a check ran and **contradicted** the claimed result |
|
|
202
|
+
|
|
203
|
+
A check that could not run (exception, missing id) degrades to `acknowledged` / `attested-only` with the
|
|
204
|
+
error in evidence. Only a contradiction is `unverified`.
|
|
205
|
+
|
|
206
|
+
## Ledger
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from attest import SqliteLedger
|
|
210
|
+
L = SqliteLedger(".attest/ledger.sqlite")
|
|
211
|
+
L.entries(run_id="…") # LedgerEntry objects
|
|
212
|
+
L.verify_chain() # ChainReport(ok, checked, broken_at)
|
|
213
|
+
L.export("json") / L.export("csv")
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## API-only (entry point 5)
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
attest.attest(system="n8n", verb="send", target="x@ext.com", result={"id": "m1"}) # ⇒ acknowledged
|
|
220
|
+
attest.attest(system="n8n", verb="send", verified=True, evidence={"message_id": "m1"}) # ⇒ verified-custom
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Environment
|
|
224
|
+
|
|
225
|
+
| var | effect |
|
|
226
|
+
| --- | --- |
|
|
227
|
+
| `ATTEST_LEDGER` | ledger path (default `.attest/ledger.sqlite`) |
|
|
228
|
+
| `ATTEST_POLICY` | policy YAML path |
|
|
229
|
+
| `ATTEST_AUTO_APPROVE` | `1` approve all, `0` reject all (skips the console gate) |
|
|
230
|
+
| `ATTEST_AGENT`, `ATTEST_ACTOR` | defaults for the descriptor |
|
attest/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Attest — proof layer for AI agents. Decide → Gate → Verify → Attest.
|
|
2
|
+
|
|
3
|
+
import attest
|
|
4
|
+
|
|
5
|
+
@attest.action(system="gmail", verb="send", target="to")
|
|
6
|
+
def send_email(to, subject, body): ...
|
|
7
|
+
|
|
8
|
+
Module-level `action`, `attest`, `run` use a lazily created default client (ledger at
|
|
9
|
+
`.attest/ledger.sqlite` or `$ATTEST_LEDGER`, policy from `$ATTEST_POLICY` / `./attest.yaml`, console gate).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from attest.core import ActionReceipt, Attest
|
|
16
|
+
from attest.descriptor import ActionDescriptor
|
|
17
|
+
from attest.exceptions import ActionPending, ActionRefused, ActionRejected, AttestError
|
|
18
|
+
from attest.gate import AutoGate, ConfirmDecision, ConfirmRequest, StoreGate
|
|
19
|
+
from attest.gate.console import ConsoleGate
|
|
20
|
+
from attest.gate.store import PendingStore
|
|
21
|
+
from attest.ledger import LedgerEntry, SqliteLedger
|
|
22
|
+
from attest.policy import PolicyContext, PolicyEngine, PolicyResult
|
|
23
|
+
from attest.registry import Detection, detect, register
|
|
24
|
+
from attest.verify import Level, ReadBackDriver
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
_default: Attest | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default() -> Attest:
|
|
31
|
+
global _default
|
|
32
|
+
if _default is None:
|
|
33
|
+
_default = Attest()
|
|
34
|
+
return _default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure(**kw: Any) -> Attest:
|
|
38
|
+
"""Replace the default client (e.g. `attest.configure(agent="x", gate=AutoGate())`)."""
|
|
39
|
+
global _default
|
|
40
|
+
_default = Attest(**kw)
|
|
41
|
+
return _default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def action(fn=None, **kw):
|
|
45
|
+
return default().action(fn, **kw)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def attest(**kw): # noqa: A001 - module-level verb by design
|
|
49
|
+
return default().attest(**kw)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(run_id: str | None = None, **kw):
|
|
53
|
+
return default().run(run_id, **kw)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["Attest", "ActionReceipt", "ActionDescriptor", "ActionRefused", "ActionRejected", "ActionPending",
|
|
57
|
+
"AttestError", "AutoGate", "ConsoleGate", "StoreGate", "PendingStore", "ConfirmDecision", "ConfirmRequest",
|
|
58
|
+
"LedgerEntry", "SqliteLedger",
|
|
59
|
+
"PolicyContext", "PolicyEngine", "PolicyResult", "Detection", "detect", "register", "Level",
|
|
60
|
+
"ReadBackDriver",
|
|
61
|
+
"action", "attest", "run", "default", "configure", "__version__"]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Claude Agent SDK adapter — Attest as PreToolUse / PostToolUse hooks.
|
|
2
|
+
|
|
3
|
+
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
|
|
4
|
+
from attest.adapters import claude_agent_sdk as attest_cl
|
|
5
|
+
|
|
6
|
+
mapping = {"mcp__gmail__send_message": {"system": "gmail", "verb": "send", "target": "to"}}
|
|
7
|
+
pre, post = attest_cl.hooks(at, mapping=mapping)
|
|
8
|
+
options = ClaudeAgentOptions(hooks={"PreToolUse": [HookMatcher(hooks=[pre])],
|
|
9
|
+
"PostToolUse": [HookMatcher(hooks=[post])]})
|
|
10
|
+
|
|
11
|
+
PreToolUse decides + gates: refuse / rejected ⇒ `permissionDecision: "deny"` with the reason; approved ⇒ "allow"
|
|
12
|
+
(with `updatedInput` when the human edited params). PostToolUse verifies the tool response and writes the
|
|
13
|
+
ledger row. The pending row is written at PreToolUse so a denied or crashed call still leaves a record.
|
|
14
|
+
Hook inputs are plain dicts (`tool_name`, `tool_input`, `tool_response`, `session_id`, `tool_use_id`), so the
|
|
15
|
+
adapter has no import of the SDK and can be driven by tests.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from attest.adapters.langgraph import Mapping, descriptor_for
|
|
23
|
+
from attest.core import Attest, entry_decision
|
|
24
|
+
from attest.descriptor import ActionDescriptor, short_hash
|
|
25
|
+
from attest.exceptions import ActionPending, ActionRefused, ActionRejected
|
|
26
|
+
from attest.ledger import ConfirmRecord, ExecutionRecord, LedgerEntry
|
|
27
|
+
from attest.ledger.models import VerificationRecord, preview
|
|
28
|
+
from attest.verify import verify
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Parked:
|
|
32
|
+
def __init__(self, d: ActionDescriptor, entry: LedgerEntry, spec: dict[str, Any]):
|
|
33
|
+
self.d, self.entry, self.spec = d, entry, spec
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hooks(client: Attest | None = None, *, mapping: Mapping | None = None, server: str | None = None
|
|
37
|
+
) -> tuple[Callable[..., Any], Callable[..., Any]]:
|
|
38
|
+
"""→ (pre_tool_use, post_tool_use) async hook callables with the SDK's `(input, tool_use_id, context)` shape."""
|
|
39
|
+
at = client or __import__("attest").default()
|
|
40
|
+
parked: dict[str, _Parked] = {}
|
|
41
|
+
|
|
42
|
+
async def pre_tool_use(input_data: dict[str, Any], tool_use_id: str | None = None,
|
|
43
|
+
context: Any = None) -> dict[str, Any]:
|
|
44
|
+
name = str(input_data.get("tool_name") or "")
|
|
45
|
+
args = dict(input_data.get("tool_input") or {})
|
|
46
|
+
srv = server or (name.split("__")[1] if name.startswith("mcp__") and name.count("__") >= 2 else None)
|
|
47
|
+
d, recognised, spec = descriptor_for(at, name, args, mapping, server=srv)
|
|
48
|
+
d.extra["framework"] = "claude-agent-sdk"
|
|
49
|
+
d.run_id = d.run_id or input_data.get("session_id")
|
|
50
|
+
pol = at.policy.evaluate(d, recognised=recognised)
|
|
51
|
+
d.target_class = pol.target_class # type: ignore[assignment]
|
|
52
|
+
entry = at._entry(d, pol)
|
|
53
|
+
key = tool_use_id or d.id
|
|
54
|
+
if pol.decision == "refuse":
|
|
55
|
+
at.ledger.append(entry)
|
|
56
|
+
return _deny(f"attest refused: {'; '.join(pol.reasons)}")
|
|
57
|
+
updated: dict[str, Any] | None = None
|
|
58
|
+
if pol.decision == "ask":
|
|
59
|
+
request = at._request(d, pol)
|
|
60
|
+
decision = at.gate.confirm(request)
|
|
61
|
+
if decision.pending:
|
|
62
|
+
try:
|
|
63
|
+
at._park(d, entry, request, decision, lambda p: None, spec.get("verify"), spec.get("readers"),
|
|
64
|
+
spec.get("http_get"))
|
|
65
|
+
except ActionPending as e:
|
|
66
|
+
return _deny(f"attest: pending human confirmation (resume_token={e.resume_token}); retry later")
|
|
67
|
+
try:
|
|
68
|
+
d, entry = at._after_confirm(d, entry, decision)
|
|
69
|
+
except ActionRejected as e:
|
|
70
|
+
return _deny("attest: a human rejected this action" + (f" ({e.note})" if e.note else ""))
|
|
71
|
+
if decision.edits:
|
|
72
|
+
updated = {**args, **{k: v for k, v in d.params.items() if k in args}}
|
|
73
|
+
parked[key] = _Parked(d, entry, spec)
|
|
74
|
+
reason = "attest: " + ("; ".join(pol.reasons) or pol.decision)
|
|
75
|
+
out: dict[str, Any] = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow",
|
|
76
|
+
"permissionDecisionReason": reason}}
|
|
77
|
+
if updated is not None:
|
|
78
|
+
out["hookSpecificOutput"]["updatedInput"] = updated
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
async def post_tool_use(input_data: dict[str, Any], tool_use_id: str | None = None,
|
|
82
|
+
context: Any = None) -> dict[str, Any]:
|
|
83
|
+
name = str(input_data.get("tool_name") or "")
|
|
84
|
+
key = tool_use_id or ""
|
|
85
|
+
parked_item = parked.pop(key, None)
|
|
86
|
+
if parked_item is None: # PostToolUse without our PreToolUse (hook not matched) — record what we can
|
|
87
|
+
args = dict(input_data.get("tool_input") or {})
|
|
88
|
+
d, recognised, spec = descriptor_for(at, name, args, mapping, server=server)
|
|
89
|
+
pol = at.policy.evaluate(d, recognised=recognised)
|
|
90
|
+
entry = at._entry(d, pol)
|
|
91
|
+
entry.confirm = ConfirmRecord(status="not_required")
|
|
92
|
+
else:
|
|
93
|
+
d, entry, spec = parked_item.d, parked_item.entry, parked_item.spec
|
|
94
|
+
response = input_data.get("tool_response")
|
|
95
|
+
result = _unwrap(response)
|
|
96
|
+
failed = isinstance(response, dict) and (response.get("is_error") or response.get("isError"))
|
|
97
|
+
entry.execution = ExecutionRecord(status="failed" if failed else "done",
|
|
98
|
+
result_hash=None if result is None else short_hash(result),
|
|
99
|
+
result_preview=preview(result), error=str(result)[:400] if failed else None)
|
|
100
|
+
if failed:
|
|
101
|
+
entry.verification = VerificationRecord(level="attested-only", method="none",
|
|
102
|
+
evidence={"detail": "tool error"})
|
|
103
|
+
else:
|
|
104
|
+
entry.verification = verify(d.with_result(result), result, custom=spec.get("verify"),
|
|
105
|
+
drivers=at._drivers(spec.get("readers"), spec.get("http_get")))
|
|
106
|
+
at.ledger.append(entry)
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
return pre_tool_use, post_tool_use
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _deny(reason: str) -> dict[str, Any]:
|
|
113
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
114
|
+
"permissionDecisionReason": reason}}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unwrap(response: Any) -> Any:
|
|
118
|
+
"""Tool responses arrive as MCP-style content lists or plain values; pull out something the ladder can judge."""
|
|
119
|
+
if isinstance(response, dict) and isinstance(response.get("content"), list):
|
|
120
|
+
import json
|
|
121
|
+
texts = [c.get("text", "") for c in response["content"] if isinstance(c, dict) and c.get("type") == "text"]
|
|
122
|
+
joined = "\n".join(texts).strip()
|
|
123
|
+
try:
|
|
124
|
+
return json.loads(joined) if joined else response
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
return joined or response
|
|
127
|
+
return response
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["hooks"]
|
|
131
|
+
_ = (ActionRefused, entry_decision)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""CrewAI adapter — wraps `crewai.tools.BaseTool` instances (or anything with `name`, `description`, `_run`).
|
|
2
|
+
|
|
3
|
+
from attest.adapters import crewai as attest_crew
|
|
4
|
+
tools = attest_crew.wrap_tools([send_email_tool, update_deal_tool], at, mapping={...})
|
|
5
|
+
agent = Agent(role="…", tools=tools)
|
|
6
|
+
|
|
7
|
+
Returns a subclass instance of the original tool whose `_run` (and `_arun` when present) goes through
|
|
8
|
+
decide → gate → execute → verify → attest. Refusals / rejections return an error string to the agent.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from attest.adapters.langgraph import Mapping, _error_text, _only_known, descriptor_for
|
|
15
|
+
from attest.core import Attest
|
|
16
|
+
from attest.exceptions import ActionPending, ActionRefused, ActionRejected
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def wrap_tool(tool: Any, client: Attest | None = None, *, mapping: Mapping | None = None,
|
|
20
|
+
raise_on_block: bool = False) -> Any:
|
|
21
|
+
at = client or __import__("attest").default()
|
|
22
|
+
name = getattr(tool, "name", type(tool).__name__)
|
|
23
|
+
spec = dict((mapping or {}).get(name) or {})
|
|
24
|
+
orig_run = tool._run
|
|
25
|
+
orig_arun = getattr(tool, "_arun", None)
|
|
26
|
+
|
|
27
|
+
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
|
28
|
+
params, by_name = _params(orig_run, args, kwargs)
|
|
29
|
+
d, recognised, sp = descriptor_for(at, name, params, mapping)
|
|
30
|
+
d.extra["framework"] = "crewai"
|
|
31
|
+
call = (lambda p: orig_run(**{**params, **_only_known(params, p)})) if by_name else \
|
|
32
|
+
(lambda p: orig_run(*args, **{**kwargs, **_only_known(kwargs, p)}))
|
|
33
|
+
try:
|
|
34
|
+
receipt = at.run_action(d, call, recognised=recognised, verify_fn=sp.get("verify"),
|
|
35
|
+
readers=sp.get("readers"), http_get=sp.get("http_get"))
|
|
36
|
+
except ActionPending as e:
|
|
37
|
+
if raise_on_block:
|
|
38
|
+
raise
|
|
39
|
+
return f"attest: pending human confirmation (resume_token={e.resume_token})"
|
|
40
|
+
except (ActionRefused, ActionRejected) as e:
|
|
41
|
+
if raise_on_block:
|
|
42
|
+
raise
|
|
43
|
+
return _error_text(e)
|
|
44
|
+
return receipt.result
|
|
45
|
+
|
|
46
|
+
async def _arun(self, *args: Any, **kwargs: Any) -> Any:
|
|
47
|
+
target = orig_arun or orig_run
|
|
48
|
+
params, by_name = _params(target, args, kwargs)
|
|
49
|
+
d, recognised, sp = descriptor_for(at, name, params, mapping)
|
|
50
|
+
d.extra["framework"] = "crewai"
|
|
51
|
+
call = (lambda p: target(**{**params, **_only_known(params, p)})) if by_name else \
|
|
52
|
+
(lambda p: target(*args, **{**kwargs, **_only_known(kwargs, p)}))
|
|
53
|
+
try:
|
|
54
|
+
receipt = await at.arun_action(d, call, recognised=recognised, verify_fn=sp.get("verify"),
|
|
55
|
+
readers=sp.get("readers"), http_get=sp.get("http_get"))
|
|
56
|
+
except ActionPending as e:
|
|
57
|
+
if raise_on_block:
|
|
58
|
+
raise
|
|
59
|
+
return f"attest: pending human confirmation (resume_token={e.resume_token})"
|
|
60
|
+
except (ActionRefused, ActionRejected) as e:
|
|
61
|
+
if raise_on_block:
|
|
62
|
+
raise
|
|
63
|
+
return _error_text(e)
|
|
64
|
+
return receipt.result
|
|
65
|
+
|
|
66
|
+
cls = type(tool)
|
|
67
|
+
wrapped_cls = type(f"Attested{cls.__name__}", (cls,), {"_run": _run, "_arun": _arun, "_attest_wrapped": True})
|
|
68
|
+
try: # pydantic models (crewai BaseTool) — copy fields
|
|
69
|
+
wrapped = wrapped_cls.model_validate(tool.model_dump()) if hasattr(tool, "model_dump") else None
|
|
70
|
+
except Exception:
|
|
71
|
+
wrapped = None
|
|
72
|
+
if wrapped is None:
|
|
73
|
+
import copy
|
|
74
|
+
wrapped = copy.copy(tool)
|
|
75
|
+
wrapped.__class__ = wrapped_cls
|
|
76
|
+
_ = spec
|
|
77
|
+
return wrapped
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _params(fn: Any, args: tuple, kwargs: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
|
81
|
+
"""→ (params, by_name): positional args mapped onto the function's parameter names when possible."""
|
|
82
|
+
if not args:
|
|
83
|
+
return dict(kwargs), True
|
|
84
|
+
import inspect
|
|
85
|
+
try:
|
|
86
|
+
names = [p for p in inspect.signature(fn).parameters if p not in ("self", "args", "kwargs")]
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
names = []
|
|
89
|
+
by_name = len(names) >= len(args)
|
|
90
|
+
out = {names[i] if i < len(names) else f"arg{i}": a for i, a in enumerate(args)}
|
|
91
|
+
out.update(kwargs)
|
|
92
|
+
return out, by_name
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def wrap_tools(tools: list[Any], client: Attest | None = None, *, mapping: Mapping | None = None,
|
|
96
|
+
raise_on_block: bool = False) -> list[Any]:
|
|
97
|
+
return [wrap_tool(t, client, mapping=mapping, raise_on_block=raise_on_block) for t in tools]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["wrap_tool", "wrap_tools"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""DeerFlow adapter. DeerFlow agents are LangChain (≥1) agents with a middleware chain; Attest plugs in as a
|
|
2
|
+
middleware — placed **outermost**, per DeerFlow's own ordering rule for ToolReceiptMiddleware, so nothing
|
|
3
|
+
downstream can short-circuit the record.
|
|
4
|
+
|
|
5
|
+
from attest.adapters.deerflow import AttestMiddleware
|
|
6
|
+
middlewares = [AttestMiddleware(at, mapping=…), *deerflow_middlewares]
|
|
7
|
+
|
|
8
|
+
DeerFlow's tool receipts (args/output hashes cited by the model) and Attest's ledger complement each other:
|
|
9
|
+
receipts prove a call happened inside the run; Attest proves the world changed and who approved it.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from attest.adapters.langgraph import AttestMiddleware, wrap_tool, wrap_tools
|
|
14
|
+
|
|
15
|
+
__all__ = ["AttestMiddleware", "wrap_tool", "wrap_tools"]
|