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.
Files changed (77) hide show
  1. attest/README.md +230 -0
  2. attest/__init__.py +61 -0
  3. attest/adapters/__init__.py +2 -0
  4. attest/adapters/claude_agent_sdk.py +131 -0
  5. attest/adapters/crewai.py +100 -0
  6. attest/adapters/deerflow.py +15 -0
  7. attest/adapters/langgraph.py +259 -0
  8. attest/adapters/openai_agents.py +92 -0
  9. attest/cli.py +215 -0
  10. attest/cloud.py +297 -0
  11. attest/core.py +496 -0
  12. attest/descriptor.py +94 -0
  13. attest/digest.py +107 -0
  14. attest/exceptions.py +31 -0
  15. attest/gate/__init__.py +168 -0
  16. attest/gate/console.py +76 -0
  17. attest/gate/email.py +56 -0
  18. attest/gate/interrupt.py +42 -0
  19. attest/gate/links.py +23 -0
  20. attest/gate/slack.py +153 -0
  21. attest/gate/store.py +177 -0
  22. attest/gate/teams.py +42 -0
  23. attest/gate/webhook.py +53 -0
  24. attest/gateway/__init__.py +1 -0
  25. attest/gateway/server.py +368 -0
  26. attest/ledger/__init__.py +6 -0
  27. attest/ledger/anchor.py +148 -0
  28. attest/ledger/checkpoints.py +73 -0
  29. attest/ledger/exports.py +178 -0
  30. attest/ledger/hashchain.py +53 -0
  31. attest/ledger/local_sqlite.py +220 -0
  32. attest/ledger/models.py +101 -0
  33. attest/ledger/signing.py +56 -0
  34. attest/mcp/__init__.py +1 -0
  35. attest/mcp/__main__.py +3 -0
  36. attest/mcp/launcher.py +41 -0
  37. attest/mcp/proxy.py +354 -0
  38. attest/mcp/server.py +202 -0
  39. attest/policy/__init__.py +14 -0
  40. attest/policy/engine.py +121 -0
  41. attest/policy/rules.py +130 -0
  42. attest/policy/yaml_loader.py +174 -0
  43. attest/registry/__init__.py +74 -0
  44. attest/registry/data.json +592 -0
  45. attest/registry/mcp_names.py +63 -0
  46. attest/registry/sdk_names.py +25 -0
  47. attest/registry/systems.py +125 -0
  48. attest/registry/url_patterns.py +93 -0
  49. attest/registry/verbs.py +125 -0
  50. attest/server.py +247 -0
  51. attest/telemetry.py +59 -0
  52. attest/verify/__init__.py +3 -0
  53. attest/verify/drivers/__init__.py +0 -0
  54. attest/verify/drivers/ack.py +76 -0
  55. attest/verify/drivers/convention.py +76 -0
  56. attest/verify/drivers/custom.py +19 -0
  57. attest/verify/drivers/openapi.py +126 -0
  58. attest/verify/drivers/recipe.py +46 -0
  59. attest/verify/ladder.py +152 -0
  60. attest/verify/match.py +115 -0
  61. attest/verify/readers.py +418 -0
  62. attest/verify/recipes/__init__.py +59 -0
  63. attest/verify/recipes/declarative.py +124 -0
  64. attest/verify/recipes/generate.py +140 -0
  65. attest/verify/recipes/gmail.py +117 -0
  66. attest/verify/recipes/google.py +151 -0
  67. attest/verify/recipes/hubspot.py +79 -0
  68. attest/verify/recipes/linear.py +84 -0
  69. attest/verify/recipes/m365.py +125 -0
  70. attest/verify/recipes/notion.py +96 -0
  71. attest/verify/recipes/slack.py +66 -0
  72. attest/verify/refs.py +70 -0
  73. attestlayer-0.1.0.dist-info/METADATA +137 -0
  74. attestlayer-0.1.0.dist-info/RECORD +77 -0
  75. attestlayer-0.1.0.dist-info/WHEEL +4 -0
  76. attestlayer-0.1.0.dist-info/entry_points.txt +6 -0
  77. 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,2 @@
1
+ """Framework adapters (doc 03 §2, entry point 2). Each wraps every tool of a framework with Attest so
2
+ one line covers the whole agent. Import the framework you use: `from attest.adapters import langgraph`."""
@@ -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"]