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.
- {pywrit-0.1.2 → pywrit-0.2.0}/LICENSE +1 -1
- pywrit-0.2.0/PKG-INFO +97 -0
- pywrit-0.2.0/README.md +83 -0
- pywrit-0.2.0/pyproject.toml +26 -0
- pywrit-0.2.0/src/pywrit/__init__.py +41 -0
- {pywrit-0.1.2/writ_cli → pywrit-0.2.0/src/pywrit}/cli.py +33 -4
- pywrit-0.2.0/src/pywrit/client.py +249 -0
- pywrit-0.2.0/src/pywrit/errors.py +30 -0
- pywrit-0.2.0/src/pywrit.egg-info/PKG-INFO +97 -0
- pywrit-0.2.0/src/pywrit.egg-info/SOURCES.txt +14 -0
- pywrit-0.2.0/src/pywrit.egg-info/entry_points.txt +2 -0
- pywrit-0.2.0/src/pywrit.egg-info/requires.txt +1 -0
- pywrit-0.2.0/src/pywrit.egg-info/top_level.txt +1 -0
- pywrit-0.2.0/tests/test_client.py +99 -0
- pywrit-0.1.2/PKG-INFO +0 -80
- pywrit-0.1.2/README.md +0 -64
- pywrit-0.1.2/pyproject.toml +0 -32
- pywrit-0.1.2/pywrit.egg-info/PKG-INFO +0 -80
- pywrit-0.1.2/pywrit.egg-info/SOURCES.txt +0 -10
- pywrit-0.1.2/pywrit.egg-info/entry_points.txt +0 -2
- pywrit-0.1.2/pywrit.egg-info/top_level.txt +0 -1
- pywrit-0.1.2/writ_cli/__init__.py +0 -3
- {pywrit-0.1.2 → pywrit-0.2.0}/setup.cfg +0 -0
- {pywrit-0.1.2 → pywrit-0.2.0/src}/pywrit.egg-info/dependency_links.txt +0 -0
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
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
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
|
-
```
|
pywrit-0.1.2/pyproject.toml
DELETED
|
@@ -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 +0,0 @@
|
|
|
1
|
-
writ_cli
|
|
File without changes
|
|
File without changes
|