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,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
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())
|