pywrit 0.1.0__tar.gz → 0.1.2__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
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Ask Writ before an agent writes — allow/deny gate with a tamper-evident audit log
5
5
  Project-URL: Homepage, https://withwrit.com
6
6
  Project-URL: Documentation, https://docs.withwrit.com
@@ -14,18 +14,45 @@ Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Dynamic: license-file
16
16
 
17
- # writ-cli (PyPI packaging)
17
+ # pywrit 🏴‍☠️
18
18
 
19
- Installable distribution of the Writ CLI. `pip install writ-cli` gives you the
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
20
47
  `writ` command — no `curl` + `chmod` dance.
21
48
 
22
- ## Layout
49
+ ### Layout
23
50
 
24
51
  - `pyproject.toml` — package metadata, `writ` console-script entry point
25
52
  - `writ_cli/__init__.py` — version
26
53
  - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib)
27
54
 
28
- ## Relationship to `gate/web/writ`
55
+ ### Relationship to `gate/web/writ`
29
56
 
30
57
  `gate/web/writ` is the **self-contained single-file script** served by the Lambda
31
58
  at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
@@ -35,7 +62,7 @@ file with no install step.
35
62
  byte-identical by copy. If you change CLI behavior, update **both** and keep
36
63
  them in sync — the download path and the pip path must behave the same.
37
64
 
38
- ## Build & test locally
65
+ ### Build & test locally
39
66
 
40
67
  ```bash
41
68
  cd cli
@@ -44,20 +71,10 @@ pip install -e . # editable install, creates the `writ` comman
44
71
  writ --help
45
72
  ```
46
73
 
47
- ## Publish (needs Chad's approval + PyPI credentials)
74
+ ### Publish
48
75
 
49
76
  ```bash
50
77
  cd cli
51
78
  python -m build
52
79
  twine upload dist/*
53
80
  ```
54
-
55
- ## Open decisions (before first publish)
56
-
57
- 1. **Distribution name**: `writ` is taken on PyPI. `pyproject.toml` uses
58
- `writ-cli` as a placeholder — Chad picks the final name.
59
- 2. **License**: the repo has no LICENSE file yet. Pick one (MIT recommended)
60
- and add `license = { text = "MIT" }` + trove classifier before publishing.
61
- 3. **Docs**: once published, update `fern/pages/quickstart.mdx` and
62
- `fern/pages/cli.mdx` to offer `pip install <name>` alongside the curl path
63
- (per the repo docs rule — every change ships with its docs).
pywrit-0.1.2/README.md ADDED
@@ -0,0 +1,64 @@
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
+ ```
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # Distribution name: pywrit (approved by Chad 2026-09-27).
7
7
  # The bare name "writ" is taken on PyPI.
8
8
  name = "pywrit"
9
- version = "0.1.0"
9
+ version = "0.1.2"
10
10
  description = "Ask Writ before an agent writes — allow/deny gate with a tamper-evident audit log"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Ask Writ before an agent writes — allow/deny gate with a tamper-evident audit log
5
5
  Project-URL: Homepage, https://withwrit.com
6
6
  Project-URL: Documentation, https://docs.withwrit.com
@@ -14,18 +14,45 @@ Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Dynamic: license-file
16
16
 
17
- # writ-cli (PyPI packaging)
17
+ # pywrit 🏴‍☠️
18
18
 
19
- Installable distribution of the Writ CLI. `pip install writ-cli` gives you the
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
20
47
  `writ` command — no `curl` + `chmod` dance.
21
48
 
22
- ## Layout
49
+ ### Layout
23
50
 
24
51
  - `pyproject.toml` — package metadata, `writ` console-script entry point
25
52
  - `writ_cli/__init__.py` — version
26
53
  - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib)
27
54
 
28
- ## Relationship to `gate/web/writ`
55
+ ### Relationship to `gate/web/writ`
29
56
 
30
57
  `gate/web/writ` is the **self-contained single-file script** served by the Lambda
31
58
  at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
@@ -35,7 +62,7 @@ file with no install step.
35
62
  byte-identical by copy. If you change CLI behavior, update **both** and keep
36
63
  them in sync — the download path and the pip path must behave the same.
37
64
 
38
- ## Build & test locally
65
+ ### Build & test locally
39
66
 
40
67
  ```bash
41
68
  cd cli
@@ -44,20 +71,10 @@ pip install -e . # editable install, creates the `writ` comman
44
71
  writ --help
45
72
  ```
46
73
 
47
- ## Publish (needs Chad's approval + PyPI credentials)
74
+ ### Publish
48
75
 
49
76
  ```bash
50
77
  cd cli
51
78
  python -m build
