pywrit 0.1.2__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Writ
3
+ Copyright (c) 2026 Avenue D
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
pywrit-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywrit
3
+ Version: 0.2.0
4
+ Summary: Python client + CLI for Writ — the gate before the write
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
7
+ Project-URL: Documentation, https://withwrit.com/docs
8
+ Keywords: ai-agents,policy,audit-log,guardrails
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: httpx>=0.27
13
+ Dynamic: license-file
14
+
15
+ # pywrit
16
+
17
+ Python client + CLI for [Writ](https://withwrit.com) — the gate before the write.
18
+ Put a policy gate in front of your agent's dangerous actions.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install pywrit
24
+ ```
25
+
26
+ ## Python client
27
+
28
+ ```python
29
+ from pywrit import WritClient
30
+
31
+ client = WritClient(api_key="writ_...")
32
+
33
+ result = client.check(
34
+ sponsor_id="acme",
35
+ agent_id="agent-7",
36
+ verb="db.write",
37
+ target="prod.customers",
38
+ purpose="backfill region field",
39
+ )
40
+
41
+ if result.decision == "ALLOW":
42
+ # result.auth_token is a short-lived token bound to this exact write
43
+ perform_write(...)
44
+ elif result.decision == "STEP_UP":
45
+ # a human sponsor must approve first: client.grant(...), then re-check
46
+ ...
47
+ else:
48
+ # DENY
49
+ ...
50
+ ```
51
+
52
+ No API key yet? Try the keyless sandbox:
53
+
54
+ ```python
55
+ client = WritClient()
56
+ client.sandbox({
57
+ "sponsorId": "acme",
58
+ "agentId": "agent-7",
59
+ "verb": "demo_write", # sandbox only allows demo_write ...
60
+ "target": "demo-customers", # ... on targets starting with demo-
61
+ "purpose": "trying the gate",
62
+ })
63
+ ```
64
+
65
+ ### What's covered
66
+
67
+ - `check(...)` — the gate: `ALLOW` / `DENY` / `STEP_UP`, plus a receipt every time
68
+ - `verify_token(...)` — validate an `ALLOW` auth token (catches purpose drift)
69
+ - `grant(...)` — human-sponsor approval for the `STEP_UP` path
70
+ - `get_policy()` / `set_policy(...)` — manage the tenant policy
71
+ - `revoke(...)` / `reinstate(...)` / `revoked()` — the kill switch
72
+ - `receipts()` / `receipt(id)` / `verify_chain()` / `stream_receipts()` — the audit log
73
+ - `sandbox(...)` — keyless trial, no API key required
74
+
75
+ Every decision writes a receipt, so the audit log is the meter.
76
+
77
+ ## CLI
78
+
79
+ The same package ships the `writ` command:
80
+
81
+ ```bash
82
+ writ check --key writ_... --sponsor acme --agent agent-7 \
83
+ --verb db.write --target prod.customers --purpose "backfill region field"
84
+ writ scan ./my-repo
85
+ writ receipts --key writ_...
86
+ writ verify-chain --key writ_...
87
+ ```
88
+
89
+ Run `writ --help` for the full command list.
90
+
91
+ ## Docs
92
+
93
+ Full API reference: [withwrit.com/docs](https://withwrit.com/docs)
94
+
95
+ ## License
96
+
97
+ MIT
pywrit-0.2.0/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # pywrit
2
+
3
+ Python client + CLI for [Writ](https://withwrit.com) — the gate before the write.
4
+ Put a policy gate in front of your agent's dangerous actions.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install pywrit
10
+ ```
11
+
12
+ ## Python client
13
+
14
+ ```python
15
+ from pywrit import WritClient
16
+
17
+ client = WritClient(api_key="writ_...")
18
+
19
+ result = client.check(
20
+ sponsor_id="acme",
21
+ agent_id="agent-7",
22
+ verb="db.write",
23
+ target="prod.customers",
24
+ purpose="backfill region field",
25
+ )
26
+
27
+ if result.decision == "ALLOW":
28
+ # result.auth_token is a short-lived token bound to this exact write
29
+ perform_write(...)
30
+ elif result.decision == "STEP_UP":
31
+ # a human sponsor must approve first: client.grant(...), then re-check
32
+ ...
33
+ else:
34
+ # DENY
35
+ ...
36
+ ```
37
+
38
+ No API key yet? Try the keyless sandbox:
39
+
40
+ ```python
41
+ client = WritClient()
42
+ client.sandbox({
43
+ "sponsorId": "acme",
44
+ "agentId": "agent-7",
45
+ "verb": "demo_write", # sandbox only allows demo_write ...
46
+ "target": "demo-customers", # ... on targets starting with demo-
47
+ "purpose": "trying the gate",
48
+ })
49
+ ```
50
+
51
+ ### What's covered
52
+
53
+ - `check(...)` — the gate: `ALLOW` / `DENY` / `STEP_UP`, plus a receipt every time
54
+ - `verify_token(...)` — validate an `ALLOW` auth token (catches purpose drift)
55
+ - `grant(...)` — human-sponsor approval for the `STEP_UP` path
56
+ - `get_policy()` / `set_policy(...)` — manage the tenant policy
57
+ - `revoke(...)` / `reinstate(...)` / `revoked()` — the kill switch
58
+ - `receipts()` / `receipt(id)` / `verify_chain()` / `stream_receipts()` — the audit log
59
+ - `sandbox(...)` — keyless trial, no API key required
60
+
61
+ Every decision writes a receipt, so the audit log is the meter.
62
+
63
+ ## CLI
64
+
65
+ The same package ships the `writ` command:
66
+
67
+ ```bash
68
+ writ check --key writ_... --sponsor acme --agent agent-7 \
69
+ --verb db.write --target prod.customers --purpose "backfill region field"
70
+ writ scan ./my-repo
71
+ writ receipts --key writ_...
72
+ writ verify-chain --key writ_...
73
+ ```
74
+
75
+ Run `writ --help` for the full command list.
76
+
77
+ ## Docs
78
+
79
+ Full API reference: [withwrit.com/docs](https://withwrit.com/docs)
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pywrit"
7
+ version = "0.2.0"
8
+ description = "Python client + CLI for Writ — the gate before the write"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = ["httpx>=0.27"]
13
+ keywords = ["ai-agents", "policy", "audit-log", "guardrails"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/AvenueDAdmin/pywrit"
17
+ Documentation = "https://withwrit.com/docs"
18
+
19
+ [project.scripts]
20
+ writ = "pywrit.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
@@ -0,0 +1,41 @@
1
+ """pywrit — Python client for Writ, the gate before the write.
2
+
3
+ Put a policy gate in front of your agent's dangerous actions:
4
+
5
+ from pywrit import WritClient
6
+
7
+ client = WritClient(api_key="writ_...")
8
+ result = client.check(
9
+ sponsor_id="acme",
10
+ agent_id="agent-7",
11
+ verb="db.write",
12
+ target="prod.customers",
13
+ purpose="backfill region field",
14
+ )
15
+ if result.decision == "ALLOW":
16
+ ... # perform the write, result.auth_token proves it was gated
17
+ elif result.decision == "STEP_UP":
18
+ ... # a human sponsor must approve via client.grant(...)
19
+ """
20
+
21
+ from .client import CheckResult, WritClient
22
+ from .errors import (
23
+ AuthenticationError,
24
+ NotFoundError,
25
+ PaymentRequiredError,
26
+ RateLimitError,
27
+ WritError,
28
+ )
29
+
30
+ __version__ = "0.2.0"
31
+
32
+ __all__ = [
33
+ "WritClient",
34
+ "CheckResult",
35
+ "WritError",
36
+ "AuthenticationError",
37
+ "PaymentRequiredError",
38
+ "NotFoundError",
39
+ "RateLimitError",
40
+ "__version__",
41
+ ]
@@ -391,20 +391,49 @@ def _find_func(tree, func_name, site_lineno):
391
391
 
392
392
 
393
393
  def _helper_insert_index(lines, tree):
394
+ """Return line index after which to splice the helper (module level).
395
+
396
+ Always inserts before the first module-level function or class.
397
+ Returns -1 to insert at the start of the file (before line 0).
398
+ `_apply_edits` splices at ``idx + 1``, so -1 → position 0.
399
+ """
400
+ first_def = None
401
+ for node in tree.body:
402
+ if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef,
403
+ _ast.ClassDef)):
404
+ first_def = node.lineno - 1
405
+ break
406
+
407
+ def _cap(idx):
408
+ # Never insert at or after the first def/class (would IndentationError).
409
+ if first_def is None:
410
+ return idx
411
+ return min(idx, first_def - 1)
412
+
394
413
  last_import_end = -1
395
414
  for node in tree.body:
396
415
  if isinstance(node, (_ast.Import, _ast.ImportFrom)):
397
- last_import_end = node.end_lineno - 1
416
+ end = node.end_lineno - 1
417
+ if first_def is not None and end >= first_def:
418
+ continue
419
+ last_import_end = end
398
420
  if last_import_end >= 0:
399
- return last_import_end
421
+ return _cap(last_import_end)
422
+
400
423
  if tree.body and isinstance(tree.body[0], _ast.Expr) and \
401
424
  isinstance(tree.body[0].value, _ast.Constant) and \
402
425
  isinstance(tree.body[0].value.value, str):
403
- return tree.body[0].end_lineno - 1
426
+ return _cap(tree.body[0].end_lineno - 1)
427
+
404
428
  idx = -1
405
429
  if lines and lines[0].startswith("#!"):
406
430
  idx = 0
407
- return idx
431
+ # PEP 263 encoding cookie on line 1 or 2
432
+ enc = 1 if idx == 0 else 0
433
+ if (len(lines) > enc and lines[enc].lstrip().startswith("#")
434
+ and "coding" in lines[enc]):
435
+ idx = enc
436
+ return _cap(idx)
408
437
 
409
438
 
410
439
  def _build_edits(path, rel_path, source, sites, gated):
@@ -0,0 +1,249 @@
1
+ """Synchronous Python client for the Writ gate API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Iterator, List, Optional
7
+
8
+ import httpx
9
+
10
+ from .errors import (
11
+ AuthenticationError,
12
+ NotFoundError,
13
+ PaymentRequiredError,
14
+ RateLimitError,
15
+ WritError,
16
+ )
17
+
18
+ DEFAULT_BASE_URL = "https://j72ckh66ukck2kcbq3oiwxaalm0olxwb.lambda-url.us-east-1.on.aws"
19
+
20
+
21
+ @dataclass
22
+ class CheckResult:
23
+ """The outcome of a gate check."""
24
+
25
+ decision: str # ALLOW | DENY | STEP_UP
26
+ receipt: Dict[str, Any] = field(default_factory=dict)
27
+ auth_token: Optional[str] = None
28
+ token_expires_in: Optional[int] = None
29
+ raw: Dict[str, Any] = field(default_factory=dict)
30
+
31
+ @property
32
+ def allowed(self) -> bool:
33
+ return self.decision == "ALLOW"
34
+
35
+ @property
36
+ def receipt_id(self) -> Optional[str]:
37
+ return self.receipt.get("receiptId") or self.raw.get("receiptId")
38
+
39
+
40
+ class WritClient:
41
+ """Client for the Writ policy gate.
42
+
43
+ Args:
44
+ api_key: Your ``writ_`` API key. Not required for the keyless
45
+ sandbox (``sandbox()``).
46
+ base_url: Gate base URL. Defaults to the hosted gate.
47
+ timeout: HTTP timeout in seconds.
48
+ http_client: Optional pre-configured ``httpx.Client`` (useful for tests).
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ api_key: Optional[str] = None,
54
+ base_url: str = DEFAULT_BASE_URL,
55
+ timeout: float = 30.0,
56
+ http_client: Optional[httpx.Client] = None,
57
+ ):
58
+ self.api_key = api_key
59
+ self.base_url = base_url.rstrip("/")
60
+ self._client = http_client or httpx.Client(timeout=timeout)
61
+
62
+ # -- internals --------------------------------------------------------
63
+
64
+ def _headers(self, authenticated: bool = True) -> Dict[str, str]:
65
+ headers = {"Content-Type": "application/json"}
66
+ if authenticated:
67
+ if not self.api_key:
68
+ raise AuthenticationError("An API key is required for this call.")
69
+ headers["Authorization"] = f"Bearer {self.api_key}"
70
+ return headers
71
+
72
+ def _request(self, method: str, path: str, authenticated: bool = True, **kwargs) -> Any:
73
+ try:
74
+ resp = self._client.request(
75
+ method, self.base_url + path, headers=self._headers(authenticated), **kwargs
76
+ )
77
+ except httpx.HTTPError as exc:
78
+ raise WritError(f"Request failed: {exc}") from exc
79
+ if resp.status_code == 401:
80
+ raise AuthenticationError("Invalid or missing API key.", 401, _safe_json(resp))
81
+ if resp.status_code == 402:
82
+ raise PaymentRequiredError(
83
+ "Free tier exhausted or billing past due — no receipt was written.",
84
+ 402,
85
+ _safe_json(resp),
86
+ )
87
+ if resp.status_code == 404:
88
+ raise NotFoundError("Not found.", 404, _safe_json(resp))
89
+ if resp.status_code == 429:
90
+ raise RateLimitError("Rate limited — slow down and retry.", 429, _safe_json(resp))
91
+ if resp.status_code >= 400:
92
+ body = _safe_json(resp)
93
+ raise WritError(
94
+ f"Writ API error {resp.status_code}: {body or resp.text}",
95
+ resp.status_code,
96
+ body,
97
+ )
98
+ if not resp.content:
99
+ return None
100
+ return resp.json()
101
+
102
+ # -- the gate ----------------------------------------------------------
103
+
104
+ def check(
105
+ self,
106
+ sponsor_id: str,
107
+ agent_id: str,
108
+ verb: str,
109
+ target: str,
110
+ purpose: str,
111
+ ttl_seconds: Optional[int] = None,
112
+ ) -> CheckResult:
113
+ """Ask the gate whether an agent may perform a write.
114
+
115
+ Returns a :class:`CheckResult` with ``decision`` of ``ALLOW``,
116
+ ``DENY``, or ``STEP_UP``. On ``ALLOW`` the result also carries
117
+ ``auth_token`` — a short-lived token bound to this exact
118
+ sponsor/agent/verb/target/purpose. On ``STEP_UP`` a human sponsor
119
+ must approve via :meth:`grant`, then the agent re-checks.
120
+ """
121
+ payload: Dict[str, Any] = {
122
+ "sponsorId": sponsor_id,
123
+ "agentId": agent_id,
124
+ "verb": verb,
125
+ "target": target,
126
+ "purpose": purpose,
127
+ }
128
+ if ttl_seconds is not None:
129
+ payload["ttlSeconds"] = ttl_seconds
130
+ raw = self._request("POST", "/v1/check", json=payload)
131
+ return CheckResult(
132
+ decision=raw.get("decision", "DENY"),
133
+ receipt=raw.get("receipt", {}),
134
+ auth_token=raw.get("authToken"),
135
+ token_expires_in=raw.get("tokenExpiresIn"),
136
+ raw=raw,
137
+ )
138
+
139
+ def verify_token(
140
+ self,
141
+ token: str,
142
+ verb: Optional[str] = None,
143
+ target: Optional[str] = None,
144
+ purpose: Optional[str] = None,
145
+ ) -> Dict[str, Any]:
146
+ """Validate an ``ALLOW`` auth token.
147
+
148
+ When verb/target/purpose are given, the token must be bound to
149
+ exactly that write — anything else is purpose drift and returns
150
+ ``{"valid": False, ...}``.
151
+ """
152
+ payload: Dict[str, Any] = {"token": token}
153
+ if verb is not None:
154
+ payload["verb"] = verb
155
+ if target is not None:
156
+ payload["target"] = target
157
+ if purpose is not None:
158
+ payload["purpose"] = purpose
159
+ return self._request("POST", "/v1/tokens/verify", json=payload)
160
+
161
+ def grant(self, payload: Dict[str, Any]) -> Dict[str, Any]:
162
+ """Mint a human-sponsor grant (the STEP_UP approval path)."""
163
+ return self._request("POST", "/v1/grants", json=payload)
164
+
165
+ # -- policy ------------------------------------------------------------
166
+
167
+ def get_policy(self) -> Dict[str, Any]:
168
+ """Fetch the tenant's current policy document."""
169
+ return self._request("GET", "/v1/policy")
170
+
171
+ def set_policy(self, policy: Dict[str, Any]) -> Dict[str, Any]:
172
+ """Replace the tenant's policy document."""
173
+ return self._request("PUT", "/v1/policy", json=policy)
174
+
175
+ # -- kill switch ---------------------------------------------------------
176
+
177
+ def revoke(self, sponsor_id: str, agent_id: str, **kwargs: Any) -> Dict[str, Any]:
178
+ """Revoke an agent's ability to write."""
179
+ return self._request(
180
+ "POST", "/v1/revoke", json={"sponsorId": sponsor_id, "agentId": agent_id, **kwargs}
181
+ )
182
+
183
+ def reinstate(self, sponsor_id: str, agent_id: str, **kwargs: Any) -> Dict[str, Any]:
184
+ """Reinstate a previously revoked agent."""
185
+ return self._request(
186
+ "POST", "/v1/reinstate", json={"sponsorId": sponsor_id, "agentId": agent_id, **kwargs}
187
+ )
188
+
189
+ def revoked(self) -> List[Dict[str, Any]]:
190
+ """List currently revoked agents."""
191
+ return self._request("GET", "/v1/revoked")
192
+
193
+ # -- audit log -----------------------------------------------------------
194
+
195
+ def receipts(self, limit: int = 50) -> List[Dict[str, Any]]:
196
+ """List recent receipts (the audit log)."""
197
+ return self._request("GET", "/v1/receipts", params={"limit": limit})
198
+
199
+ def receipt(self, receipt_id: str) -> Dict[str, Any]:
200
+ """Fetch a single receipt by id."""
201
+ return self._request("GET", f"/v1/receipts/{receipt_id}")
202
+
203
+ def verify_chain(self, limit: int = 200) -> Dict[str, Any]:
204
+ """Verify the tamper-evident audit-log hash chain.
205
+
206
+ Recomputes every chained receipt's HMAC and checks each link.
207
+ Returns ``{"ok": True, ...}`` when the chain is intact.
208
+ """
209
+ return self._request("GET", "/v1/receipts/verify", params={"limit": limit})
210
+
211
+ def stream_receipts(self) -> Iterator[Dict[str, Any]]:
212
+ """Yield receipts as they are written (server-sent events)."""
213
+ with self._client.stream(
214
+ "GET", self.base_url + "/v1/receipts/stream", headers=self._headers()
215
+ ) as resp:
216
+ if resp.status_code != 200:
217
+ raise WritError(f"Stream failed: HTTP {resp.status_code}", resp.status_code)
218
+ import json
219
+
220
+ for line in resp.iter_lines():
221
+ if line.startswith("data:"):
222
+ yield json.loads(line[5:].strip())
223
+
224
+ # -- keyless trial ---------------------------------------------------------
225
+
226
+ def sandbox(self, payload: Dict[str, Any]) -> Dict[str, Any]:
227
+ """Run a check against the keyless sandbox. No API key required.
228
+
229
+ The sandbox only allows verb ``demo_write`` and targets starting
230
+ with ``demo-``. The payload needs ``sponsorId``, ``agentId``,
231
+ ``verb``, ``target`` and ``purpose``.
232
+ """
233
+ return self._request("POST", "/v1/sandbox", authenticated=False, json=payload)
234
+
235
+ def close(self) -> None:
236
+ self._client.close()
237
+
238
+ def __enter__(self) -> "WritClient":
239
+ return self
240
+
241
+ def __exit__(self, *exc: Any) -> None:
242
+ self.close()
243
+
244
+
245
+ def _safe_json(resp: httpx.Response) -> Any:
246
+ try:
247
+ return resp.json()
248
+ except Exception:
249
+ return None
@@ -0,0 +1,30 @@
1
+ """Typed errors for the Writ API."""
2
+
3
+
4
+ class WritError(Exception):
5
+ """Base error for Writ API failures."""
6
+
7
+ def __init__(self, message, status_code=None, body=None):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+ self.body = body
11
+
12
+
13
+ class AuthenticationError(WritError):
14
+ """401 — missing or invalid API key."""
15
+
16
+
17
+ class PaymentRequiredError(WritError):
18
+ """402 — free tier exhausted or billing past due. No receipt is written."""
19
+
20
+ def __init__(self, message, status_code=None, body=None):
21
+ super().__init__(message, status_code, body)
22
+ self.portal_endpoint = (body or {}).get("portal_endpoint")
23
+
24
+
25
+ class NotFoundError(WritError):
26
+ """404 — the requested resource does not exist."""
27
+
28
+
29
+ class RateLimitError(WritError):
30
+ """429 — slow down and retry."""
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywrit
3
+ Version: 0.2.0
4
+ Summary: Python client + CLI for Writ — the gate before the write
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
7
+ Project-URL: Documentation, https://withwrit.com/docs
8
+ Keywords: ai-agents,policy,audit-log,guardrails
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: httpx>=0.27
13
+ Dynamic: license-file
14
+
15
+ # pywrit
16
+
17
+ Python client + CLI for [Writ](https://withwrit.com) — the gate before the write.
18
+ Put a policy gate in front of your agent's dangerous actions.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install pywrit
24
+ ```
25
+
26
+ ## Python client
27
+
28
+ ```python
29
+ from pywrit import WritClient
30
+
31
+ client = WritClient(api_key="writ_...")
32
+
33
+ result = client.check(
34
+ sponsor_id="acme",
35
+ agent_id="agent-7",
36
+ verb="db.write",
37
+ target="prod.customers",
38
+ purpose="backfill region field",
39
+ )
40
+
41
+ if result.decision == "ALLOW":
42
+ # result.auth_token is a short-lived token bound to this exact write
43
+ perform_write(...)
44
+ elif result.decision == "STEP_UP":
45
+ # a human sponsor must approve first: client.grant(...), then re-check
46
+ ...
47
+ else:
48
+ # DENY
49
+ ...
50
+ ```
51
+
52
+ No API key yet? Try the keyless sandbox:
53
+
54
+ ```python
55
+ client = WritClient()
56
+ client.sandbox({
57
+ "sponsorId": "acme",
58
+ "agentId": "agent-7",
59
+ "verb": "demo_write", # sandbox only allows demo_write ...
60
+ "target": "demo-customers", # ... on targets starting with demo-
61
+ "purpose": "trying the gate",
62
+ })
63
+ ```
64
+
65
+ ### What's covered
66
+
67
+ - `check(...)` — the gate: `ALLOW` / `DENY` / `STEP_UP`, plus a receipt every time
68
+ - `verify_token(...)` — validate an `ALLOW` auth token (catches purpose drift)
69
+ - `grant(...)` — human-sponsor approval for the `STEP_UP` path
70
+ - `get_policy()` / `set_policy(...)` — manage the tenant policy
71
+ - `revoke(...)` / `reinstate(...)` / `revoked()` — the kill switch
72
+ - `receipts()` / `receipt(id)` / `verify_chain()` / `stream_receipts()` — the audit log
73
+ - `sandbox(...)` — keyless trial, no API key required
74
+
75
+ Every decision writes a receipt, so the audit log is the meter.
76
+
77
+ ## CLI
78
+
79
+ The same package ships the `writ` command:
80
+
81
+ ```bash
82
+ writ check --key writ_... --sponsor acme --agent agent-7 \
83
+ --verb db.write --target prod.customers --purpose "backfill region field"
84
+ writ scan ./my-repo
85
+ writ receipts --key writ_...
86
+ writ verify-chain --key writ_...
87
+ ```
88
+
89
+ Run `writ --help` for the full command list.
90
+
91
+ ## Docs
92
+
93
+ Full API reference: [withwrit.com/docs](https://withwrit.com/docs)
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pywrit/__init__.py
5
+ src/pywrit/cli.py
6
+ src/pywrit/client.py
7
+ src/pywrit/errors.py
8
+ src/pywrit.egg-info/PKG-INFO
9
+ src/pywrit.egg-info/SOURCES.txt
10
+ src/pywrit.egg-info/dependency_links.txt
11
+ src/pywrit.egg-info/entry_points.txt
12
+ src/pywrit.egg-info/requires.txt
13
+ src/pywrit.egg-info/top_level.txt
14
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ writ = pywrit.cli:main
@@ -0,0 +1 @@
1
+ httpx>=0.27
@@ -0,0 +1 @@
1
+ pywrit
@@ -0,0 +1,99 @@
1
+ """Tests for pywrit. Run with: python -m pytest"""
2
+
3
+ import json
4
+
5
+ import httpx
6
+
7
+ from pywrit import PaymentRequiredError, WritClient, WritError
8
+
9
+
10
+ def _client(handler):
11
+ transport = httpx.MockTransport(handler)
12
+ return WritClient(api_key="writ_test", http_client=httpx.Client(transport=transport))
13
+
14
+
15
+ def test_check_allow():
16
+ def handler(request):
17
+ assert request.url.path == "/v1/check"
18
+ body = json.loads(request.content)
19
+ assert body["verb"] == "db.write"
20
+ return httpx.Response(
21
+ 200,
22
+ json={
23
+ "decision": "ALLOW",
24
+ "receipt": {"receiptId": "r_123"},
25
+ "authToken": "tok_abc",
26
+ "tokenExpiresIn": 90,
27
+ },
28
+ )
29
+
30
+ result = _client(handler).check("acme", "agent-7", "db.write", "prod.customers", "backfill")
31
+ assert result.decision == "ALLOW"
32
+ assert result.allowed
33
+ assert result.receipt_id == "r_123"
34
+ assert result.auth_token == "tok_abc"
35
+
36
+
37
+ def test_check_step_up():
38
+ def handler(request):
39
+ return httpx.Response(200, json={"decision": "STEP_UP", "receipt": {}})
40
+
41
+ result = _client(handler).check("acme", "agent-7", "db.write", "prod.customers", "backfill")
42
+ assert result.decision == "STEP_UP"
43
+ assert not result.allowed
44
+
45
+
46
+ def test_payment_required():
47
+ def handler(request):
48
+ return httpx.Response(402, json={"portal_endpoint": "/v1/billing/portal"})
49
+
50
+ try:
51
+ _client(handler).check("acme", "agent-7", "db.write", "t", "p")
52
+ except PaymentRequiredError as exc:
53
+ assert exc.status_code == 402
54
+ assert exc.portal_endpoint == "/v1/billing/portal"
55
+ else:
56
+ raise AssertionError("expected PaymentRequiredError")
57
+
58
+
59
+ def test_server_error():
60
+ def handler(request):
61
+ return httpx.Response(500, json={"error": "boom"})
62
+
63
+ try:
64
+ _client(handler).receipts()
65
+ except WritError as exc:
66
+ assert exc.status_code == 500
67
+ else:
68
+ raise AssertionError("expected WritError")
69
+
70
+
71
+ def test_verify_chain():
72
+ def handler(request):
73
+ assert request.url.path == "/v1/receipts/verify"
74
+ return httpx.Response(200, json={"ok": True, "checked": 42, "complete": True})
75
+
76
+ report = _client(handler).verify_chain()
77
+ assert report["ok"] is True
78
+ assert report["checked"] == 42
79
+
80
+
81
+ def test_cli_help():
82
+ import io
83
+ import sys
84
+ from contextlib import redirect_stdout
85
+
86
+ from pywrit.cli import main
87
+
88
+ buf = io.StringIO()
89
+ old = sys.argv
90
+ sys.argv = ["writ", "--help"]
91
+ try:
92
+ with redirect_stdout(buf):
93
+ main()
94
+ except SystemExit as exc:
95
+ assert exc.code == 0
96
+ finally:
97
+ sys.argv = old
98
+ assert "check" in buf.getvalue()
99
+ assert "scan" in buf.getvalue()
pywrit-0.1.2/PKG-INFO DELETED
@@ -1,80 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pywrit
3
- Version: 0.1.2
4
- Summary: Ask Writ before an agent writes — allow/deny gate with a tamper-evident audit log
5
- Project-URL: Homepage, https://withwrit.com
6
- Project-URL: Documentation, https://docs.withwrit.com
7
- Keywords: agents,audit-log,authorization,api-keys
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: Operating System :: OS Independent
10
- Classifier: Topic :: Security
11
- Classifier: Topic :: System :: Systems Administration
12
- Requires-Python: >=3.9
13
- Description-Content-Type: text/markdown
14
- License-File: LICENSE
15
- Dynamic: license-file
16
-
17
- # pywrit 🏴‍☠️
18
-
19
- *Arrr!* The Writ CLI — ask before yer agent writes.
20
-
21
- ```bash
22
- pip install pywrit
23
- writ key --email you@company.com
24
- ```
25
-
26
- Writ is an allow/deny gate that sits in front of every write your AI agent makes. Each check writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
27
-
28
- Shiver me timbers, it's that simple.
29
-
30
- ## What it does
31
-
32
- - `writ key` — Get a free API key (1,000 receipts/month, no card)
33
- - `writ check` — Ask the gate before a write (ALLOW or DENY)
34
- - `writ revoke` — Kill switch: revoke a principal's tokens in one click
35
- - `writ receipts` — See your audit log
36
- - `writ scan` — Find every write in your repo and instrument it
37
-
38
- ## Docs
39
-
40
- Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
41
-
42
- ---
43
-
44
- ## Packaging notes (for maintainers)
45
-
46
- Installable distribution of the Writ CLI. `pip install pywrit` gives you the
47
- `writ` command — no `curl` + `chmod` dance.
48
-
49
- ### Layout
50
-
51
- - `pyproject.toml` — package metadata, `writ` console-script entry point
52
- - `writ_cli/__init__.py` — version
53
- - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib)
54
-
55
- ### Relationship to `gate/web/writ`
56
-
57
- `gate/web/writ` is the **self-contained single-file script** served by the Lambda
58
- at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
59
- file with no install step.
60
-
61
- `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
62
- byte-identical by copy. If you change CLI behavior, update **both** and keep
63
- them in sync — the download path and the pip path must behave the same.
64
-
65
- ### Build & test locally
66
-
67
- ```bash
68
- cd cli
69
- python -m build # needs `pip install build`
70
- pip install -e . # editable install, creates the `writ` command
71
- writ --help
72
- ```
73
-
74
- ### Publish
75
-
76
- ```bash
77
- cd cli
78
- python -m build
79
- twine upload dist/*
80
- ```
pywrit-0.1.2/README.md DELETED
@@ -1,64 +0,0 @@
1
- # pywrit 🏴‍☠️
2
-
3
- *Arrr!* The Writ CLI — ask before yer agent writes.
4
-
5
- ```bash
6
- pip install pywrit
7
- writ key --email you@company.com
8
- ```
9
-
10
- Writ is an allow/deny gate that sits in front of every write your AI agent makes. Each check writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
11
-
12
- Shiver me timbers, it's that simple.
13
-
14
- ## What it does
15
-
16
- - `writ key` — Get a free API key (1,000 receipts/month, no card)
17
- - `writ check` — Ask the gate before a write (ALLOW or DENY)
18
- - `writ revoke` — Kill switch: revoke a principal's tokens in one click
19
- - `writ receipts` — See your audit log
20
- - `writ scan` — Find every write in your repo and instrument it
21
-
22
- ## Docs
23
-
24
- Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
25
-
26
- ---
27
-
28
- ## Packaging notes (for maintainers)
29
-
30
- Installable distribution of the Writ CLI. `pip install pywrit` gives you the
31
- `writ` command — no `curl` + `chmod` dance.
32
-
33
- ### Layout
34
-
35
- - `pyproject.toml` — package metadata, `writ` console-script entry point
36
- - `writ_cli/__init__.py` — version
37
- - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib)
38
-
39
- ### Relationship to `gate/web/writ`
40
-
41
- `gate/web/writ` is the **self-contained single-file script** served by the Lambda
42
- at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
43
- file with no install step.
44
-
45
- `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
46
- byte-identical by copy. If you change CLI behavior, update **both** and keep
47
- them in sync — the download path and the pip path must behave the same.
48
-
49
- ### Build & test locally
50
-
51
- ```bash
52
- cd cli
53
- python -m build # needs `pip install build`
54
- pip install -e . # editable install, creates the `writ` command
55
- writ --help
56
- ```
57
-
58
- ### Publish
59
-
60
- ```bash
61
- cd cli
62
- python -m build
63
- twine upload dist/*
64
- ```
@@ -1,32 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=61"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- # Distribution name: pywrit (approved by Chad 2026-09-27).
7
- # The bare name "writ" is taken on PyPI.
8
- name = "pywrit"
9
- version = "0.1.2"
10
- description = "Ask Writ before an agent writes — allow/deny gate with a tamper-evident audit log"
11
- readme = "README.md"
12
- requires-python = ">=3.9"
13
- keywords = ["agents", "audit-log", "authorization", "api-keys"]
14
- classifiers = [
15
- "Programming Language :: Python :: 3",
16
- "Operating System :: OS Independent",
17
- "Topic :: Security",
18
- "Topic :: System :: Systems Administration",
19
- ]
20
- # No third-party dependencies — the CLI is stdlib-only (argparse, json, urllib).
21
- dependencies = []
22
-
23
- [project.urls]
24
- Homepage = "https://withwrit.com"
25
- Documentation = "https://docs.withwrit.com"
26
-
27
- [project.scripts]
28
- writ = "writ_cli.cli:main"
29
-
30
- [tool.setuptools.packages.find]
31
- where = ["."]
32
- include = ["writ_cli*"]
@@ -1,80 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pywrit
3
- Version: 0.1.2
4
- Summary: Ask Writ before an agent writes — allow/deny gate with a tamper-evident audit log
5
- Project-URL: Homepage, https://withwrit.com
6
- Project-URL: Documentation, https://docs.withwrit.com
7
- Keywords: agents,audit-log,authorization,api-keys
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: Operating System :: OS Independent
10
- Classifier: Topic :: Security
11
- Classifier: Topic :: System :: Systems Administration
12
- Requires-Python: >=3.9
13
- Description-Content-Type: text/markdown
14
- License-File: LICENSE
15
- Dynamic: license-file
16
-
17
- # pywrit 🏴‍☠️
18
-
19
- *Arrr!* The Writ CLI — ask before yer agent writes.
20
-
21
- ```bash
22
- pip install pywrit
23
- writ key --email you@company.com
24
- ```
25
-
26
- Writ is an allow/deny gate that sits in front of every write your AI agent makes. Each check writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
27
-
28
- Shiver me timbers, it's that simple.
29
-
30
- ## What it does
31
-
32
- - `writ key` — Get a free API key (1,000 receipts/month, no card)
33
- - `writ check` — Ask the gate before a write (ALLOW or DENY)
34
- - `writ revoke` — Kill switch: revoke a principal's tokens in one click
35
- - `writ receipts` — See your audit log
36
- - `writ scan` — Find every write in your repo and instrument it
37
-
38
- ## Docs
39
-
40
- Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
41
-
42
- ---
43
-
44
- ## Packaging notes (for maintainers)
45
-
46
- Installable distribution of the Writ CLI. `pip install pywrit` gives you the
47
- `writ` command — no `curl` + `chmod` dance.
48
-
49
- ### Layout
50
-
51
- - `pyproject.toml` — package metadata, `writ` console-script entry point
52
- - `writ_cli/__init__.py` — version
53
- - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib)
54
-
55
- ### Relationship to `gate/web/writ`
56
-
57
- `gate/web/writ` is the **self-contained single-file script** served by the Lambda
58
- at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
59
- file with no install step.
60
-
61
- `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
62
- byte-identical by copy. If you change CLI behavior, update **both** and keep
63
- them in sync — the download path and the pip path must behave the same.
64
-
65
- ### Build & test locally
66
-
67
- ```bash
68
- cd cli
69
- python -m build # needs `pip install build`
70
- pip install -e . # editable install, creates the `writ` command
71
- writ --help
72
- ```
73
-
74
- ### Publish
75
-
76
- ```bash
77
- cd cli
78
- python -m build
79
- twine upload dist/*
80
- ```
@@ -1,10 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- pywrit.egg-info/PKG-INFO
5
- pywrit.egg-info/SOURCES.txt
6
- pywrit.egg-info/dependency_links.txt
7
- pywrit.egg-info/entry_points.txt
8
- pywrit.egg-info/top_level.txt
9
- writ_cli/__init__.py
10
- writ_cli/cli.py
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- writ = writ_cli.cli:main
@@ -1 +0,0 @@
1
- writ_cli
@@ -1,3 +0,0 @@
1
- """Writ CLI package."""
2
-
3
- __version__ = "0.1.0"
File without changes