pywrit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywrit
3
+ Version: 0.1.0
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
+ # writ-cli (PyPI packaging)
18
+
19
+ Installable distribution of the Writ CLI. `pip install writ-cli` gives you the
20
+ `writ` command — no `curl` + `chmod` dance.
21
+
22
+ ## Layout
23
+
24
+ - `pyproject.toml` — package metadata, `writ` console-script entry point
25
+ - `writ_cli/__init__.py` — version
26
+ - `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib)
27
+
28
+ ## Relationship to `gate/web/writ`
29
+
30
+ `gate/web/writ` is the **self-contained single-file script** served by the Lambda
31
+ at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
32
+ file with no install step.
33
+
34
+ `writ_cli/cli.py` is the **PyPI package source**. The two files are currently
35
+ byte-identical by copy. If you change CLI behavior, update **both** and keep
36
+ them in sync — the download path and the pip path must behave the same.
37
+
38
+ ## Build & test locally
39
+
40
+ ```bash
41
+ cd cli
42
+ python -m build # needs `pip install build`
43
+ pip install -e . # editable install, creates the `writ` command
44
+ writ --help
45
+ ```
46
+
47
+ ## Publish (needs Chad's approval + PyPI credentials)
48
+
49
+ ```bash
50
+ cd cli
51
+ python -m build
52
+ twine upload dist/*
53
+ ```
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,8 @@
1
+ pywrit-0.1.0.dist-info/licenses/LICENSE,sha256=K7HmV3Hw8W9Qw3C_EFmvxErqZbs7AZnFVKUCgJiI-no,1061
2
+ writ_cli/__init__.py,sha256=nI5qTo0ebr5IfQVNfUeSUZIypa2RxRBuAKgG62YP_J8,47
3
+ writ_cli/cli.py,sha256=K-YIKwmjdrdJnY8EaB4dSfIYAUrdyAA-ACvl8PaQFTg,10448
4
+ pywrit-0.1.0.dist-info/METADATA,sha256=yA7EXxRgx8x1Vyqxx3ISKcsAeU5aLtRitM-EniqNSxA,2246
5
+ pywrit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ pywrit-0.1.0.dist-info/entry_points.txt,sha256=Z178SwFVAN2TAkK6fPQCcss2moCg6AY5iCz-0E75U3A,43
7
+ pywrit-0.1.0.dist-info/top_level.txt,sha256=YJoJ_48DQajE7XWe3gXkjRJMgB5Wsdx2g_N7VpfcZMg,9
8
+ pywrit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ writ = writ_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Writ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ writ_cli
writ_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Writ CLI package."""
2
+
3
+ __version__ = "0.1.0"
writ_cli/cli.py ADDED
@@ -0,0 +1,234 @@
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())