pywrit 0.1.2__tar.gz → 0.1.4__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.4/PKG-INFO +133 -0
- pywrit-0.1.4/README.md +113 -0
- {pywrit-0.1.2 → pywrit-0.1.4}/pyproject.toml +11 -2
- pywrit-0.1.4/pywrit.egg-info/PKG-INFO +133 -0
- {pywrit-0.1.2 → pywrit-0.1.4}/pywrit.egg-info/SOURCES.txt +4 -1
- pywrit-0.1.4/pywrit.egg-info/requires.txt +5 -0
- pywrit-0.1.4/tests/test_scan_tsjs.py +243 -0
- pywrit-0.1.4/writ_cli/cli.py +11729 -0
- pywrit-0.1.4/writ_cli/risk.py +176 -0
- pywrit-0.1.2/PKG-INFO +0 -80
- pywrit-0.1.2/README.md +0 -64
- pywrit-0.1.2/pywrit.egg-info/PKG-INFO +0 -80
- pywrit-0.1.2/writ_cli/cli.py +0 -730
- {pywrit-0.1.2 → pywrit-0.1.4}/LICENSE +0 -0
- {pywrit-0.1.2 → pywrit-0.1.4}/pywrit.egg-info/dependency_links.txt +0 -0
- {pywrit-0.1.2 → pywrit-0.1.4}/pywrit.egg-info/entry_points.txt +0 -0
- {pywrit-0.1.2 → pywrit-0.1.4}/pywrit.egg-info/top_level.txt +0 -0
- {pywrit-0.1.2 → pywrit-0.1.4}/setup.cfg +0 -0
- {pywrit-0.1.2 → pywrit-0.1.4}/writ_cli/__init__.py +0 -0
pywrit-0.1.4/PKG-INFO
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pywrit
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Writ — the gate before the write. 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
|
+
Provides-Extra: polyglot
|
|
16
|
+
Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
|
|
17
|
+
Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
|
|
18
|
+
Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# pywrit
|
|
22
|
+
|
|
23
|
+
The Writ CLI — the gate before the write.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install pywrit
|
|
27
|
+
writ key --email you@company.com
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Find the consequential writes your agents can perform.** `writ scan .` is a
|
|
31
|
+
free, local scan (no API key) that lists the writes it finds in your agent code. Then gate
|
|
32
|
+
them: Writ is an allow/deny gate you put in front of your agent's writes, and each check
|
|
33
|
+
writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
- `writ key` — Get a free API key (1,000 receipts/month, no card)
|
|
40
|
+
- `writ check` — Ask the gate before a write (ALLOW or DENY)
|
|
41
|
+
- `writ revoke` — Kill switch: revoke a principal's tokens in one click
|
|
42
|
+
- `writ receipts` — See your audit log
|
|
43
|
+
- `writ scan` — Find writes in your repo (Python: scan + instrument; TS/JS: scan only)
|
|
44
|
+
|
|
45
|
+
## Scanning a repo
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
writ scan ./my-repo # report writes, print the diff, write writ-policy.json
|
|
49
|
+
writ scan ./my-repo --apply # prompt, then insert gates (Python files only)
|
|
50
|
+
pip install 'pywrit[polyglot]' # optional: also scan TypeScript / JavaScript
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
- **Python** (`.py`): parsed with the stdlib `ast`, no extra dependencies.
|
|
56
|
+
Finds DB calls, HTTP clients, file writes, email, queues, and AWS SDK
|
|
57
|
+
writes. `writ scan --apply` inserts a `_writ_check(...)` gate at the top of
|
|
58
|
+
each writing function.
|
|
59
|
+
- **TypeScript / JavaScript** (`.ts .tsx .js .jsx .mjs .cjs`): **scan only**,
|
|
60
|
+
never rewritten, and needs the `polyglot` extra (tree-sitter). Without it,
|
|
61
|
+
`writ scan` prints a one-line install hint and keeps scanning Python.
|
|
62
|
+
Each finding is listed as `file:line [category] rule function(): snippet`,
|
|
63
|
+
and its verb goes into `writ-policy.json` like a Python finding. Detected:
|
|
64
|
+
- HTTP: `fetch` / `axios` / `got` / `ky` / `superagent` with a literal
|
|
65
|
+
`POST`/`PUT`/`PATCH`/`DELETE` method, `axios.post|put|patch|delete`,
|
|
66
|
+
`http(s).request` with a write method
|
|
67
|
+
- Files: `fs.writeFile`, `appendFile`, `unlink`, `rm`, `rename`, `mkdir`, …
|
|
68
|
+
plus `Sync` and `fs/promises` variants
|
|
69
|
+
- SQL: literal `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/… strings passed to
|
|
70
|
+
`query` / `execute` / `raw` (including tagged templates such as
|
|
71
|
+
`prisma.$executeRaw`)
|
|
72
|
+
- SDKs: Stripe `create|update|del` (e.g. `stripe.refunds.create`), Prisma
|
|
73
|
+
`create|update|upsert|delete|deleteMany|updateMany`, knex/db builders
|
|
74
|
+
`insert|update|del`, `child_process` `exec`/`spawn`
|
|
75
|
+
- A function that already calls `writCheck(...)` / `writ_check(...)` counts
|
|
76
|
+
as gated.
|
|
77
|
+
- Skipped: `node_modules`, `dist`, `build`, hidden dirs such as `.git` and
|
|
78
|
+
`.next`, virtualenvs, and test files. TS/JS scanning also skips `vendor`,
|
|
79
|
+
`*.d.ts`, `*.min.js`, and files over 1 MB, and respects `.gitignore` when
|
|
80
|
+
the repo is a git checkout.
|
|
81
|
+
- TS/JS limitations: calls are matched by name, so HTTP methods, SQL strings,
|
|
82
|
+
or options held in variables (`fetch(url, opts)`, `db.query(sql)`) are not
|
|
83
|
+
detected, and route definitions like `router.post(...)` are deliberately
|
|
84
|
+
not flagged. The Writ Score grades Python findings only; the
|
|
85
|
+
`ts/js gated: N/M` line is the TS/JS coverage number.
|
|
86
|
+
|
|
87
|
+
## Docs
|
|
88
|
+
|
|
89
|
+
Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Packaging notes (for maintainers)
|
|
94
|
+
|
|
95
|
+
Installable distribution of the Writ CLI. `pip install pywrit` gives you the
|
|
96
|
+
`writ` command — no `curl` + `chmod` dance.
|
|
97
|
+
|
|
98
|
+
### Layout
|
|
99
|
+
|
|
100
|
+
- `pyproject.toml` — package metadata, `writ` console-script entry point
|
|
101
|
+
- `writ_cli/__init__.py` — version
|
|
102
|
+
- `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib;
|
|
103
|
+
TS/JS scanning imports tree-sitter only when the `polyglot` extra is installed)
|
|
104
|
+
- `tests/` — scanner tests: `python -m unittest discover -s tests`
|
|
105
|
+
- Demo GIFs: recorded with [VHS](https://github.com/charmbracelet/vhs) from
|
|
106
|
+
`docs/tapes/*.tape` (repo root: `docs/tapes/render.sh`)
|
|
107
|
+
|
|
108
|
+
### Relationship to `gate/web/writ`
|
|
109
|
+
|
|
110
|
+
`gate/web/writ` is the **self-contained single-file script** served by the Lambda
|
|
111
|
+
at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
|
|
112
|
+
file with no install step.
|
|
113
|
+
|
|
114
|
+
`writ_cli/cli.py` is the **PyPI package source**. The two files are currently
|
|
115
|
+
byte-identical by copy. If you change CLI behavior, update **both** and keep
|
|
116
|
+
them in sync — the download path and the pip path must behave the same.
|
|
117
|
+
|
|
118
|
+
### Build & test locally
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
cd cli
|
|
122
|
+
python -m build # needs `pip install build`
|
|
123
|
+
pip install -e . # editable install, creates the `writ` command
|
|
124
|
+
writ --help
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Publish
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cd cli
|
|
131
|
+
python -m build
|
|
132
|
+
twine upload dist/*
|
|
133
|
+
```
|
pywrit-0.1.4/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# pywrit
|
|
2
|
+
|
|
3
|
+
The Writ CLI — the gate before the write.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install pywrit
|
|
7
|
+
writ key --email you@company.com
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**Find the consequential writes your agents can perform.** `writ scan .` is a
|
|
11
|
+
free, local scan (no API key) that lists the writes it finds in your agent code. Then gate
|
|
12
|
+
them: Writ is an allow/deny gate you put in front of your agent's writes, and each check
|
|
13
|
+
writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
- `writ key` — Get a free API key (1,000 receipts/month, no card)
|
|
20
|
+
- `writ check` — Ask the gate before a write (ALLOW or DENY)
|
|
21
|
+
- `writ revoke` — Kill switch: revoke a principal's tokens in one click
|
|
22
|
+
- `writ receipts` — See your audit log
|
|
23
|
+
- `writ scan` — Find writes in your repo (Python: scan + instrument; TS/JS: scan only)
|
|
24
|
+
|
|
25
|
+
## Scanning a repo
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
writ scan ./my-repo # report writes, print the diff, write writ-policy.json
|
|
29
|
+
writ scan ./my-repo --apply # prompt, then insert gates (Python files only)
|
|
30
|
+
pip install 'pywrit[polyglot]' # optional: also scan TypeScript / JavaScript
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
- **Python** (`.py`): parsed with the stdlib `ast`, no extra dependencies.
|
|
36
|
+
Finds DB calls, HTTP clients, file writes, email, queues, and AWS SDK
|
|
37
|
+
writes. `writ scan --apply` inserts a `_writ_check(...)` gate at the top of
|
|
38
|
+
each writing function.
|
|
39
|
+
- **TypeScript / JavaScript** (`.ts .tsx .js .jsx .mjs .cjs`): **scan only**,
|
|
40
|
+
never rewritten, and needs the `polyglot` extra (tree-sitter). Without it,
|
|
41
|
+
`writ scan` prints a one-line install hint and keeps scanning Python.
|
|
42
|
+
Each finding is listed as `file:line [category] rule function(): snippet`,
|
|
43
|
+
and its verb goes into `writ-policy.json` like a Python finding. Detected:
|
|
44
|
+
- HTTP: `fetch` / `axios` / `got` / `ky` / `superagent` with a literal
|
|
45
|
+
`POST`/`PUT`/`PATCH`/`DELETE` method, `axios.post|put|patch|delete`,
|
|
46
|
+
`http(s).request` with a write method
|
|
47
|
+
- Files: `fs.writeFile`, `appendFile`, `unlink`, `rm`, `rename`, `mkdir`, …
|
|
48
|
+
plus `Sync` and `fs/promises` variants
|
|
49
|
+
- SQL: literal `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/… strings passed to
|
|
50
|
+
`query` / `execute` / `raw` (including tagged templates such as
|
|
51
|
+
`prisma.$executeRaw`)
|
|
52
|
+
- SDKs: Stripe `create|update|del` (e.g. `stripe.refunds.create`), Prisma
|
|
53
|
+
`create|update|upsert|delete|deleteMany|updateMany`, knex/db builders
|
|
54
|
+
`insert|update|del`, `child_process` `exec`/`spawn`
|
|
55
|
+
- A function that already calls `writCheck(...)` / `writ_check(...)` counts
|
|
56
|
+
as gated.
|
|
57
|
+
- Skipped: `node_modules`, `dist`, `build`, hidden dirs such as `.git` and
|
|
58
|
+
`.next`, virtualenvs, and test files. TS/JS scanning also skips `vendor`,
|
|
59
|
+
`*.d.ts`, `*.min.js`, and files over 1 MB, and respects `.gitignore` when
|
|
60
|
+
the repo is a git checkout.
|
|
61
|
+
- TS/JS limitations: calls are matched by name, so HTTP methods, SQL strings,
|
|
62
|
+
or options held in variables (`fetch(url, opts)`, `db.query(sql)`) are not
|
|
63
|
+
detected, and route definitions like `router.post(...)` are deliberately
|
|
64
|
+
not flagged. The Writ Score grades Python findings only; the
|
|
65
|
+
`ts/js gated: N/M` line is the TS/JS coverage number.
|
|
66
|
+
|
|
67
|
+
## Docs
|
|
68
|
+
|
|
69
|
+
Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Packaging notes (for maintainers)
|
|
74
|
+
|
|
75
|
+
Installable distribution of the Writ CLI. `pip install pywrit` gives you the
|
|
76
|
+
`writ` command — no `curl` + `chmod` dance.
|
|
77
|
+
|
|
78
|
+
### Layout
|
|
79
|
+
|
|
80
|
+
- `pyproject.toml` — package metadata, `writ` console-script entry point
|
|
81
|
+
- `writ_cli/__init__.py` — version
|
|
82
|
+
- `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib;
|
|
83
|
+
TS/JS scanning imports tree-sitter only when the `polyglot` extra is installed)
|
|
84
|
+
- `tests/` — scanner tests: `python -m unittest discover -s tests`
|
|
85
|
+
- Demo GIFs: recorded with [VHS](https://github.com/charmbracelet/vhs) from
|
|
86
|
+
`docs/tapes/*.tape` (repo root: `docs/tapes/render.sh`)
|
|
87
|
+
|
|
88
|
+
### Relationship to `gate/web/writ`
|
|
89
|
+
|
|
90
|
+
`gate/web/writ` is the **self-contained single-file script** served by the Lambda
|
|
91
|
+
at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
|
|
92
|
+
file with no install step.
|
|
93
|
+
|
|
94
|
+
`writ_cli/cli.py` is the **PyPI package source**. The two files are currently
|
|
95
|
+
byte-identical by copy. If you change CLI behavior, update **both** and keep
|
|
96
|
+
them in sync — the download path and the pip path must behave the same.
|
|
97
|
+
|
|
98
|
+
### Build & test locally
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cd cli
|
|
102
|
+
python -m build # needs `pip install build`
|
|
103
|
+
pip install -e . # editable install, creates the `writ` command
|
|
104
|
+
writ --help
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Publish
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cd cli
|
|
111
|
+
python -m build
|
|
112
|
+
twine upload dist/*
|
|
113
|
+
```
|
|
@@ -6,8 +6,8 @@ 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.
|
|
10
|
-
description = "
|
|
9
|
+
version = "0.1.4"
|
|
10
|
+
description = "Writ — the gate before the write. Allow/deny gate with a tamper-evident audit log"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.9"
|
|
13
13
|
keywords = ["agents", "audit-log", "authorization", "api-keys"]
|
|
@@ -20,6 +20,15 @@ classifiers = [
|
|
|
20
20
|
# No third-party dependencies — the CLI is stdlib-only (argparse, json, urllib).
|
|
21
21
|
dependencies = []
|
|
22
22
|
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
# `pip install 'pywrit[polyglot]'`: lets `writ scan` parse TypeScript and
|
|
25
|
+
# JavaScript (scan only). Python scanning never needs it.
|
|
26
|
+
polyglot = [
|
|
27
|
+
"tree-sitter>=0.23",
|
|
28
|
+
"tree-sitter-typescript>=0.23",
|
|
29
|
+
"tree-sitter-javascript>=0.23",
|
|
30
|
+
]
|
|
31
|
+
|
|
23
32
|
[project.urls]
|
|
24
33
|
Homepage = "https://withwrit.com"
|
|
25
34
|
Documentation = "https://docs.withwrit.com"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pywrit
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Writ — the gate before the write. 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
|
+
Provides-Extra: polyglot
|
|
16
|
+
Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
|
|
17
|
+
Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
|
|
18
|
+
Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# pywrit
|
|
22
|
+
|
|
23
|
+
The Writ CLI — the gate before the write.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install pywrit
|
|
27
|
+
writ key --email you@company.com
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Find the consequential writes your agents can perform.** `writ scan .` is a
|
|
31
|
+
free, local scan (no API key) that lists the writes it finds in your agent code. Then gate
|
|
32
|
+
them: Writ is an allow/deny gate you put in front of your agent's writes, and each check
|
|
33
|
+
writes a tamper-evident receipt to your audit log. No more wondering who called what, or when.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
- `writ key` — Get a free API key (1,000 receipts/month, no card)
|
|
40
|
+
- `writ check` — Ask the gate before a write (ALLOW or DENY)
|
|
41
|
+
- `writ revoke` — Kill switch: revoke a principal's tokens in one click
|
|
42
|
+
- `writ receipts` — See your audit log
|
|
43
|
+
- `writ scan` — Find writes in your repo (Python: scan + instrument; TS/JS: scan only)
|
|
44
|
+
|
|
45
|
+
## Scanning a repo
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
writ scan ./my-repo # report writes, print the diff, write writ-policy.json
|
|
49
|
+
writ scan ./my-repo --apply # prompt, then insert gates (Python files only)
|
|
50
|
+
pip install 'pywrit[polyglot]' # optional: also scan TypeScript / JavaScript
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
- **Python** (`.py`): parsed with the stdlib `ast`, no extra dependencies.
|
|
56
|
+
Finds DB calls, HTTP clients, file writes, email, queues, and AWS SDK
|
|
57
|
+
writes. `writ scan --apply` inserts a `_writ_check(...)` gate at the top of
|
|
58
|
+
each writing function.
|
|
59
|
+
- **TypeScript / JavaScript** (`.ts .tsx .js .jsx .mjs .cjs`): **scan only**,
|
|
60
|
+
never rewritten, and needs the `polyglot` extra (tree-sitter). Without it,
|
|
61
|
+
`writ scan` prints a one-line install hint and keeps scanning Python.
|
|
62
|
+
Each finding is listed as `file:line [category] rule function(): snippet`,
|
|
63
|
+
and its verb goes into `writ-policy.json` like a Python finding. Detected:
|
|
64
|
+
- HTTP: `fetch` / `axios` / `got` / `ky` / `superagent` with a literal
|
|
65
|
+
`POST`/`PUT`/`PATCH`/`DELETE` method, `axios.post|put|patch|delete`,
|
|
66
|
+
`http(s).request` with a write method
|
|
67
|
+
- Files: `fs.writeFile`, `appendFile`, `unlink`, `rm`, `rename`, `mkdir`, …
|
|
68
|
+
plus `Sync` and `fs/promises` variants
|
|
69
|
+
- SQL: literal `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/… strings passed to
|
|
70
|
+
`query` / `execute` / `raw` (including tagged templates such as
|
|
71
|
+
`prisma.$executeRaw`)
|
|
72
|
+
- SDKs: Stripe `create|update|del` (e.g. `stripe.refunds.create`), Prisma
|
|
73
|
+
`create|update|upsert|delete|deleteMany|updateMany`, knex/db builders
|
|
74
|
+
`insert|update|del`, `child_process` `exec`/`spawn`
|
|
75
|
+
- A function that already calls `writCheck(...)` / `writ_check(...)` counts
|
|
76
|
+
as gated.
|
|
77
|
+
- Skipped: `node_modules`, `dist`, `build`, hidden dirs such as `.git` and
|
|
78
|
+
`.next`, virtualenvs, and test files. TS/JS scanning also skips `vendor`,
|
|
79
|
+
`*.d.ts`, `*.min.js`, and files over 1 MB, and respects `.gitignore` when
|
|
80
|
+
the repo is a git checkout.
|
|
81
|
+
- TS/JS limitations: calls are matched by name, so HTTP methods, SQL strings,
|
|
82
|
+
or options held in variables (`fetch(url, opts)`, `db.query(sql)`) are not
|
|
83
|
+
detected, and route definitions like `router.post(...)` are deliberately
|
|
84
|
+
not flagged. The Writ Score grades Python findings only; the
|
|
85
|
+
`ts/js gated: N/M` line is the TS/JS coverage number.
|
|
86
|
+
|
|
87
|
+
## Docs
|
|
88
|
+
|
|
89
|
+
Full docs at [docs.withwrit.com](https://docs.withwrit.com). Quickstart at [docs.withwrit.com/quickstart](https://docs.withwrit.com/quickstart).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Packaging notes (for maintainers)
|
|
94
|
+
|
|
95
|
+
Installable distribution of the Writ CLI. `pip install pywrit` gives you the
|
|
96
|
+
`writ` command — no `curl` + `chmod` dance.
|
|
97
|
+
|
|
98
|
+
### Layout
|
|
99
|
+
|
|
100
|
+
- `pyproject.toml` — package metadata, `writ` console-script entry point
|
|
101
|
+
- `writ_cli/__init__.py` — version
|
|
102
|
+
- `writ_cli/cli.py` — the CLI itself (stdlib-only: argparse, json, urllib;
|
|
103
|
+
TS/JS scanning imports tree-sitter only when the `polyglot` extra is installed)
|
|
104
|
+
- `tests/` — scanner tests: `python -m unittest discover -s tests`
|
|
105
|
+
- Demo GIFs: recorded with [VHS](https://github.com/charmbracelet/vhs) from
|
|
106
|
+
`docs/tapes/*.tape` (repo root: `docs/tapes/render.sh`)
|
|
107
|
+
|
|
108
|
+
### Relationship to `gate/web/writ`
|
|
109
|
+
|
|
110
|
+
`gate/web/writ` is the **self-contained single-file script** served by the Lambda
|
|
111
|
+
at `/writ` (the `curl -fsSL ... -o writ` download path). It must stay a single
|
|
112
|
+
file with no install step.
|
|
113
|
+
|
|
114
|
+
`writ_cli/cli.py` is the **PyPI package source**. The two files are currently
|
|
115
|
+
byte-identical by copy. If you change CLI behavior, update **both** and keep
|
|
116
|
+
them in sync — the download path and the pip path must behave the same.
|
|
117
|
+
|
|
118
|
+
### Build & test locally
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
cd cli
|
|
122
|
+
python -m build # needs `pip install build`
|
|
123
|
+
pip install -e . # editable install, creates the `writ` command
|
|
124
|
+
writ --help
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Publish
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cd cli
|
|
131
|
+
python -m build
|
|
132
|
+
twine upload dist/*
|
|
133
|
+
```
|
|
@@ -5,6 +5,9 @@ pywrit.egg-info/PKG-INFO
|
|
|
5
5
|
pywrit.egg-info/SOURCES.txt
|
|
6
6
|
pywrit.egg-info/dependency_links.txt
|
|
7
7
|
pywrit.egg-info/entry_points.txt
|
|
8
|
+
pywrit.egg-info/requires.txt
|
|
8
9
|
pywrit.egg-info/top_level.txt
|
|
10
|
+
tests/test_scan_tsjs.py
|
|
9
11
|
writ_cli/__init__.py
|
|
10
|
-
writ_cli/cli.py
|
|
12
|
+
writ_cli/cli.py
|
|
13
|
+
writ_cli/risk.py
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic tests for `writ scan` TypeScript/JavaScript support.
|
|
3
|
+
|
|
4
|
+
Fixtures live in tests/fixtures/tsjs. Every line tagged `// expect: <rule>`
|
|
5
|
+
must be reported with that rule, and nothing else may be reported.
|
|
6
|
+
Tests that parse TS/JS skip when the optional `polyglot` extra
|
|
7
|
+
(tree-sitter) is not installed; the rest run with zero extra deps.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import contextlib
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import unittest
|
|
21
|
+
from unittest import mock
|
|
22
|
+
|
|
23
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
24
|
+
sys.path.insert(0, str(HERE.parent))
|
|
25
|
+
|
|
26
|
+
from writ_cli import cli # noqa: E402
|
|
27
|
+
|
|
28
|
+
FIXTURES = HERE / "fixtures" / "tsjs"
|
|
29
|
+
HAVE_EXTRA = bool(cli._tsjs_parsers())
|
|
30
|
+
_BLOCK_EXTRA = {m: None for m in ("tree_sitter", "tree_sitter_typescript",
|
|
31
|
+
"tree_sitter_javascript",
|
|
32
|
+
"tree_sitter_language_pack")}
|
|
33
|
+
_EXPECT = re.compile(r"//\s*expect:\s*([a-z0-9-]+)")
|
|
34
|
+
_FINDING = re.compile(
|
|
35
|
+
r"^ (?P<file>\S+):(?P<line>\d+) \[(?P<kind>[a-z]+)\] "
|
|
36
|
+
r"(?P<rule>[a-z0-9-]+) (?P<func>\S+)\(\): (?P<snippet>.+)$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _args(path, policy_out, **kw):
|
|
40
|
+
ns = argparse.Namespace(path=str(path), exclude=[], policy_out=str(policy_out),
|
|
41
|
+
apply=False, yes=False, score=False, push_policy=False,
|
|
42
|
+
key="")
|
|
43
|
+
for k, v in kw.items():
|
|
44
|
+
setattr(ns, k, v)
|
|
45
|
+
return ns
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_scan(path, **kw):
|
|
49
|
+
"""Run scan_cmd in-process; return (exit code, stdout)."""
|
|
50
|
+
with tempfile.TemporaryDirectory() as td:
|
|
51
|
+
buf = io.StringIO()
|
|
52
|
+
with contextlib.redirect_stdout(buf):
|
|
53
|
+
code = cli.scan_cmd(_args(path, pathlib.Path(td) / "policy.json", **kw))
|
|
54
|
+
return code, buf.getvalue()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _expected():
|
|
58
|
+
exp = set()
|
|
59
|
+
for p in sorted(FIXTURES.rglob("*")):
|
|
60
|
+
if p.suffix not in cli._TSJS_EXTS:
|
|
61
|
+
continue
|
|
62
|
+
for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
|
63
|
+
m = _EXPECT.search(line)
|
|
64
|
+
if m:
|
|
65
|
+
exp.add((str(p.relative_to(FIXTURES)), i, m.group(1)))
|
|
66
|
+
return exp
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _findings(out):
|
|
70
|
+
return {(m.group("file"), int(m.group("line")), m.group("rule"))
|
|
71
|
+
for m in map(_FINDING.match, out.splitlines()) if m}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _copy_fixtures(dst):
|
|
75
|
+
shutil.copytree(str(FIXTURES), str(dst))
|
|
76
|
+
return dst
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FixtureRulesTest(unittest.TestCase):
|
|
80
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
81
|
+
def test_every_expected_finding_and_nothing_else(self):
|
|
82
|
+
with tempfile.TemporaryDirectory() as td:
|
|
83
|
+
root = _copy_fixtures(pathlib.Path(td) / "repo") # outside git
|
|
84
|
+
code, out = _run_scan(root)
|
|
85
|
+
self.assertEqual(code, 0)
|
|
86
|
+
self.assertEqual(_findings(out), _expected(), out)
|
|
87
|
+
|
|
88
|
+
def test_every_rule_has_a_fixture(self):
|
|
89
|
+
covered = {r for (_f, _l, r) in _expected()}
|
|
90
|
+
self.assertEqual(covered, {r[0] for r in cli._TSJS_RULES})
|
|
91
|
+
|
|
92
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
93
|
+
def test_negative_reads_not_flagged(self):
|
|
94
|
+
with tempfile.TemporaryDirectory() as td:
|
|
95
|
+
root = pathlib.Path(td)
|
|
96
|
+
shutil.copy(str(FIXTURES / "reads.tsx"), str(root / "reads.tsx"))
|
|
97
|
+
code, out = _run_scan(root)
|
|
98
|
+
self.assertEqual(code, 0)
|
|
99
|
+
self.assertIn("ts/js files scanned: 1 skipped: 0", out)
|
|
100
|
+
self.assertIn("ts/js write sites: 0 in 0 function(s)", out)
|
|
101
|
+
self.assertEqual(_findings(out), set())
|
|
102
|
+
self.assertIn("nothing to instrument.", out)
|
|
103
|
+
|
|
104
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
105
|
+
def test_output_format_counts_gating_and_verbs(self):
|
|
106
|
+
with tempfile.TemporaryDirectory() as td:
|
|
107
|
+
root = _copy_fixtures(pathlib.Path(td) / "repo")
|
|
108
|
+
policy = pathlib.Path(td) / "p.json"
|
|
109
|
+
buf = io.StringIO()
|
|
110
|
+
with contextlib.redirect_stdout(buf):
|
|
111
|
+
code = cli.scan_cmd(_args(root, policy))
|
|
112
|
+
out = buf.getvalue()
|
|
113
|
+
verbs = __import__("json").loads(policy.read_text())
|
|
114
|
+
self.assertEqual(code, 0)
|
|
115
|
+
n = len(_expected())
|
|
116
|
+
self.assertIn("ts/js files scanned: 7 skipped: 0 (scan only, no --apply)", out)
|
|
117
|
+
self.assertIn("ts/js write sites: %d in 11 function(s)" % n, out)
|
|
118
|
+
self.assertIn("ts/js gated: 1/%d" % n, out) # gated.ts calls writCheck
|
|
119
|
+
self.assertIn(" payments/refunds.service.ts:10 [sdk] stripe-write "
|
|
120
|
+
"issueRefund(): stripe.refunds.create({ charge: chargeId })", out)
|
|
121
|
+
self.assertIn(" http.ts:10 [http] fetch-write createOrder(): fetch(url, "
|
|
122
|
+
"{ method: \"POST\", body: JSON.stringify(body) })", out)
|
|
123
|
+
for v in ("payments.refund", "payments.record", "payments.export",
|
|
124
|
+
"storage.save", "storage.create", "http.create", "legacy.write"):
|
|
125
|
+
self.assertEqual(verbs.get(v), "require_grant", v)
|
|
126
|
+
self.assertIn("nothing to instrument (ts/js findings are scan-only", out)
|
|
127
|
+
|
|
128
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
129
|
+
def test_apply_never_rewrites_tsjs_but_still_gates_python(self):
|
|
130
|
+
with tempfile.TemporaryDirectory() as td:
|
|
131
|
+
root = pathlib.Path(td) / "repo"
|
|
132
|
+
(root / "orders").mkdir(parents=True)
|
|
133
|
+
ts = root / "orders" / "api.ts"
|
|
134
|
+
ts.write_text('export async function createOrder(u: string) {\n'
|
|
135
|
+
' await fetch(u, { method: "POST" });\n}\n')
|
|
136
|
+
py = root / "orders" / "store.py"
|
|
137
|
+
py.write_text('def create_order():\n'
|
|
138
|
+
' with open("/tmp/o", "w") as f:\n'
|
|
139
|
+
' f.write("o")\n')
|
|
140
|
+
before = ts.read_bytes()
|
|
141
|
+
code, out = _run_scan(root, apply=True, yes=True)
|
|
142
|
+
self.assertEqual(code, 0)
|
|
143
|
+
self.assertEqual(ts.read_bytes(), before)
|
|
144
|
+
self.assertIn("_writ_check(\"orders.create\")", py.read_text())
|
|
145
|
+
self.assertIn("orders/api.ts:2 [http] fetch-write createOrder()", out)
|
|
146
|
+
self.assertIn("applied 1 gate(s) to 1 file(s).", out)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class FileWalkTest(unittest.TestCase):
|
|
150
|
+
"""File collection is stdlib-only, so these run with or without the extra."""
|
|
151
|
+
|
|
152
|
+
WRITE = 'export function f(){ return fetch("/x", { method: "POST" }); }\n'
|
|
153
|
+
|
|
154
|
+
def _tree(self, root, rels):
|
|
155
|
+
for rel in rels:
|
|
156
|
+
p = root / rel
|
|
157
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
p.write_text(self.WRITE)
|
|
159
|
+
|
|
160
|
+
def test_skip_dirs_and_test_files(self):
|
|
161
|
+
with tempfile.TemporaryDirectory() as td:
|
|
162
|
+
root = pathlib.Path(td)
|
|
163
|
+
skipped = ["node_modules/pkg/index.js", "dist/app.js", "build/app.js",
|
|
164
|
+
".next/server/page.js", ".git/hooks/x.js",
|
|
165
|
+
"vendor/lib.js", "venv/lib/x.js", ".venv/x.js",
|
|
166
|
+
"src/__tests__/a.ts", "tests/a.ts", "src/a.test.ts",
|
|
167
|
+
"src/a.spec.tsx", "types/a.d.ts", "public/app.min.js",
|
|
168
|
+
"src/readme.md", "src/a.py"]
|
|
169
|
+
kept = ["src/a.ts", "src/b.tsx", "src/c.js", "src/d.jsx",
|
|
170
|
+
"src/e.mjs", "src/f.cjs", "lib/distance.ts"]
|
|
171
|
+
self._tree(root, skipped + kept)
|
|
172
|
+
got = [str(p.relative_to(root))
|
|
173
|
+
for p in cli._tsjs_collect_files(root, [])]
|
|
174
|
+
self.assertEqual(sorted(got), sorted(kept))
|
|
175
|
+
|
|
176
|
+
def test_exclude_substring(self):
|
|
177
|
+
with tempfile.TemporaryDirectory() as td:
|
|
178
|
+
root = pathlib.Path(td)
|
|
179
|
+
self._tree(root, ["src/a.ts", "generated/b.ts"])
|
|
180
|
+
got = [str(p.relative_to(root))
|
|
181
|
+
for p in cli._tsjs_collect_files(root, ["generated"])]
|
|
182
|
+
self.assertEqual(got, ["src/a.ts"])
|
|
183
|
+
|
|
184
|
+
@unittest.skipUnless(shutil.which("git"), "git not installed")
|
|
185
|
+
def test_respects_gitignore_inside_git_repo(self):
|
|
186
|
+
with tempfile.TemporaryDirectory() as td:
|
|
187
|
+
root = pathlib.Path(td)
|
|
188
|
+
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
|
189
|
+
(root / ".gitignore").write_text("generated/\n*.gen.ts\n")
|
|
190
|
+
self._tree(root, ["src/a.ts", "generated/b.ts", "src/c.gen.ts",
|
|
191
|
+
"node_modules/x/y.js"])
|
|
192
|
+
got = [str(p.relative_to(root))
|
|
193
|
+
for p in cli._tsjs_collect_files(root, [])]
|
|
194
|
+
self.assertEqual(got, ["src/a.ts"])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class MissingExtraTest(unittest.TestCase):
|
|
198
|
+
def test_hint_once_and_python_still_scanned(self):
|
|
199
|
+
with tempfile.TemporaryDirectory() as td:
|
|
200
|
+
root = pathlib.Path(td)
|
|
201
|
+
(root / "web").mkdir()
|
|
202
|
+
(root / "web" / "a.ts").write_text(FileWalkTest.WRITE)
|
|
203
|
+
(root / "web" / "b.js").write_text(FileWalkTest.WRITE)
|
|
204
|
+
(root / "orders.py").write_text(
|
|
205
|
+
'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
|
|
206
|
+
with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
|
|
207
|
+
self.assertEqual(cli._tsjs_parsers(), {})
|
|
208
|
+
code, out = _run_scan(root)
|
|
209
|
+
self.assertEqual(code, 0)
|
|
210
|
+
hints = [l for l in out.splitlines() if "hint:" in l]
|
|
211
|
+
self.assertEqual(hints, [" hint: 2 TS/JS file(s) not scanned; install "
|
|
212
|
+
"the extra: pip install 'pywrit[polyglot]'"])
|
|
213
|
+
self.assertIn("write sites: 1 in 1 function(s)", out)
|
|
214
|
+
self.assertIn("orders.create", out)
|
|
215
|
+
self.assertNotIn("ts/js files scanned", out)
|
|
216
|
+
|
|
217
|
+
def test_python_only_repo_output_unchanged(self):
|
|
218
|
+
with tempfile.TemporaryDirectory() as td:
|
|
219
|
+
root = pathlib.Path(td)
|
|
220
|
+
(root / "orders.py").write_text(
|
|
221
|
+
'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
|
|
222
|
+
with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
|
|
223
|
+
code, out = _run_scan(root)
|
|
224
|
+
self.assertEqual(code, 0)
|
|
225
|
+
self.assertNotIn("hint:", out)
|
|
226
|
+
self.assertNotIn("ts/js", out)
|
|
227
|
+
self.assertTrue(out.startswith("writ scan: %s\n files scanned: 1 skipped: 0\n"
|
|
228
|
+
" write sites: 1 in 1 function(s)\n"
|
|
229
|
+
" gated: 0/1 (0%%)\n verbs discovered: 1\n"
|
|
230
|
+
" orders.create\n" % root.resolve()), out)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CliSyncTest(unittest.TestCase):
|
|
234
|
+
def test_download_copy_is_byte_identical(self):
|
|
235
|
+
web = HERE.parent.parent / "gate" / "web" / "writ"
|
|
236
|
+
if not web.exists():
|
|
237
|
+
self.skipTest("gate/web/writ not present")
|
|
238
|
+
self.assertEqual(web.read_bytes(),
|
|
239
|
+
(HERE.parent / "writ_cli" / "cli.py").read_bytes())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
unittest.main(verbosity=2)
|