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.
- {pywrit-0.1.0/pywrit.egg-info → pywrit-0.1.2}/PKG-INFO +34 -17
- pywrit-0.1.2/README.md +64 -0
- {pywrit-0.1.0 → pywrit-0.1.2}/pyproject.toml +1 -1
- {pywrit-0.1.0 → pywrit-0.1.2/pywrit.egg-info}/PKG-INFO +34 -17
- pywrit-0.1.2/writ_cli/cli.py +730 -0
- pywrit-0.1.0/README.md +0 -47
- pywrit-0.1.0/writ_cli/cli.py +0 -234
- {pywrit-0.1.0 → pywrit-0.1.2}/LICENSE +0 -0
- {pywrit-0.1.0 → pywrit-0.1.2}/pywrit.egg-info/SOURCES.txt +0 -0
- {pywrit-0.1.0 → pywrit-0.1.2}/pywrit.egg-info/dependency_links.txt +0 -0
- {pywrit-0.1.0 → pywrit-0.1.2}/pywrit.egg-info/entry_points.txt +0 -0
- {pywrit-0.1.0 → pywrit-0.1.2}/pywrit.egg-info/top_level.txt +0 -0
- {pywrit-0.1.0 → pywrit-0.1.2}/setup.cfg +0 -0
- {pywrit-0.1.0 → pywrit-0.1.2}/writ_cli/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywrit
|
|
3
|
-
Version: 0.1.
|
|
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
|
-
#
|
|
17
|
+
# pywrit 🏴☠️
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
17
|
+
# pywrit 🏴☠️
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
pywrit-0.1.0/writ_cli/cli.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|