52
79
  twine upload dist/*
53
80
  ```
54
-
55
- ## Open decisions (before first publish)
56
-
57
- 1. **Distribution name**: `writ` is taken on PyPI. `pyproject.toml` uses
58
- `writ-cli` as a placeholder — Chad picks the final name.
59
- 2. **License**: the repo has no LICENSE file yet. Pick one (MIT recommended)
60
- and add `license = { text = "MIT" }` + trove classifier before publishing.
61
- 3. **Docs**: once published, update `fern/pages/quickstart.mdx` and
62
- `fern/pages/cli.mdx` to offer `pip install <name>` alongside the curl path
63
- (per the repo docs rule — every change ships with its docs).
@@ -0,0 +1,730 @@
1
+ #!/usr/bin/env python3
2
+ """Writ CLI. Get a key and ask the gate before a write."""
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ BASE = os.environ.get(
12
+ "WRIT_BASE",
13
+ "https://j72ckh66ukck2kcbq3oiwxaalm0olxwb.lambda-url.us-east-1.on.aws",
14
+ )
15
+
16
+
17
+ def request(method, path, payload=None, key=None, token=None):
18
+ headers = {"content-type": "application/json"}
19
+ if key:
20
+ headers["authorization"] = "Bearer " + key
21
+ elif token:
22
+ headers["authorization"] = token
23
+ data = None if payload is None else json.dumps(payload).encode()
24
+ req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
25
+ try:
26
+ with urllib.request.urlopen(req, timeout=20) as resp:
27
+ body = resp.read().decode()
28
+ print(body)
29
+ return 0
30
+ except urllib.error.HTTPError as exc:
31
+ print(exc.read().decode(), file=sys.stderr)
32
+ return exc.code
33
+
34
+
35
+ def stream_cmd(args):
36
+ """Tail the tenant audit log via the SSE stream, reconnecting on each
37
+ long-poll expiry. Prints one line per receipt."""
38
+ import time
39
+ import urllib.parse
40
+
41
+ key = args.key
42
+ since = args.since
43
+ deadline = None if args.timeout == 0 else time.time() + args.timeout
44
+ try:
45
+ while deadline is None or time.time() < deadline:
46
+ params = {"apiKey": key, "timeout": "10", "replay": "20"}
47
+ if since:
48
+ params["since"] = since
49
+ url = BASE + "/v1/receipts/stream?" + urllib.parse.urlencode(params)
50
+ req = urllib.request.Request(url, method="GET")
51
+ try:
52
+ with urllib.request.urlopen(req, timeout=30) as resp:
53
+ data_lines = []
54
+ for raw in resp:
55
+ line = raw.decode("utf-8", "replace").rstrip("\n")
56
+ if line.startswith("data:"):
57
+ data_lines.append(line[5:].strip())
58
+ elif line == "" and data_lines:
59
+ try:
60
+ r = json.loads("".join(data_lines))
61
+ except json.JSONDecodeError:
62
+ data_lines = []
63
+ continue
64
+ ts = time.strftime("%H:%M:%S", time.localtime(r.get("createdAt", 0)))
65
+ print(f"{ts} {r.get('decision','?'):7} {r.get('verb','')} {r.get('target','')} "
66
+ f"sponsor={r.get('sponsorId','')} agent={r.get('agentId','')} "
67
+ f"id={r.get('receiptId','')}", flush=True)
68
+ since = r.get("receiptId", since)
69
+ data_lines = []
70
+ # ": ping" heartbeats and "id:"/"event:" lines are ignored
71
+ except urllib.error.HTTPError as exc:
72
+ print(exc.read().decode(), file=sys.stderr)
73
+ return exc.code
74
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
75
+ print(f"stream reconnecting: {exc}", file=sys.stderr)
76
+ time.sleep(2)
77
+ except KeyboardInterrupt:
78
+ pass
79
+ return 0
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # writ scan: find writes in a repo, map them to verbs, and generate the
84
+ # instrumentation diff + policy file + coverage report. Stdlib only.
85
+ # ---------------------------------------------------------------------------
86
+ import ast as _ast
87
+ import difflib as _difflib
88
+ import pathlib as _pathlib
89
+
90
+ _SCAN_MARKER = "writ instrumentation (generated by `writ scan`)"
91
+ _SCAN_HELPER = '''# --- writ instrumentation (generated by `writ scan`) ---
92
+ import json as _writ_json
93
+ import os as _writ_os
94
+ import urllib.request as _writ_urllib
95
+
96
+
97
+ _WRIT_BASE = _writ_os.environ.get(
98
+ "WRIT_BASE",
99
+ "https://j72ckh66ukck2kcbq3oiwxaalm0olxwb.lambda-url.us-east-1.on.aws",
100
+ )
101
+ _WRIT_KEY = _writ_os.environ.get("WRIT_API_KEY", "")
102
+ _WRIT_SPONSOR = _writ_os.environ.get("WRIT_SPONSOR", "app")
103
+ _WRIT_AGENT = _writ_os.environ.get("WRIT_AGENT", "app")
104
+
105
+
106
+ def _writ_check(verb, target="", purpose=""):
107
+ """Ask the Writ gate before a write. Fails closed (DENY) on any error."""
108
+ try:
109
+ body = _writ_json.dumps(
110
+ {
111
+ "sponsorId": _WRIT_SPONSOR,
112
+ "agentId": _WRIT_AGENT,
113
+ "verb": verb,
114
+ "target": target,
115
+ "purpose": purpose,
116
+ }
117
+ ).encode()
118
+ req = _writ_urllib.Request(
119
+ _WRIT_BASE + "/v1/check",
120
+ data=body,
121
+ headers={
122
+ "content-type": "application/json",
123
+ "authorization": "Bearer " + _WRIT_KEY,
124
+ },
125
+ )
126
+ with _writ_urllib.urlopen(req, timeout=10) as resp:
127
+ return _writ_json.loads(resp.read().decode()).get("decision", "DENY")
128
+ except Exception:
129
+ return "DENY"
130
+
131
+
132
+ # --- end writ instrumentation ---
133
+ '''
134
+
135
+ _DB_BARE = {"execute", "executemany", "executescript", "flush", "merge",
136
+ "bulk_save_objects"}
137
+ _DB_HINTED = {"save", "update", "delete", "add", "remove", "commit", "insert"}
138
+ _DB_HINTS = {"db", "session", "conn", "connection", "cursor", "engine",
139
+ "store", "collection", "table", "model", "query", "tx",
140
+ "transaction", "repository", "repo", "dal"}
141
+ _HTTP_BARE = {"post", "put", "patch"}
142
+ _HTTP_HINTED = {"delete", "request"}
143
+ _HTTP_HINTS = {"requests", "session", "client", "http", "api", "rest"}
144
+ _PATH_METHODS = {"write_text", "write_bytes", "unlink", "mkdir", "rmdir",
145
+ "rename", "replace", "touch", "symlink_to", "hardlink_to"}
146
+ _OS_WRITES = {"remove", "unlink", "rename", "replace", "mkdir", "makedirs",
147
+ "rmdir", "removedirs", "truncate", "symlink", "link"}
148
+ _SHUTIL_WRITES = {"rmtree", "move", "copy", "copy2", "copytree", "chown"}
149
+ _EMAIL_METHODS = {"sendmail", "send_message", "send_email",
150
+ "send_templated_email"}
151
+ _QUEUE_BARE = {"publish", "produce", "enqueue"}
152
+ _QUEUE_HINTED = {"send"}
153
+ _QUEUE_HINTS = {"queue", "topic", "channel", "bus", "stream", "kafka",
154
+ "redis", "sqs", "pubsub", "mq", "exchange"}
155
+ _AWS_METHODS = {"put_object", "put_item", "delete_item", "delete_object",
156
+ "send_message", "publish", "upload_file", "upload_fileobj",
157
+ "send_email", "start_execution", "put_metric_data"}
158
+
159
+ _ACTION_SYNONYMS = {
160
+ "create": "create", "add": "create", "insert": "create",
161
+ "register": "create", "enroll": "create", "subscribe": "create",
162
+ "update": "update", "edit": "update", "modify": "update",
163
+ "patch": "update", "set": "update", "upsert": "update",
164
+ "rename": "update",
165
+ "delete": "delete", "remove": "delete", "destroy": "delete",
166
+ "drop": "delete", "archive": "delete", "purge": "delete",
167
+ "send": "send", "dispatch": "send", "notify": "send", "emit": "send",
168
+ "broadcast": "send", "alert": "send",
169
+ "publish": "publish",
170
+ "charge": "charge", "refund": "refund", "capture": "capture",
171
+ "void": "void", "payout": "payout",
172
+ "issue": "issue", "revoke": "revoke", "grant": "grant",
173
+ "upload": "upload", "save": "save", "write": "write",
174
+ "store": "write",
175
+ "revoked": "revoke", "created": "create", "deleted": "delete",
176
+ "updated": "update", "granted": "grant", "archived": "delete",
177
+ }
178
+ _GENERIC_PREFIXES = {"issue", "process", "handle", "do", "run", "perform",
179
+ "execute", "make", "get", "fetch",
180
+ "is", "has", "are", "was", "were", "check"}
181
+ _KIND_FALLBACK_ACTION = {"db": "write", "http": "call", "file": "write",
182
+ "email": "send", "queue": "publish", "aws": "write"}
183
+ _SKIP_DIRS = {"src", "app", "lib", "api", "v1", "v2", "server", "services",
184
+ "models", "routes", "handlers", "controllers", "actions",
185
+ "core", "common", "utils", "util", "pkg", "internal"}
186
+ _SKIP_FILES = {"__init__", "__main__", "main", "app", "index", "conftest",
187
+ "setup"}
188
+ _SKIP_DIR_NAMES = {".git", ".hg", ".svn", "__pycache__", ".venv", "venv",
189
+ "node_modules", ".tox", ".eggs", "dist", "build"}
190
+
191
+
192
+ class _WriteSite:
193
+ __slots__ = ("file", "func", "cls", "lineno", "kind", "snippet")
194
+
195
+ def __init__(self, file, func, cls, lineno, kind, snippet):
196
+ self.file = file
197
+ self.func = func
198
+ self.cls = cls
199
+ self.lineno = lineno
200
+ self.kind = kind
201
+ self.snippet = snippet
202
+
203
+
204
+ def _recv_name(func):
205
+ if isinstance(func, _ast.Attribute):
206
+ v = func.value
207
+ if isinstance(v, _ast.Name):
208
+ return v.id.lower()
209
+ if isinstance(v, _ast.Attribute):
210
+ return v.attr.lower()
211
+ if isinstance(v, _ast.Call):
212
+ return _recv_name(v.func)
213
+ return ""
214
+
215
+
216
+ def _root_name(func):
217
+ while isinstance(func, _ast.Attribute):
218
+ func = func.value
219
+ return func.id.lower() if isinstance(func, _ast.Name) else ""
220
+
221
+
222
+ _READ_SQL = ("select", "with", "explain", "describe", "show", "pragma")
223
+
224
+
225
+ def _sql_is_write(node):
226
+ """True unless the statement is clearly a read (SELECT/WITH/...)."""
227
+ if not node.args:
228
+ return True
229
+ first = node.args[0]
230
+ if isinstance(first, _ast.Constant) and isinstance(first.value, str):
231
+ stmt = first.value.lstrip(" (").lower()
232
+ return not stmt.startswith(_READ_SQL)
233
+ return True
234
+
235
+
236
+ def _open_is_write(node):
237
+ mode = None
238
+ if len(node.args) >= 2 and isinstance(node.args[1], _ast.Constant):
239
+ mode = node.args[1].value
240
+ for kw in node.keywords:
241
+ if kw.arg == "mode" and isinstance(kw.value, _ast.Constant):
242
+ mode = kw.value.value
243
+ return isinstance(mode, str) and any(c in mode for c in "wax+")
244
+
245
+
246
+ def _classify_call(node):
247
+ func = node.func
248
+ if isinstance(func, _ast.Name) and func.id == "open":
249
+ return "file" if _open_is_write(node) else None
250
+ if not isinstance(func, _ast.Attribute):
251
+ return None
252
+ name = func.attr
253
+ recv = _recv_name(func)
254
+ root = _root_name(func)
255
+ if root == "os" and name in _OS_WRITES:
256
+ return "file"
257
+ if root == "shutil" and name in _SHUTIL_WRITES:
258
+ return "file"
259
+ if name in _PATH_METHODS:
260
+ return "file"
261
+ if name in _EMAIL_METHODS:
262
+ return "email"
263
+ if name in _AWS_METHODS:
264
+ return "aws"
265
+ if name in _DB_BARE:
266
+ if name in ("execute", "executemany", "executescript") \
267
+ and not _sql_is_write(node):
268
+ return None
269
+ return "db"
270
+ if name in _DB_HINTED:
271
+ return "db" if recv in _DB_HINTS else None
272
+ if name in _HTTP_BARE:
273
+ return "http"
274
+ if name in _HTTP_HINTED and recv in _HTTP_HINTS:
275
+ return "http"
276
+ if name in _QUEUE_BARE:
277
+ return "queue"
278
+ if name in _QUEUE_HINTED and recv in _QUEUE_HINTS:
279
+ return "queue"
280
+ return None
281
+
282
+
283
+ def _is_gate_call(node):
284
+ func = node.func
285
+ if isinstance(func, _ast.Name):
286
+ return func.id in ("writ_check", "_writ_check")
287
+ if isinstance(func, _ast.Attribute):
288
+ return func.attr in ("writ_check", "_writ_check")
289
+ return False
290
+
291
+
292
+ class _ScanVisitor(_ast.NodeVisitor):
293
+ def __init__(self, src, filename):
294
+ self.src = src
295
+ self.filename = filename
296
+ self.func_stack = []
297
+ self.cls_stack = []
298
+ self.sites = []
299
+ self.gated = set() # (func_name, func_lineno)
300
+
301
+ def visit_ClassDef(self, node):
302
+ self.cls_stack.append(node.name)
303
+ self.generic_visit(node)
304
+ self.cls_stack.pop()
305
+
306
+ def visit_FunctionDef(self, node):
307
+ self._visit_func(node)
308
+
309
+ def visit_AsyncFunctionDef(self, node):
310
+ self._visit_func(node)
311
+
312
+ def _visit_func(self, node):
313
+ self.func_stack.append(node)
314
+ self.generic_visit(node)
315
+ self.func_stack.pop()
316
+
317
+ def visit_Call(self, node):
318
+ if self.func_stack:
319
+ func = self.func_stack[-1]
320
+ if _is_gate_call(node):
321
+ self.gated.add((func.name, func.lineno))
322
+ kind = _classify_call(node)
323
+ if kind:
324
+ cls = self.cls_stack[-1] if self.cls_stack else ""
325
+ try:
326
+ seg = _ast.get_source_segment(self.src, node) or ""
327
+ except Exception:
328
+ seg = ""
329
+ snippet = " ".join(seg.split())[:100]
330
+ self.sites.append(_WriteSite(self.filename, func.name, cls,
331
+ node.lineno, kind, snippet))
332
+ self.generic_visit(node)
333
+
334
+
335
+ def _infer_action(func_name, kind):
336
+ tokens = [t for t in func_name.lower().split("_") if t]
337
+ if not tokens:
338
+ return _KIND_FALLBACK_ACTION[kind]
339
+ first = tokens[0]
340
+ if first in _GENERIC_PREFIXES:
341
+ if len(tokens) > 1:
342
+ second = tokens[1]
343
+ return _ACTION_SYNONYMS.get(second, second)
344
+ return _KIND_FALLBACK_ACTION[kind]
345
+ return _ACTION_SYNONYMS.get(first, first)
346
+
347
+
348
+ def _infer_domain(rel_path, cls_name):
349
+ parts = [p for p in rel_path.parts[:-1]
350
+ if p not in _SKIP_DIRS and not p.startswith((".", "_"))]
351
+ if parts:
352
+ return parts[0].lower().replace("-", "_")
353
+ stem = rel_path.stem
354
+ if stem not in _SKIP_FILES:
355
+ return stem.lower().replace("-", "_")
356
+ if cls_name:
357
+ d = cls_name.lower().replace("service", "").replace("handler", "")
358
+ return d or "app"
359
+ return "app"
360
+
361
+
362
+ def infer_verb(rel_path, func_name, cls_name, kind):
363
+ return "%s.%s" % (_infer_domain(rel_path, cls_name),
364
+ _infer_action(func_name, kind))
365
+
366
+
367
+ def _should_skip(path, root, excludes):
368
+ rel = path.relative_to(root)
369
+ if any(p in _SKIP_DIR_NAMES or p.startswith(".") for p in rel.parts[:-1]):
370
+ return True
371
+ if path.name.startswith("test_") or path.name.endswith("_test.py"):
372
+ return True
373
+ if "tests" in rel.parts or "testing" in rel.parts:
374
+ return True
375
+ s = str(path)
376
+ return any(e in s for e in excludes)
377
+
378
+
379
+ def _collect_files(root, excludes):
380
+ return sorted(p for p in root.rglob("*.py")
381
+ if p.is_file() and not _should_skip(p, root, excludes))
382
+
383
+
384
+ def _find_func(tree, func_name, site_lineno):
385
+ for node in _ast.walk(tree):
386
+ if isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)) \
387
+ and node.name == func_name \
388
+ and node.lineno <= site_lineno <= (node.end_lineno or 10**9):
389
+ return node
390
+ return None
391
+
392
+
393
+ def _helper_insert_index(lines, tree):
394
+ last_import_end = -1
395
+ for node in tree.body:
396
+ if isinstance(node, (_ast.Import, _ast.ImportFrom)):
397
+ last_import_end = node.end_lineno - 1
398
+ if last_import_end >= 0:
399
+ return last_import_end
400
+ if tree.body and isinstance(tree.body[0], _ast.Expr) and \
401
+ isinstance(tree.body[0].value, _ast.Constant) and \
402
+ isinstance(tree.body[0].value.value, str):
403
+ return tree.body[0].end_lineno - 1
404
+ idx = -1
405
+ if lines and lines[0].startswith("#!"):
406
+ idx = 0
407
+ return idx
408
+
409
+
410
+ def _build_edits(path, rel_path, source, sites, gated):
411
+ """Return (edits, verbs, n_gates, manual)."""
412
+ tree = _ast.parse(source)
413
+ lines = source.splitlines(keepends=True)
414
+ by_func = {}
415
+ for s in sites:
416
+ by_func.setdefault((s.func, s.cls), []).append(s)
417
+ edits, verbs, manual = [], {}, []
418
+ n_gates = 0
419
+ for (func_name, cls_name), ss in sorted(by_func.items()):
420
+ verb = infer_verb(rel_path, func_name, cls_name, ss[0].kind)
421
+ verbs[verb] = "require_grant"
422
+ node = _find_func(tree, func_name, min(s.lineno for s in ss))
423
+ if node is None:
424
+ continue
425
+ if (func_name, node.lineno) in gated:
426
+ continue
427
+ if not node.body or node.body[0].lineno == node.lineno:
428
+ manual.append((func_name, verb, "one-line function; gate by hand"))
429
+ continue
430
+ first = lines[node.body[0].lineno - 1]
431
+ stripped = first.lstrip()
432
+ indent = first[:len(first) - len(stripped)]
433
+ if not indent:
434
+ dline = lines[node.lineno - 1]
435
+ indent = dline[:len(dline) - len(dline.lstrip())] + " "
436
+ gate = ["%sif _writ_check(\"%s\") != \"ALLOW\":\n" % (indent, verb),
437
+ "%s raise PermissionError(\"writ denied %s\")\n" % (indent, verb)]
438
+ edits.append((node.lineno - 1, gate))
439
+ n_gates += 1
440
+ if edits and _SCAN_MARKER not in source:
441
+ idx = _helper_insert_index(lines, tree)
442
+ helper_lines = ["\n"] + _SCAN_HELPER.splitlines(keepends=True)
443
+ if not helper_lines[-1].endswith("\n"):
444
+ helper_lines[-1] += "\n"
445
+ # avoid doubling blank lines
446
+ edits.append((idx, helper_lines))
447
+ return edits, verbs, n_gates, manual
448
+
449
+
450
+ def _apply_edits(lines, edits):
451
+ out = list(lines)
452
+ for idx, new_lines in sorted(edits, key=lambda e: e[0], reverse=True):
453
+ out[idx + 1:idx + 1] = new_lines
454
+ return out
455
+
456
+
457
+ def scan_cmd(args):
458
+ import json as _json
459
+ root = _pathlib.Path(args.path).resolve()
460
+ if not root.is_dir():
461
+ print("error: not a directory: %s" % args.path, file=sys.stderr)
462
+ return 2
463
+ files = _collect_files(root, args.exclude or [])
464
+
465
+ sites, gated, skipped = [], set(), []
466
+ for path in files:
467
+ try:
468
+ src = path.read_text(encoding="utf-8")
469
+ except (UnicodeDecodeError, OSError):
470
+ skipped.append(str(path))
471
+ continue
472
+ try:
473
+ tree = _ast.parse(src)
474
+ except SyntaxError:
475
+ skipped.append(str(path))
476
+ continue
477
+ v = _ScanVisitor(src, str(path))
478
+ v.visit(tree)
479
+ sites.extend(v.sites)
480
+ gated.update((str(path), n, ln) for (n, ln) in v.gated)
481
+
482
+ by_func = {}
483
+ for s in sites:
484
+ by_func.setdefault((s.file, s.func, s.cls), []).append(s)
485
+ gated_funcs = {(f, n) for (f, n, ln) in gated}
486
+ # a site is gated if its function has a gate call; match by file+name
487
+ gated_site_count = sum(1 for s in sites if (s.file, s.func) in gated_funcs)
488
+ total = len(sites)
489
+ pct = (100 * gated_site_count // total) if total else 100
490
+
491
+ verbs = {}
492
+ for (f, func_name, cls_name), ss in sorted(by_func.items()):
493
+ rel = _pathlib.Path(f).relative_to(root)
494
+ verbs.setdefault(infer_verb(rel, func_name, cls_name, ss[0].kind),
495
+ "require_grant")
496
+
497
+ print("writ scan: %s" % root)
498
+ print(" files scanned: %d skipped: %d" % (len(files), len(skipped)))
499
+ print(" write sites: %d in %d function(s)" % (total, len(by_func)))
500
+ print(" gated: %d/%d (%d%%)" % (gated_site_count, total, pct))
501
+ if verbs:
502
+ print(" verbs discovered: %d" % len(verbs))
503
+ for vname in sorted(verbs):
504
+ print(" %s" % vname)
505
+
506
+ if verbs:
507
+ ppath = _pathlib.Path(args.policy_out)
508
+ ppath.write_text(_json.dumps(verbs, indent=2) + "\n")
509
+ print(" policy written: %s" % ppath)
510
+
511
+ results = [] # (path, old_lines, new_lines, n_gates)
512
+ manuals = []
513
+ for path in sorted({s.file for s in sites}):
514
+ src = _pathlib.Path(path).read_text(encoding="utf-8")
515
+ rel = _pathlib.Path(path).relative_to(root)
516
+ gated_here = {(n, ln) for (f, n, ln) in gated if f == path}
517
+ edits, _, n_gates, manual = _build_edits(
518
+ path, rel, src, [s for s in sites if s.file == path], gated_here)
519
+ manuals.extend((path, m[0], m[1], m[2]) for m in manual)
520
+ if not edits:
521
+ continue
522
+ new_lines = _apply_edits(src.splitlines(keepends=True), edits)
523
+ results.append((path, src.splitlines(keepends=True), new_lines,
524
+ n_gates))
525
+ diff = "".join(_difflib.unified_diff(
526
+ src.splitlines(keepends=True), new_lines,
527
+ fromfile="a/" + str(rel), tofile="b/" + str(rel)))
528
+ print(diff, end="")
529
+ for path, func_name, verb, why in manuals:
530
+ print(" manual: %s %s() [%s]: %s" % (path, func_name, verb, why))
531
+
532
+ if not results:
533
+ print("nothing to instrument.")
534
+ return 0
535
+
536
+ total_gates = sum(r[3] for r in results)
537
+ do_apply = args.apply
538
+ if do_apply and not args.yes:
539
+ try:
540
+ ans = input("apply %d gate(s) to %d file(s)? [y/N] "
541
+ % (total_gates, len(results))).strip().lower()
542
+ except EOFError:
543
+ ans = ""
544
+ do_apply = ans in ("y", "yes")
545
+ if do_apply:
546
+ for path, _old, new, _n in results:
547
+ _pathlib.Path(path).write_text("".join(new), encoding="utf-8")
548
+ print("applied %d gate(s) to %d file(s)." % (total_gates, len(results)))
549
+ else:
550
+ print("re-run with --apply to write these changes.")
551
+
552
+ if args.push_policy:
553
+ if not args.key:
554
+ print("error: --push-policy needs --key", file=sys.stderr)
555
+ return 2
556
+ return request("PUT", "/v1/policy", verbs, key=args.key)
557
+ return 0
558
+
559
+
560
+
561
+ def main():
562
+ parser = argparse.ArgumentParser(description="Ask Writ before an agent writes.")
563
+ sub = parser.add_subparsers(dest="cmd", required=True)
564
+
565
+ key = sub.add_parser("key", help="Get a free API key + tenant")
566
+ key.add_argument("--email", required=True)
567
+ key.add_argument("--tenant-name", default="", help="Optional tenant name (defaults to email domain)")
568
+ key.add_argument("--invite-code", default="", help="Join an existing tenant instead of creating one")
569
+
570
+ me = sub.add_parser("me", help="Show tenant, key meta, and policy")
571
+ me.add_argument("--key", required=True)
572
+
573
+ MODES = ("allow", "deny", "require_grant", "step_up")
574
+ policy = sub.add_parser("policy", help="Get or set tenant verb policy")
575
+ policy.add_argument("--key", required=True)
576
+ policy.add_argument("--set", nargs=2, metavar=("VERB", "MODE"), action="append", default=[])
577
+
578
+ check = sub.add_parser("check", help="ALLOW or DENY a write")
579
+ check.add_argument("--key", required=True)
580
+ check.add_argument("--sponsor", required=True)
581
+ check.add_argument("--agent", required=True)
582
+ check.add_argument("--verb", required=True)
583
+ check.add_argument("--target", required=True)
584
+ check.add_argument("--purpose", required=True)
585
+
586
+ sandbox = sub.add_parser("sandbox", help="90-second grant for demo_write")
587
+ sandbox.add_argument("--sponsor", required=True)
588
+ sandbox.add_argument("--agent", required=True)
589
+ sandbox.add_argument("--target", required=True)
590
+ sandbox.add_argument("--purpose", required=True)
591
+
592
+ revoke = sub.add_parser("revoke", help="Kill switch: revoke a principal (sponsor token)")
593
+ revoke.add_argument("--sponsor", required=True)
594
+ revoke.add_argument("--agent", required=True)
595
+ revoke.add_argument("--reason", default="")
596
+ revoke.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
597
+
598
+ reinstate = sub.add_parser("reinstate", help="Undo a revocation (sponsor token)")
599
+ reinstate.add_argument("--sponsor", required=True)
600
+ reinstate.add_argument("--agent", required=True)
601
+ reinstate.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
602
+
603
+ revoked = sub.add_parser("revoked", help="List revoked principals (sponsor token)")
604
+ revoked.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
605
+
606
+ receipts = sub.add_parser("receipts", help="Latest receipts for your tenant")
607
+ receipts.add_argument("--key", required=True)
608
+
609
+ stream = sub.add_parser("stream", help="Tail the live audit log (SSE stream)")
610
+ stream.add_argument("--key", required=True)
611
+ stream.add_argument("--since", default="", help="receiptId to resume after")
612
+ stream.add_argument("--timeout", type=int, default=600, help="Seconds to stream before exiting (0 = forever)")
613
+
614
+ verify = sub.add_parser("verify-chain", help="Verify the tenant audit-log hash chain")
615
+ verify.add_argument("--key", required=True)
616
+ verify.add_argument("--limit", type=int, default=200, help="Max receipts to verify (1-1000)")
617
+
618
+ invite = sub.add_parser("invite", help="Create a team invite code for your tenant")
619
+ invite.add_argument("--key", required=True)
620
+
621
+ grant = sub.add_parser("grant", help="Sponsor approval: grant one write (sponsor token)")
622
+ grant.add_argument("--sponsor", required=True)
623
+ grant.add_argument("--agent", required=True)
624
+ grant.add_argument("--verb", required=True)
625
+ grant.add_argument("--target", required=True)
626
+ grant.add_argument("--purpose", required=True)
627
+ grant.add_argument("--ttl", type=int, default=90, help="Seconds the grant stays valid (1-3600)")
628
+ grant.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
629
+
630
+ tv = sub.add_parser("token-verify", help="Validate an ALLOW auth token")
631
+ tv.add_argument("--key", required=True)
632
+ tv.add_argument("--token", required=True)
633
+ tv.add_argument("--verb", default=None)
634
+ tv.add_argument("--target", default=None)
635
+ tv.add_argument("--purpose", default=None)
636
+
637
+ scan = sub.add_parser("scan", help="Scan a repo for writes; map to verbs; diff instrumentation")
638
+ scan.add_argument("path", nargs="?", default=".", help="Repo root to scan (default: .)")
639
+ scan.add_argument("--exclude", action="append", default=[], metavar="SUBSTR",
640
+ help="Skip paths containing SUBSTR (repeatable)")
641
+ scan.add_argument("--policy-out", default="writ-policy.json",
642
+ help="Where to write the discovered verb policy")
643
+ scan.add_argument("--apply", action="store_true",
644
+ help="Write instrumentation into the repo")
645
+ scan.add_argument("--yes", action="store_true",
646
+ help="Apply without prompting")
647
+ scan.add_argument("--push-policy", action="store_true",
648
+ help="PUT the discovered policy to the gate (needs --key)")
649
+ scan.add_argument("--key", default="",
650
+ help="API key (for --push-policy)")
651
+
652
+ args = parser.parse_args()
653
+ if args.cmd == "key":
654
+ payload = {"email": args.email}
655
+ if args.tenant_name.strip():
656
+ payload["tenantName"] = args.tenant_name.strip()
657
+ if args.invite_code.strip():
658
+ payload["inviteCode"] = args.invite_code.strip()
659
+ return request("POST", "/v1/keys", payload)
660
+ if args.cmd == "invite":
661
+ return request("POST", "/v1/tenant/invites", key=args.key)
662
+ if args.cmd == "grant":
663
+ if not args.sponsor_token:
664
+ print("error: sponsor token required (--sponsor-token or WRIT_SPONSOR_TOKEN)", file=sys.stderr)
665
+ return 2
666
+ return request(
667
+ "POST",
668
+ "/v1/grants",
669
+ {
670
+ "sponsorId": args.sponsor,
671
+ "agentId": args.agent,
672
+ "verb": args.verb,
673
+ "target": args.target,
674
+ "purpose": args.purpose,
675
+ "ttlSeconds": args.ttl,
676
+ },
677
+ token="Bearer " + args.sponsor_token,
678
+ )
679
+ if args.cmd == "me":
680
+ return request("GET", "/v1/me", key=args.key)
681
+ if args.cmd == "policy":
682
+ if args.set:
683
+ for verb, mode in args.set:
684
+ if mode not in MODES:
685
+ print(f"error: mode must be one of {', '.join(MODES)}", file=sys.stderr)
686
+ return 2
687
+ return request("PUT", "/v1/policy", {verb: mode for verb, mode in args.set}, key=args.key)
688
+ return request("GET", "/v1/policy", key=args.key)
689
+ if args.cmd == "receipts":
690
+ return request("GET", "/v1/receipts", key=args.key)
691
+ if args.cmd == "stream":
692
+ return stream_cmd(args)
693
+ if args.cmd == "verify-chain":
694
+ return request("GET", f"/v1/receipts/verify?limit={args.limit}", key=args.key)
695
+ if args.cmd == "token-verify":
696
+ payload = {"token": args.token}
697
+ for field in ("verb", "target", "purpose"):
698
+ value = getattr(args, field)
699
+ if value is not None:
700
+ payload[field] = value
701
+ return request("POST", "/v1/tokens/verify", payload, key=args.key)
702
+ if args.cmd == "scan":
703
+ return scan_cmd(args)
704
+ if args.cmd in ("revoke", "reinstate", "revoked"):
705
+ if not args.sponsor_token:
706
+ print("error: sponsor token required (--sponsor-token or WRIT_SPONSOR_TOKEN)", file=sys.stderr)
707
+ return 2
708
+ token = "Bearer " + args.sponsor_token
709
+ if args.cmd == "revoked":
710
+ return request("GET", "/v1/revoked", token=token)
711
+ payload = {"sponsorId": args.sponsor, "agentId": args.agent}
712
+ if args.cmd == "revoke":
713
+ if args.reason:
714
+ payload["reason"] = args.reason
715
+ return request("POST", "/v1/revoke", payload, token=token)
716
+ return request("POST", "/v1/reinstate", payload, token=token)
717
+ body = {
718
+ "sponsorId": args.sponsor,
719
+ "agentId": args.agent,
720
+ "verb": args.verb if args.cmd == "check" else "demo_write",
721
+ "target": args.target,
722
+ "purpose": args.purpose,
723
+ }
724
+ if args.cmd == "sandbox":
725
+ return request("POST", "/v1/sandbox", body)
726
+ return request("POST", "/v1/check", body, key=args.key)
727
+
728
+
729
+ if __name__ == "__main__":
730
+ raise SystemExit(main())
pywrit-0.1.0/README.md DELETED
@@ -1,47 +0,0 @@
1
- # writ-cli (PyPI packaging)
2
-
3
- Installable distribution of the Writ CLI. `pip install writ-cli` gives you the
4
- `writ` command — no `curl` + `chmod` dance.
5
-
6
- ## Layout
7
-
8
- - `pyproject.toml` — package metadata, `writ` console-script entry point
9
- - `writ_cli/__init__.py` — version
10
- - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib)
11
-
12
- ## Relationship to `gate/web/writ`
13
-
14
- `gate/web/writ` is the **self-contained single-file script** served by the Lambda
15
- at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
16
- file with no install step.
17
-
18
- `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
19
- byte-identical by copy. If you change CLI behavior, update **both** and keep
20
- them in sync — the download path and the pip path must behave the same.
21
-
22
- ## Build & test locally
23
-
24
- ```bash
25
- cd cli
26
- python -m build # needs `pip install build`
27
- pip install -e . # editable install, creates the `writ` command
28
- writ --help
29
- ```
30
-
31
- ## Publish (needs Chad's approval + PyPI credentials)
32
-
33
- ```bash
34
- cd cli
35
- python -m build
36
- twine upload dist/*
37
- ```
38
-
39
- ## Open decisions (before first publish)
40
-
41
- 1. **Distribution name**: `writ` is taken on PyPI. `pyproject.toml` uses
42
- `writ-cli` as a placeholder — Chad picks the final name.
43
- 2. **License**: the repo has no LICENSE file yet. Pick one (MIT recommended)
44
- and add `license = { text = "MIT" }` + trove classifier before publishing.
45
- 3. **Docs**: once published, update `fern/pages/quickstart.mdx` and
46
- `fern/pages/cli.mdx` to offer `pip install <name>` alongside the curl path
47
- (per the repo docs rule — every change ships with its docs).
@@ -1,234 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Writ CLI. Get a key and ask the gate before a write."""
3
-
4
- import argparse
5
- import json
6
- import os
7
- import sys
8
- import urllib.error
9
- import urllib.request
10
-
11
- BASE = os.environ.get(
12
- "WRIT_BASE",
13
- "https://j72ckh66ukck2kcbq3oiwxaalm0olxwb.lambda-url.us-east-1.on.aws",
14
- )
15
-
16
-
17
- def request(method, path, payload=None, key=None, token=None):
18
- headers = {"content-type": "application/json"}
19
- if key:
20
- headers["authorization"] = "Bearer " + key
21
- elif token:
22
- headers["authorization"] = token
23
- data = None if payload is None else json.dumps(payload).encode()
24
- req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
25
- try:
26
- with urllib.request.urlopen(req, timeout=20) as resp:
27
- body = resp.read().decode()
28
- print(body)
29
- return 0
30
- except urllib.error.HTTPError as exc:
31
- print(exc.read().decode(), file=sys.stderr)
32
- return exc.code
33
-
34
-
35
- def stream_cmd(args):
36
- """Tail the tenant audit log via the SSE stream, reconnecting on each
37
- long-poll expiry. Prints one line per receipt."""
38
- import time
39
- import urllib.parse
40
-
41
- key = args.key
42
- since = args.since
43
- deadline = None if args.timeout == 0 else time.time() + args.timeout
44
- try:
45
- while deadline is None or time.time() < deadline:
46
- params = {"apiKey": key, "timeout": "10", "replay": "20"}
47
- if since:
48
- params["since"] = since
49
- url = BASE + "/v1/receipts/stream?" + urllib.parse.urlencode(params)
50
- req = urllib.request.Request(url, method="GET")
51
- try:
52
- with urllib.request.urlopen(req, timeout=30) as resp:
53
- data_lines = []
54
- for raw in resp:
55
- line = raw.decode("utf-8", "replace").rstrip("\n")
56
- if line.startswith("data:"):
57
- data_lines.append(line[5:].strip())
58
- elif line == "" and data_lines:
59
- try:
60
- r = json.loads("".join(data_lines))
61
- except json.JSONDecodeError:
62
- data_lines = []
63
- continue
64
- ts = time.strftime("%H:%M:%S", time.localtime(r.get("createdAt", 0)))
65
- print(f"{ts} {r.get('decision','?'):7} {r.get('verb','')} {r.get('target','')} "
66
- f"sponsor={r.get('sponsorId','')} agent={r.get('agentId','')} "
67
- f"id={r.get('receiptId','')}", flush=True)
68
- since = r.get("receiptId", since)
69
- data_lines = []
70
- # ": ping" heartbeats and "id:"/"event:" lines are ignored
71
- except urllib.error.HTTPError as exc:
72
- print(exc.read().decode(), file=sys.stderr)
73
- return exc.code
74
- except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
75
- print(f"stream reconnecting: {exc}", file=sys.stderr)
76
- time.sleep(2)
77
- except KeyboardInterrupt:
78
- pass
79
- return 0
80
-
81
-
82
- def main():
83
- parser = argparse.ArgumentParser(description="Ask Writ before an agent writes.")
84
- sub = parser.add_subparsers(dest="cmd", required=True)
85
-
86
- key = sub.add_parser("key", help="Get a free API key + tenant")
87
- key.add_argument("--email", required=True)
88
- key.add_argument("--tenant-name", default="", help="Optional tenant name (defaults to email domain)")
89
- key.add_argument("--invite-code", default="", help="Join an existing tenant instead of creating one")
90
-
91
- me = sub.add_parser("me", help="Show tenant, key meta, and policy")
92
- me.add_argument("--key", required=True)
93
-
94
- MODES = ("allow", "deny", "require_grant", "step_up")
95
- policy = sub.add_parser("policy", help="Get or set tenant verb policy")
96
- policy.add_argument("--key", required=True)
97
- policy.add_argument("--set", nargs=2, metavar=("VERB", "MODE"), action="append", default=[])
98
-
99
- check = sub.add_parser("check", help="ALLOW or DENY a write")
100
- check.add_argument("--key", required=True)
101
- check.add_argument("--sponsor", required=True)
102
- check.add_argument("--agent", required=True)
103
- check.add_argument("--verb", required=True)
104
- check.add_argument("--target", required=True)
105
- check.add_argument("--purpose", required=True)
106
-
107
- sandbox = sub.add_parser("sandbox", help="90-second grant for demo_write")
108
- sandbox.add_argument("--sponsor", required=True)
109
- sandbox.add_argument("--agent", required=True)
110
- sandbox.add_argument("--target", required=True)
111
- sandbox.add_argument("--purpose", required=True)
112
-
113
- revoke = sub.add_parser("revoke", help="Kill switch: revoke a principal (sponsor token)")
114
- revoke.add_argument("--sponsor", required=True)
115
- revoke.add_argument("--agent", required=True)
116
- revoke.add_argument("--reason", default="")
117
- revoke.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
118
-
119
- reinstate = sub.add_parser("reinstate", help="Undo a revocation (sponsor token)")
120
- reinstate.add_argument("--sponsor", required=True)
121
- reinstate.add_argument("--agent", required=True)
122
- reinstate.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
123
-
124
- revoked = sub.add_parser("revoked", help="List revoked principals (sponsor token)")
125
- revoked.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
126
-
127
- receipts = sub.add_parser("receipts", help="Latest receipts for your tenant")
128
- receipts.add_argument("--key", required=True)
129
-
130
- stream = sub.add_parser("stream", help="Tail the live audit log (SSE stream)")
131
- stream.add_argument("--key", required=True)
132
- stream.add_argument("--since", default="", help="receiptId to resume after")
133
- stream.add_argument("--timeout", type=int, default=600, help="Seconds to stream before exiting (0 = forever)")
134
-
135
- verify = sub.add_parser("verify-chain", help="Verify the tenant audit-log hash chain")
136
- verify.add_argument("--key", required=True)
137
- verify.add_argument("--limit", type=int, default=200, help="Max receipts to verify (1-1000)")
138
-
139
- invite = sub.add_parser("invite", help="Create a team invite code for your tenant")
140
- invite.add_argument("--key", required=True)
141
-
142
- grant = sub.add_parser("grant", help="Sponsor approval: grant one write (sponsor token)")
143
- grant.add_argument("--sponsor", required=True)
144
- grant.add_argument("--agent", required=True)
145
- grant.add_argument("--verb", required=True)
146
- grant.add_argument("--target", required=True)
147
- grant.add_argument("--purpose", required=True)
148
- grant.add_argument("--ttl", type=int, default=90, help="Seconds the grant stays valid (1-3600)")
149
- grant.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
150
-
151
- tv = sub.add_parser("token-verify", help="Validate an ALLOW auth token")
152
- tv.add_argument("--key", required=True)
153
- tv.add_argument("--token", required=True)
154
- tv.add_argument("--verb", default=None)
155
- tv.add_argument("--target", default=None)
156
- tv.add_argument("--purpose", default=None)
157
-
158
- args = parser.parse_args()
159
- if args.cmd == "key":
160
- payload = {"email": args.email}
161
- if args.tenant_name.strip():
162
- payload["tenantName"] = args.tenant_name.strip()
163
- if args.invite_code.strip():
164
- payload["inviteCode"] = args.invite_code.strip()
165
- return request("POST", "/v1/keys", payload)
166
- if args.cmd == "invite":
167
- return request("POST", "/v1/tenant/invites", key=args.key)
168
- if args.cmd == "grant":
169
- if not args.sponsor_token:
170
- print("error: sponsor token required (--sponsor-token or WRIT_SPONSOR_TOKEN)", file=sys.stderr)
171
- return 2
172
- return request(
173
- "POST",
174
- "/v1/grants",
175
- {
176
- "sponsorId": args.sponsor,
177
- "agentId": args.agent,
178
- "verb": args.verb,
179
- "target": args.target,
180
- "purpose": args.purpose,
181
- "ttlSeconds": args.ttl,
182
- },
183
- token="Bearer " + args.sponsor_token,
184
- )
185
- if args.cmd == "me":
186
- return request("GET", "/v1/me", key=args.key)
187
- if args.cmd == "policy":
188
- if args.set:
189
- for verb, mode in args.set:
190
- if mode not in MODES:
191
- print(f"error: mode must be one of {', '.join(MODES)}", file=sys.stderr)
192
- return 2
193
- return request("PUT", "/v1/policy", {verb: mode for verb, mode in args.set}, key=args.key)
194
- return request("GET", "/v1/policy", key=args.key)
195
- if args.cmd == "receipts":
196
- return request("GET", "/v1/receipts", key=args.key)
197
- if args.cmd == "stream":
198
- return stream_cmd(args)
199
- if args.cmd == "verify-chain":
200
- return request("GET", f"/v1/receipts/verify?limit={args.limit}", key=args.key)
201
- if args.cmd == "token-verify":
202
- payload = {"token": args.token}
203
- for field in ("verb", "target", "purpose"):
204
- value = getattr(args, field)
205
- if value is not None:
206
- payload[field] = value
207
- return request("POST", "/v1/tokens/verify", payload, key=args.key)
208
- if args.cmd in ("revoke", "reinstate", "revoked"):
209
- if not args.sponsor_token:
210
- print("error: sponsor token required (--sponsor-token or WRIT_SPONSOR_TOKEN)", file=sys.stderr)
211
- return 2
212
- token = "Bearer " + args.sponsor_token
213
- if args.cmd == "revoked":
214
- return request("GET", "/v1/revoked", token=token)
215
- payload = {"sponsorId": args.sponsor, "agentId": args.agent}
216
- if args.cmd == "revoke":
217
- if args.reason:
218
- payload["reason"] = args.reason
219
- return request("POST", "/v1/revoke", payload, token=token)
220
- return request("POST", "/v1/reinstate", payload, token=token)
221
- body = {
222
- "sponsorId": args.sponsor,
223
- "agentId": args.agent,
224
- "verb": args.verb if args.cmd == "check" else "demo_write",
225
- "target": args.target,
226
- "purpose": args.purpose,
227
- }
228
- if args.cmd == "sandbox":
229
- return request("POST", "/v1/sandbox", body)
230
- return request("POST", "/v1/check", body, key=args.key)
231
-
232
-
233
- if __name__ == "__main__":
234
- raise SystemExit(main())
File without changes
File without changes
File without changes