marchward 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.
- marchward/__init__.py +51 -0
- marchward/client.py +264 -0
- marchward/errors.py +20 -0
- marchward/models.py +69 -0
- marchward-0.1.0.dist-info/METADATA +114 -0
- marchward-0.1.0.dist-info/RECORD +8 -0
- marchward-0.1.0.dist-info/WHEEL +4 -0
- marchward-0.1.0.dist-info/licenses/LICENSE +21 -0
marchward/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Marchward Python SDK — runtime authority for AI agents.
|
|
3
|
+
|
|
4
|
+
The wedge persona builds on LangGraph/LangChain (both Python), so this is
|
|
5
|
+
the primary, first-class SDK. Mirrors the `/v1/execute` contract and the
|
|
6
|
+
TypeScript SDK's `execute()` surface.
|
|
7
|
+
|
|
8
|
+
Quickstart
|
|
9
|
+
----------
|
|
10
|
+
from marchward import MarchwardClient
|
|
11
|
+
|
|
12
|
+
tenet = MarchwardClient(api_key="tnt_...") # or TENET_API_KEY env
|
|
13
|
+
|
|
14
|
+
decision = tenet.execute(
|
|
15
|
+
service="github",
|
|
16
|
+
tool_name="github.repos.delete",
|
|
17
|
+
arguments={"owner": "acme", "repo": "old"},
|
|
18
|
+
context={"env": "production"},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if decision.allowed:
|
|
22
|
+
... # safe to proceed
|
|
23
|
+
elif decision.escalated:
|
|
24
|
+
... # paused for human approval (decision.review_id)
|
|
25
|
+
elif decision.blocked:
|
|
26
|
+
... # refused by policy
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .client import MarchwardClient
|
|
30
|
+
from .models import Decision, Outcome
|
|
31
|
+
from .errors import MarchwardError, MarchwardAuthError, MarchwardAPIError
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"MarchwardClient",
|
|
35
|
+
"Decision",
|
|
36
|
+
"Outcome",
|
|
37
|
+
"MarchwardError",
|
|
38
|
+
"MarchwardAuthError",
|
|
39
|
+
"MarchwardAPIError",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# ── Back-compat aliases (pre-Marchward names; deprecated, kept one major cycle) ──
|
|
43
|
+
# Existing `from marchward import TenetClient` (or legacy `tenet`) consumers keep working.
|
|
44
|
+
TenetClient = MarchwardClient
|
|
45
|
+
TenetError = MarchwardError
|
|
46
|
+
TenetAuthError = MarchwardAuthError
|
|
47
|
+
TenetAPIError = MarchwardAPIError
|
|
48
|
+
|
|
49
|
+
__all__ += ["TenetClient", "TenetError", "TenetAuthError", "TenetAPIError"]
|
|
50
|
+
|
|
51
|
+
__version__ = "0.1.0"
|
marchward/client.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MarchwardClient — the primary Python entry point.
|
|
3
|
+
|
|
4
|
+
Zero runtime dependencies (stdlib urllib) so it drops into any agent
|
|
5
|
+
environment without dependency conflicts. Speaks the Model-B contract:
|
|
6
|
+
|
|
7
|
+
client.execute(service="github", tool_name="github.repos.delete",
|
|
8
|
+
arguments={"owner": "acme", "repo": "old"})
|
|
9
|
+
|
|
10
|
+
You send a logical tool call (service + tool_name + arguments); Marchward
|
|
11
|
+
resolves the real downstream HTTP request, governs it, injects the
|
|
12
|
+
credential server-side, executes it, and returns the result. The agent
|
|
13
|
+
never holds downstream credentials.
|
|
14
|
+
|
|
15
|
+
Response model (matches /v1/execute):
|
|
16
|
+
403 BLOCK -> Decision(blocked) [immediate]
|
|
17
|
+
202 ESCALATE + reviewId -> Decision(escalated) [immediate]
|
|
18
|
+
202 ALLOW + jobId -> poll GET /v1/jobs/:id until terminal,
|
|
19
|
+
then Decision(allowed) with .execution set
|
|
20
|
+
401 -> MarchwardAuthError
|
|
21
|
+
5xx / unknown_tool etc. -> MarchwardAPIError / surfaced on the Decision
|
|
22
|
+
|
|
23
|
+
ALLOW is asynchronous server-side (Marchward fires the downstream in the
|
|
24
|
+
background and hands back a jobId). By default execute() polls that job to
|
|
25
|
+
completion so callers get the result synchronously; pass wait=False to get
|
|
26
|
+
the pending Decision back immediately and poll yourself.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import time
|
|
34
|
+
import uuid
|
|
35
|
+
import urllib.request
|
|
36
|
+
import urllib.error
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from .models import Decision, Outcome
|
|
40
|
+
from .errors import MarchwardAuthError, MarchwardAPIError
|
|
41
|
+
|
|
42
|
+
# Identify as the Marchward SDK, not the stdlib default. urllib's default
|
|
43
|
+
# `Python-urllib/3.x` User-Agent is fingerprinted and BANNED by Cloudflare's
|
|
44
|
+
# browser-integrity check (returns "error code: 1010" — the request never
|
|
45
|
+
# reaches the API). A normal product UA passes. This matters for real
|
|
46
|
+
# customers too: agent SDKs on stdlib HTTP must not look like a bot.
|
|
47
|
+
_USER_AGENT = "tenet-python-sdk/0.1"
|
|
48
|
+
_DEFAULT_API_URL = "https://api.trytenet.com"
|
|
49
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
50
|
+
_DEFAULT_POLL_TIMEOUT = 120.0
|
|
51
|
+
_DEFAULT_POLL_INTERVAL = 0.75
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MarchwardClient:
|
|
55
|
+
"""Client for the Marchward runtime-authority API.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
api_key: Your tenant API key (``tnt_...``). Falls back to the
|
|
59
|
+
``TENET_API_KEY`` env var.
|
|
60
|
+
api_url: API base URL. Falls back to ``TENET_API_URL`` env var,
|
|
61
|
+
then the production default.
|
|
62
|
+
default_agent_id: Agent identity attached to every call. Defaults
|
|
63
|
+
to ``"default"`` — the agent every tenant auto-provisions and
|
|
64
|
+
that the dashboard binds connected services to. Override only
|
|
65
|
+
if you created a differently-named agent and bound your
|
|
66
|
+
credentials to it (otherwise credential resolution won't find
|
|
67
|
+
a binding and calls return 403 credential_not_found).
|
|
68
|
+
timeout: Per-request timeout in seconds.
|
|
69
|
+
poll_timeout: Max seconds to wait for an async ALLOW job to finish.
|
|
70
|
+
poll_interval: Seconds between job polls.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
api_key: str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
api_url: str | None = None,
|
|
78
|
+
default_agent_id: str = "default",
|
|
79
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
80
|
+
poll_timeout: float = _DEFAULT_POLL_TIMEOUT,
|
|
81
|
+
poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
|
82
|
+
) -> None:
|
|
83
|
+
self.api_key = api_key or (os.environ.get("MARCHWARD_API_KEY") or os.environ.get("TENET_API_KEY"))
|
|
84
|
+
if not self.api_key:
|
|
85
|
+
raise MarchwardAuthError(
|
|
86
|
+
"No API key. Pass api_key=... or set the MARCHWARD_API_KEY (or legacy TENET_API_KEY) env var."
|
|
87
|
+
)
|
|
88
|
+
self.api_url = (api_url or (os.environ.get("MARCHWARD_API_URL") or os.environ.get("TENET_API_URL")) or _DEFAULT_API_URL).rstrip("/")
|
|
89
|
+
self.default_agent_id = default_agent_id
|
|
90
|
+
self.timeout = timeout
|
|
91
|
+
self.poll_timeout = poll_timeout
|
|
92
|
+
self.poll_interval = poll_interval
|
|
93
|
+
|
|
94
|
+
# ──────────────────────────────────────────────────────────────────
|
|
95
|
+
def execute(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
service: str,
|
|
99
|
+
tool_name: str,
|
|
100
|
+
arguments: dict[str, Any] | None = None,
|
|
101
|
+
context: dict[str, Any] | None = None,
|
|
102
|
+
agent_id: str | None = None,
|
|
103
|
+
request_id: str | None = None,
|
|
104
|
+
wait: bool = True,
|
|
105
|
+
) -> Decision:
|
|
106
|
+
"""Authorize + execute a tool call through Marchward (Model B).
|
|
107
|
+
|
|
108
|
+
Sends `service` + `tool_name` + `arguments`; Marchward resolves the
|
|
109
|
+
downstream call from its tool catalog. Returns a :class:`Decision`.
|
|
110
|
+
Never raises on a normal governance outcome (ALLOW/ESCALATE/BLOCK)
|
|
111
|
+
— only on auth/transport errors.
|
|
112
|
+
|
|
113
|
+
When the outcome is ALLOW, the downstream runs asynchronously
|
|
114
|
+
server-side. With ``wait=True`` (default) this polls the job to
|
|
115
|
+
completion and attaches the result to ``decision.execution``. With
|
|
116
|
+
``wait=False`` you get the pending Decision immediately (poll the
|
|
117
|
+
job yourself via ``get_job(decision.job_id)``).
|
|
118
|
+
"""
|
|
119
|
+
rid = request_id or str(uuid.uuid4())
|
|
120
|
+
body = json.dumps(
|
|
121
|
+
{
|
|
122
|
+
"requestId": rid,
|
|
123
|
+
"service": service,
|
|
124
|
+
"toolCall": {"toolName": tool_name, "arguments": arguments or {}},
|
|
125
|
+
"agent": {"agentId": agent_id or self.default_agent_id},
|
|
126
|
+
"context": context or {},
|
|
127
|
+
}
|
|
128
|
+
).encode()
|
|
129
|
+
|
|
130
|
+
payload, status = self._post("/v1/execute", body, idempotency_key=rid)
|
|
131
|
+
decision = self._to_decision(payload, status)
|
|
132
|
+
|
|
133
|
+
# ALLOW arrives as 202 + jobId (async downstream). Poll unless the
|
|
134
|
+
# caller opted out. ESCALATE/BLOCK are terminal and have no job.
|
|
135
|
+
job_id = payload.get("jobId") if isinstance(payload, dict) else None
|
|
136
|
+
decision.job_id = job_id if isinstance(job_id, str) else None
|
|
137
|
+
if wait and decision.job_id and status == 202 and decision.allowed:
|
|
138
|
+
return self._await_job(decision)
|
|
139
|
+
|
|
140
|
+
return decision
|
|
141
|
+
|
|
142
|
+
# ──────────────────────────────────────────────────────────────────
|
|
143
|
+
def get_job(self, job_id: str) -> dict[str, Any]:
|
|
144
|
+
"""Poll a single async job once. Returns the raw job payload
|
|
145
|
+
(`status` is one of pending / running / completed / failed)."""
|
|
146
|
+
payload, _ = self._get(f"/v1/jobs/{job_id}")
|
|
147
|
+
return payload if isinstance(payload, dict) else {}
|
|
148
|
+
|
|
149
|
+
# ── Internals ──────────────────────────────────────────────────────
|
|
150
|
+
def _await_job(self, decision: Decision) -> Decision:
|
|
151
|
+
"""Poll GET /v1/jobs/:id until the downstream call terminates,
|
|
152
|
+
then fold the result into the Decision."""
|
|
153
|
+
assert decision.job_id is not None
|
|
154
|
+
deadline = time.monotonic() + self.poll_timeout
|
|
155
|
+
while True:
|
|
156
|
+
payload, status = self._get(f"/v1/jobs/{decision.job_id}")
|
|
157
|
+
job_status = payload.get("status") if isinstance(payload, dict) else None
|
|
158
|
+
|
|
159
|
+
if job_status in ("completed", "failed"):
|
|
160
|
+
decision.http_status = status
|
|
161
|
+
decision.raw = payload
|
|
162
|
+
if job_status == "completed":
|
|
163
|
+
decision.execution = payload.get("execution")
|
|
164
|
+
else:
|
|
165
|
+
decision.execution_error = payload.get("executionError") or "downstream_failed"
|
|
166
|
+
return decision
|
|
167
|
+
|
|
168
|
+
if time.monotonic() >= deadline:
|
|
169
|
+
decision.execution_error = "poll_timeout"
|
|
170
|
+
return decision
|
|
171
|
+
|
|
172
|
+
time.sleep(self.poll_interval)
|
|
173
|
+
|
|
174
|
+
def _post(self, path: str, body: bytes, *, idempotency_key: str | None = None):
|
|
175
|
+
headers = {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
178
|
+
"User-Agent": _USER_AGENT,
|
|
179
|
+
}
|
|
180
|
+
if idempotency_key:
|
|
181
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
182
|
+
req = urllib.request.Request(f"{self.api_url}{path}", data=body, headers=headers, method="POST")
|
|
183
|
+
return self._send(req)
|
|
184
|
+
|
|
185
|
+
def _get(self, path: str):
|
|
186
|
+
req = urllib.request.Request(
|
|
187
|
+
f"{self.api_url}{path}",
|
|
188
|
+
headers={"Authorization": f"Bearer {self.api_key}", "User-Agent": _USER_AGENT},
|
|
189
|
+
method="GET",
|
|
190
|
+
)
|
|
191
|
+
return self._send(req)
|
|
192
|
+
|
|
193
|
+
def _send(self, req: urllib.request.Request):
|
|
194
|
+
_debug = (os.environ.get("MARCHWARD_DEBUG") or os.environ.get("TENET_DEBUG"))
|
|
195
|
+
if _debug:
|
|
196
|
+
body_preview = (req.data or b"").decode("utf-8", "replace")[:300]
|
|
197
|
+
print(f"[tenet] → {req.method} {req.full_url}")
|
|
198
|
+
print(f"[tenet] headers: { {k: ('***' if k.lower()=='authorization' else v) for k,v in req.headers.items()} }")
|
|
199
|
+
print(f"[tenet] body: {body_preview}")
|
|
200
|
+
try:
|
|
201
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
202
|
+
payload = json.loads(resp.read() or b"{}")
|
|
203
|
+
if _debug:
|
|
204
|
+
print(f"[tenet] ← {resp.status} {json.dumps(payload)[:400]}")
|
|
205
|
+
return payload, resp.status
|
|
206
|
+
except urllib.error.HTTPError as e:
|
|
207
|
+
raw = e.read() or b"{}"
|
|
208
|
+
try:
|
|
209
|
+
payload = json.loads(raw)
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
payload = {}
|
|
212
|
+
status = e.code
|
|
213
|
+
if _debug:
|
|
214
|
+
print(f"[tenet] ← {status} (raw: {raw.decode('utf-8','replace')[:400]})")
|
|
215
|
+
if status == 401:
|
|
216
|
+
raise MarchwardAuthError("Marchward rejected the API key (401).") from e
|
|
217
|
+
if status >= 500:
|
|
218
|
+
raise MarchwardAPIError(
|
|
219
|
+
f"Marchward API error ({status}).", status=status, body=raw.decode("utf-8", "replace")
|
|
220
|
+
) from e
|
|
221
|
+
# 4xx governance outcomes (403 BLOCK) + resolver 4xx (400
|
|
222
|
+
# unknown_tool etc.) arrive here as HTTPError; fall through so
|
|
223
|
+
# the caller sees them on the Decision rather than as an
|
|
224
|
+
# exception.
|
|
225
|
+
return payload, status
|
|
226
|
+
except urllib.error.URLError as e:
|
|
227
|
+
raise MarchwardAPIError(f"Could not reach Marchward at {self.api_url}: {e}") from e
|
|
228
|
+
|
|
229
|
+
# ──────────────────────────────────────────────────────────────────
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _to_decision(payload: dict[str, Any], status: int) -> Decision:
|
|
232
|
+
# The API returns the outcome either at the top level (`decision`
|
|
233
|
+
# string + `decisionId`) or nested under `decision`; handle both.
|
|
234
|
+
decision_obj = payload.get("decision")
|
|
235
|
+
if isinstance(decision_obj, dict):
|
|
236
|
+
outcome_str = decision_obj.get("outcome") or decision_obj.get("decision")
|
|
237
|
+
decision_id = decision_obj.get("decisionId") or decision_obj.get("id")
|
|
238
|
+
review_id = decision_obj.get("reviewId")
|
|
239
|
+
reasons = decision_obj.get("reasonCodes") or decision_obj.get("reason_codes") or []
|
|
240
|
+
else:
|
|
241
|
+
outcome_str = decision_obj if isinstance(decision_obj, str) else payload.get("outcome")
|
|
242
|
+
decision_id = payload.get("decisionId")
|
|
243
|
+
review_id = payload.get("reviewId")
|
|
244
|
+
reasons = payload.get("reasonCodes") or payload.get("reason_codes") or []
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
outcome = Outcome(outcome_str)
|
|
248
|
+
except (ValueError, TypeError):
|
|
249
|
+
# No recognizable outcome — infer from HTTP status as a fallback.
|
|
250
|
+
outcome = {200: Outcome.ALLOW, 202: Outcome.ESCALATE, 403: Outcome.BLOCK}.get(
|
|
251
|
+
status, Outcome.BLOCK
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return Decision(
|
|
255
|
+
outcome=outcome,
|
|
256
|
+
decision_id=decision_id,
|
|
257
|
+
review_id=review_id,
|
|
258
|
+
reason_codes=list(reasons),
|
|
259
|
+
http_status=status,
|
|
260
|
+
raw=payload,
|
|
261
|
+
execution_error=(
|
|
262
|
+
payload.get("executionError") if isinstance(payload, dict) else None
|
|
263
|
+
),
|
|
264
|
+
)
|
marchward/errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Marchward SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MarchwardError(Exception):
|
|
7
|
+
"""Base class for all Marchward SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MarchwardAuthError(MarchwardError):
|
|
11
|
+
"""Raised on a 401 — the API key is missing, invalid, or revoked."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MarchwardAPIError(MarchwardError):
|
|
15
|
+
"""Raised on an unexpected API response (5xx, malformed body, etc.)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, status: int | None = None, body: str | None = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
self.body = body
|
marchward/models.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Typed result objects for Marchward decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Outcome(str, Enum):
|
|
11
|
+
"""The governance outcome for a tool call."""
|
|
12
|
+
|
|
13
|
+
ALLOW = "ALLOW"
|
|
14
|
+
ALLOW_WITH_CONDITIONS = "ALLOW_WITH_CONDITIONS"
|
|
15
|
+
ESCALATE = "ESCALATE"
|
|
16
|
+
BLOCK = "BLOCK"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Decision:
|
|
21
|
+
"""The result of a `tenet.execute()` call.
|
|
22
|
+
|
|
23
|
+
The boolean helpers (`allowed` / `escalated` / `blocked`) are the
|
|
24
|
+
ergonomic surface most agent code branches on; the raw fields are
|
|
25
|
+
there when you need them.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
outcome: Outcome
|
|
29
|
+
decision_id: str | None = None
|
|
30
|
+
review_id: str | None = None
|
|
31
|
+
reason_codes: list[str] = field(default_factory=list)
|
|
32
|
+
http_status: int = 0
|
|
33
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
# ── Model-B async-execute fields ────────────────────────────────────
|
|
35
|
+
# For ALLOW, the downstream runs asynchronously: `job_id` identifies the
|
|
36
|
+
# async job; after the SDK polls it to completion, `execution` holds the
|
|
37
|
+
# downstream result ({status, headers, body, durationMs}). On a failed
|
|
38
|
+
# downstream (or unmet binding / poll timeout), `execution_error` is set.
|
|
39
|
+
job_id: str | None = None
|
|
40
|
+
execution: dict[str, Any] | None = None
|
|
41
|
+
execution_error: str | None = None
|
|
42
|
+
|
|
43
|
+
# ── Ergonomic branch helpers ───────────────────────────────────────
|
|
44
|
+
@property
|
|
45
|
+
def allowed(self) -> bool:
|
|
46
|
+
return self.outcome in (Outcome.ALLOW, Outcome.ALLOW_WITH_CONDITIONS)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def escalated(self) -> bool:
|
|
50
|
+
return self.outcome == Outcome.ESCALATE
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def blocked(self) -> bool:
|
|
54
|
+
return self.outcome == Outcome.BLOCK
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def executed(self) -> bool:
|
|
58
|
+
"""True only if the downstream actually ran (ALLOW + a completed
|
|
59
|
+
execution with no error). An ALLOW with no connected credential, a
|
|
60
|
+
failed downstream, or a still-pending job is NOT executed."""
|
|
61
|
+
return self.allowed and self.execution is not None and self.execution_error is None
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
bits = [self.outcome.value]
|
|
65
|
+
if self.review_id:
|
|
66
|
+
bits.append(f"review={self.review_id}")
|
|
67
|
+
if self.reason_codes:
|
|
68
|
+
bits.append(f"reasons={','.join(self.reason_codes)}")
|
|
69
|
+
return f"Decision({' '.join(bits)})"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: marchward
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tenet — runtime authority for AI agents. Gate every tool call through cost caps, approval gates, and a tamper-evident audit log.
|
|
5
|
+
Project-URL: Homepage, https://trytenet.com
|
|
6
|
+
Project-URL: Documentation, https://trytenet.com/docs
|
|
7
|
+
Project-URL: Source, https://github.com/trytenet/tenet-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/trytenet/tenet-python/releases
|
|
9
|
+
Author-email: Tenet <team@trytenet.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,ai,audit,cost-cap,governance,guardrails,human-in-the-loop,langchain,langgraph
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Marchward — Python SDK
|
|
27
|
+
|
|
28
|
+
Runtime authority for AI agents. Gate every tool call through a cost cap,
|
|
29
|
+
approval gates on irreversible actions, and a tamper-evident audit log.
|
|
30
|
+
|
|
31
|
+
**Python is the primary Marchward SDK** — the wedge persona builds on
|
|
32
|
+
LangGraph / LangChain (both Python). Zero runtime dependencies (stdlib only).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
```bash
|
|
36
|
+
pip install tenet-python
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
```python
|
|
41
|
+
from tenet import TenetClient
|
|
42
|
+
|
|
43
|
+
tenet = TenetClient(api_key="tnt_...") # or set TENET_API_KEY
|
|
44
|
+
|
|
45
|
+
decision = tenet.execute(
|
|
46
|
+
service="github",
|
|
47
|
+
tool_name="github.repos.delete",
|
|
48
|
+
arguments={"owner": "acme", "repo": "old-experiment"},
|
|
49
|
+
context={"env": "production"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if decision.allowed:
|
|
53
|
+
do_the_delete()
|
|
54
|
+
elif decision.escalated:
|
|
55
|
+
print(f"Paused for approval — review {decision.review_id}")
|
|
56
|
+
elif decision.blocked:
|
|
57
|
+
print(f"Blocked: {decision.reason_codes}")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## With LangGraph (the persona's stack)
|
|
61
|
+
```python
|
|
62
|
+
from langchain_core.tools import tool
|
|
63
|
+
from tenet import TenetClient
|
|
64
|
+
|
|
65
|
+
tenet = TenetClient()
|
|
66
|
+
|
|
67
|
+
@tool
|
|
68
|
+
def delete_repo(owner: str, repo: str) -> str:
|
|
69
|
+
"""Delete a GitHub repository."""
|
|
70
|
+
d = tenet.execute(service="github", tool_name="github.repos.delete",
|
|
71
|
+
arguments={"owner": owner, "repo": repo})
|
|
72
|
+
if not d.allowed:
|
|
73
|
+
return f"Refused by Marchward ({d.outcome.value})."
|
|
74
|
+
# ... real delete here ...
|
|
75
|
+
return "deleted"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## How it works (Model B)
|
|
79
|
+
You send a logical tool call — `service` + `tool_name` + `arguments`. Marchward
|
|
80
|
+
resolves the real downstream HTTP request from its tool catalog, governs it,
|
|
81
|
+
injects your stored credential server-side, and executes it. Your agent holds
|
|
82
|
+
only `TENET_API_KEY`; it never touches downstream credentials. Connect those
|
|
83
|
+
once in the dashboard (Settings → Connected services).
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
- `TenetClient(api_key=None, *, api_url=None, default_agent_id="python-sdk", timeout=30.0, poll_timeout=120.0, poll_interval=0.75)`
|
|
87
|
+
- `.execute(*, service, tool_name, arguments=None, context=None, agent_id=None, request_id=None, wait=True) -> Decision`
|
|
88
|
+
- `.get_job(job_id) -> dict` — poll one async job manually (for `wait=False`).
|
|
89
|
+
- `Decision`: `.allowed` / `.escalated` / `.blocked` / `.executed`, plus `.outcome`,
|
|
90
|
+
`.decision_id`, `.review_id`, `.reason_codes`, `.http_status`, `.raw`,
|
|
91
|
+
`.job_id`, `.execution`, `.execution_error`.
|
|
92
|
+
|
|
93
|
+
## Contract
|
|
94
|
+
| HTTP | Outcome | Meaning |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| 200/202 + jobId | ALLOW | authorized; downstream runs async — the SDK polls the job and fills `.execution` (set `wait=False` to poll yourself) |
|
|
97
|
+
| 202 + reviewId | ESCALATE | held for human approval; auto-executes on approve |
|
|
98
|
+
| 403 | BLOCK | refused by policy |
|
|
99
|
+
| 401 | — | `TenetAuthError` (bad/missing/revoked key) |
|
|
100
|
+
|
|
101
|
+
`.executed` is `True` only when an ALLOW actually ran its downstream — an
|
|
102
|
+
ALLOW with no connected credential, a failed downstream, or a still-pending
|
|
103
|
+
job is allowed-but-not-executed.
|
|
104
|
+
|
|
105
|
+
## Risk classification
|
|
106
|
+
Risk is classified by the **resolved HTTP method**, not the tool name — any
|
|
107
|
+
`DELETE` (or a flagged destructive `POST` like `stripe.charges.create`) is
|
|
108
|
+
treated as irreversible and gated, regardless of what the tool is named. So a
|
|
109
|
+
custom-named destructive tool can't slip past the approval gate.
|
|
110
|
+
|
|
111
|
+
## Tests
|
|
112
|
+
```bash
|
|
113
|
+
cd packages/sdk-python && python -m unittest discover -s tests
|
|
114
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
marchward/__init__.py,sha256=lfvtK8lBOx13KRNjcM2ZEhHpsWwsPgDEWTMmxRPryaE,1495
|
|
2
|
+
marchward/client.py,sha256=CDn9gWhYwc3i7j4Kh8Kw8GvS0OI6rwYH3AQ60HoE31I,12330
|
|
3
|
+
marchward/errors.py,sha256=lJS2Y9PqskP4uhL82hDj712Y2CSaIXe94Ce1aIAxRuY,582
|
|
4
|
+
marchward/models.py,sha256=CGye295YbHDm_4e61BtAee03GLkfvxEqVzz7WXRykMI,2536
|
|
5
|
+
marchward-0.1.0.dist-info/METADATA,sha256=4nbFe3bWgToCUlPyjDSZChFAJi4VecQUj4GtXXSwn7U,4463
|
|
6
|
+
marchward-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
marchward-0.1.0.dist-info/licenses/LICENSE,sha256=UbfFi7BOqNW_2AixHhoot6oQ0ohuoMiA2aw0S92M8HI,1062
|
|
8
|
+
marchward-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tenet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|