> Call POST /v1/check before a tool that writes. DENY means do not invoke the tool. For clean Markdown of any page, append .md to the page URL. For the complete index, fetch https://docs.withwrit.com/llms.txt.

# Scanner

> Point writ scan at a Python, TypeScript/JavaScript, or Go repo to discover writes, suggest verbs, and draft instrumentation. Coverage: Python scan plus auto-instrument; TypeScript/JavaScript scan-only; Go scan-only (first detection family plus request-pipeline Call/RawRequest).

`writ scan` points at a repository and finds likely writes, suggests
verbs, and drafts the gates, so you instrument every write without
hand-instrumenting every write.

Coverage: **Python: scan + auto-instrument. TypeScript/JavaScript:
scan-only. Go: scan-only (first detection family plus request-pipeline
`Call`/`RawRequest`).** TS/JS and Go
scanning rides the `polyglot` extra (`pip install 'pywrit[polyglot]'`);
`--apply` rewrites Python only.

It is **discovery for instrumentation — not complete security coverage.**
Known gaps (raw SQL strings built dynamically, `getattr` / `eval` tricks,
deep shared HTTP clients, non-call state mutations) may be missed.
Enforcement starts with the SDK and check API; the credential vault
with gated execution (`POST /v1/leases`, `POST /v1/execute`) and MCP
tool gating are also available when you want writes that cannot bypass
the check.
Treat the coverage percentage as a discovery scorecard for what the
scanner saw, not a guarantee that every write path is gated. Scan finds
the writes; the gate is the guarantee.

```bash
pip install pywrit
writ scan ./my-repo
```

(Alternative: a standalone script, `curl -fsSL https://withwrit.com/writ -o writ && chmod +x writ`, runs the same commands as `./writ`.)

## What it detects

The scanner parses **Python** with the AST (no regexes, no LLM)
and flags call sites that look like writes to a system of record:

* **Database** — `execute` / `executemany` / `executescript`,
  `session.add` / `session.commit` / `flush` / `merge` /
  `bulk_save_objects`, `session.query(...).delete()` /
  `.filter_by(...).delete()`, Django `Order.objects.create()` /
  `get_or_create()` / `update_or_create()` / `update()` /
  `bulk_create()` / `bulk_update()`, instance `order.save()` /
  `user.delete()` / `user.delete_instance()` (Django, SQLAlchemy,
  Peewee, Pony), and Pony `@db_session` transactional boundaries
* **HTTP** — `requests.post` / `put` / `patch`, `client.delete(url)` /
  `api.delete(...)` on http-ish receivers, and `request` on http-ish
  receivers (`client`, `session`, `api`, …)
* **Third-party SDKs** — Stripe `stripe.Refund.create()` /
  `stripe.Charge.create()` / `PaymentIntent.confirm()` (any
  `stripe.<Resource>.<action>` except `retrieve` / `list` / `search`,
  plus `.save()` / `.delete()` on resources fetched from Stripe),
  Twilio `client.messages.create()` / `client.calls.create()`, Resend
  `resend.Emails.send()`, SendGrid `sg.client.mail.send.post()`
* **Files** — `open(..., "w"|"a"|"x"|"+")`, `Path.write_text` /
  `write_bytes` / `unlink` / …, `os.remove` / `makedirs` / …,
  `shutil.rmtree` / `move` / …
* **Email** — `sendmail`, `send_message`, `send_email`,
  `send_templated_email`
* **Queues** — `publish` / `produce` / `enqueue`, and hinted `send` on
  queue-ish receivers (`sqs`, `kafka`, `redis`, …)
* **Deferred tasks** — Celery `@shared_task` / `@app.task` and
  `task.delay()` / `task.apply_async()` call sites, Dramatiq `@actor`,
  RQ `queue.enqueue(...)`. The task *definition* is flagged so
  `--apply` gates the task body, where the deferred writes execute.
* **AWS** — a mutating-operation list **generated from the botocore
  service models** (8,500+ operations across 434 services), not
  hand-curated: `s3.put_object`, `ec2.terminate_instances`,
  `iam.attach_user_policy`, `dynamodb.put_item`, `lambda.invoke`, …
  Read-only operations (`describe_*`, `list_*`, `get_*`, …) are
  excluded. Generated names only match on AWS-looking receivers
  (`s3`, `ec2`, `*_client`, `boto3.client("s3")` chains, or variables
  assigned from `boto3.client("ec2")`), so domain code like
  `orders.cancel_order(...)` doesn't collide. Regenerate with
  `scripts/gen_boto_sinks.py` (needs botocore, dev-time only).
* **Agent tool registries** — LangChain / CrewAI `@tool`, OpenAI Agents
  SDK `@function_tool`, CrewAI `BaseTool` subclasses, OpenAI-style
  `{"type": "function", ...}` schema dicts, and `Agent(tools=[...])`
  registries. A declared tool is your agent's write surface; the gate
  lands at the top of it.

Read queries are not flagged: string SQL that clearly starts with
`SELECT` / `WITH` / `EXPLAIN` / … is skipped. Test files, `tests/`
directories, virtualenvs, and hidden directories are skipped by default.

TS/JS scan-only coverage is also **product-code-only by default**: write
sites under `docs/`, `scripts/`, `sandbox/`, `examples/`, `benchmark/`,
`demo/`, and in build-tooling files (`gulpfile.js`, `Gruntfile.js`,
`webpack.config.*`, `rollup.config.*`, `vite.config.*`, …) are real —
docs tooling and build scripts do write files and call APIs — but they
are not product behavior, so a first scan that lists them reads as
noise. The scanner suppresses them from the findings list and reports a
suppressed count per path class, naming every suppressed path (and the
same breakdown rides the signed manifest as
`findings.tsjs.suppressedNonProduct`). Matching is by whole path
segment, never filename substring, so `lib/benchmark-utils.ts` is still
product code. `--include-non-product` restores the full list.

Without the `polyglot` extra installed, TS/JS files are collected but never
parsed: the report prints a prominent warning block (file count, the exact
`pip install 'pywrit[polyglot]'` command) right under the headline counts,
in the normal report and in `writ scan --score`, and the signed manifest
records the unscanned state under `findings.tsjs.notScanned`. Zero TS/JS
findings without the extra is a coverage gap, not a clean result.

Go scan-only coverage is a first detection family plus the
request-pipeline family, behind the
`polyglot` extra: `database/sql` `Exec` / `ExecContext`, GORM `Create` /
`Save` / `Update` / `Updates` / `Delete`, HTTP client `Post` / `Put` /
`Patch` / `Delete` and `http.NewRequest` / `NewRequestWithContext` with a
write method, generated-client calls shaped
`<client>.<Resource>.<New|Update|Delete|Create>` with an uppercase
resource segment (the stripe-go shape), and request-pipeline calls
`X.Call(method, ...)` / `X.RawRequest(method, ...)` on any receiver
chain where argument 0 is a write method, a `"POST"` / `"PUT"` /
`"PATCH"` / `"DELETE"` string literal or an `http.MethodPost` /
`MethodPut` / `MethodPatch` / `MethodDelete` constant (the
`stripe.Backend` shape, e.g. `c.B.Call(http.MethodPost, "/v1/accounts",
...)`). GET/HEAD requests, `db.Query` /
`QueryRow`, bare constructors (`.New` on a non-client receiver), and
pipeline calls with a variable method or a non-method first argument
are not flagged. `_test.go` files and `vendor/` trees are skipped.
Without
the extra, Go files are counted in the same warning block as TS/JS
files, and the signed manifest records the unscanned state under
`findings.go.notScanned`. Go findings are scan-only: `writ scan --apply` never rewrites `.go` files, gate them by hand. This is still
not full Go coverage: package-level generated
clients (`charge.New(...)`, the pre-v87 stripe-go shape), GORM raw-SQL
helpers (`gorm.Raw(...)`), and other Go write patterns are not detected
yet.

## What it misses (coverage gaps)

Be honest with yourself and with agents: scan will not see everything.

* **Raw SQL in strings** built dynamically or passed through helpers
  (only literal first-arg SQL is classified)
* **Dynamic dispatch** — `getattr` / `eval` tricks that hide the real
  call target
* **Other languages** — Ruby, shell, Terraform, … (TypeScript,
  JavaScript, and Go are scan-only; see above. The GitHub App bundles
  the ts/js grammars; locally install the `polyglot` extra for the same
  reach)
* **Wrappers and indirection** — shared HTTP/DB clients several layers
  down, repository methods that hide `execute`, boto3 wrappers that
  rename API calls, `client = boto3.client(...)` assigned before the
  scan's forward pass reaches it
* **Non-call writes** — assignments that mutate state without a call,
  ORM relationship writes, bulk APIs the scanner has no name for
* **One-liners and module-level writes** — listed as `manual`; gate by
  hand

When you find a gap, note it and instrument by hand (or extend the
scanner). Do not treat "gated: 100%" as "every write is safe."

## Discovery vs enforcement

| Layer                                                        | Role today                                                                                                                                                     |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`writ scan`**                                              | Discovery on-ramp: find likely writes (Python scan + auto-instrument, TypeScript/JavaScript scan-only, Go scan-only), suggest verbs, draft `_writ_check` gates |
| **Gate (`POST /v1/check`)**                                  | Enforcement: ALLOW / DENY / REQUIRE_GRANT with a tamper-evident receipt                                                                                        |
| **Credential vault (`POST /v1/leases`, `POST /v1/execute`)** | Optional gated execution: Writ holds the credential, issues single-use leases, and makes the upstream call itself (Stripe, GitHub, Salesforce today)           |

Scan without the gate is a todo list. The gate without scan is manual.
Together they close the loop for Python agent scripts; the vault and
MCP tool gating extend enforcement beyond the SDK.

## Verbs

Each write maps to a verb in `<domain>.<action>` form. The domain comes
from the file path (`src/payments/refunds.py` → `payments`); the action
from the function name through a synonym table (`issue_refund` →
`refund`, `create_order` → `create`, `notify_customer` → `send`).

```bash
verbs discovered: 6
  orders.create
  orders.delete
  orders.send
  payments.create
  payments.refund
```

### Preferring your vocabulary

The scanner guesses the domain from file paths — your tenant names it
canonically. If you've defined [custom verbs](/custom-verbs), pass
`--prefer-custom-verbs --key <key>` and the scanner fetches your verb
manifest (`GET /v1/verbs`) and maps each inferred verb onto your
vocabulary before writing the policy, pushing it, or instrumenting:

* An inferred verb that exactly matches a custom verb is kept.
* Otherwise, when its action (the part after the last dot) matches
  **exactly one** custom verb, it is remapped to it: `records.export` →
  `phi.export`.
* Ambiguous actions (two custom verbs share one action) are left alone —
  disambiguate them in the policy file.

The scan report lists every remap (`records.export -> phi.export`), so
nothing changes silently. Requires `--key` because the manifest is
tenant-scoped.

```bash
writ scan --prefer-custom-verbs --key writ_KEY
```

## Risk report

Every scan prints a risk-graded summary — a Lighthouse-style 0-100 score
based only on what the scanner actually found. Run `writ scan --score` for
the report alone: no diff, no policy file, pure read-only assessment.
Scanning is free forever and needs no API key.

The score grades **Python the scanner parsed by default**, plus ts/js write
sites when the polyglot grammars are available. Locally that means
`pip install 'pywrit[polyglot]'`; on the GitHub App the grammars are vendored
with the scanner, so TS/JS sites are always scanned there (scan-only). A
TypeScript-only (or otherwise unscanned-language) repo without the extra
prints a prominent warning block naming how many TS/JS files went unscanned
and the exact install command, and the verdict reads "No write operations
discovered in the files that were scanned". That means the scanner saw
nothing to grade, not that the repo is safe. It still scores 100/100; read
it as "100/100 of what I could see."

```bash
Writ Score: 50/100

7 write operations discovered

HIGH RISK (4)
  auth.grant             auth/roles.py:2
  payments.refund        payments/refunds.py:4
  users.delete           users/admin.py:2

MEDIUM (2)
  notifications.send     notifications/worker.py:5
  orders.update          orders/update.py:2

UNPROTECTED (0)
  0 write paths with no policy gate

Coverage: 1/7 writes instrumented (14%)

Not covered by this scanner (review by hand):
  - raw SQL strings built dynamically or passed through helpers
  - getattr / eval tricks that hide the real call target
  - indirect writes through shared HTTP clients several layers down
  - non-call state mutations (assignments, ORM relationship writes)

Scanner is free forever -- no API key needed to scan.
Fix this with Writ: pip install pywrit && writ scan --apply
Run this scan on every push and pull request with the Writ GitHub App: check runs on each change, history in the portal. Install: https://github.com/apps/writ-scanner (guide: https://docs.withwrit.com/git-hub-app)
```

Every scan report (full scan or `--score`, findings or none) ends with
that GitHub App line: the local scan is a one-off snapshot, the app runs
it on every push and pull request. The signed manifest carries the same
pointer for machine consumers under `githubApp` (`installUrl`,
`docsUrl`, `note`).

**Risk tiers** — a heuristic on the inferred `domain.action` verb:

* **HIGH (−10 each)** — money movement (`payments.refund`,
  `billing.charge`), identity/permissions (`iam.grant_admin`,
  `tokens.issue`), customer-data deletion (`users.delete`),
  infrastructure changes (`aws.terminate`)
* **MEDIUM (−5 each)** — external communications (`email.send`),
  customer-data updates (`orders.update`)
* **LOW (−2 each)** — everything else

Only **unprotected** writes subtract: functions that already have a gate
don't count against the score. Floor is 0. The report is honest about its
limits — it lists exactly the write sites the AST found, plus the known
coverage gaps above. It never claims to see writes the scanner can't
detect.

Each ungated-site row carries a scrubbed, single-line snippet (≤120
characters, string literals and comments removed) so the gate's risk
classifier can tell a `db` write from a `money` write from an `auth`
write. Snippets leave the local machine only as part of the optional
manifest (`--manifest-out`) or the GitHub App scan payload.

## The three outputs

1. **Policy** — `writ-policy.json` maps every discovered verb to
   `require_grant`, the gate's default for unknown verbs. Review it,
   then `--push-policy --key writ_KEY` to PUT it directly.
2. **Instrumentation diff** — a unified diff inserting a `_writ_check`
   gate at the top of each uninstrumented function, plus a small
   stdlib-only helper block per file. The helper is always inserted at
   **module level** (before the first function or class), even when the
   file has no imports. It reads `WRIT_API_KEY`, `WRIT_SPONSOR`,
   `WRIT_AGENT`, and `WRIT_BASE` from the environment and fails closed
   (`DENY`) on any error.
3. **Coverage report** — write sites found vs. already gated, overall
   and per file. Treat it as a discovery scorecard for what the scanner
   saw — not a guarantee that every write path is gated.

```bash
writ scan: /repo
  files scanned: 24  skipped: 0
  write sites: 31 in 12 function(s)
  gated: 4/31 (12%)
```

## Apply with approval

The scanner never writes without asking. `--apply` prompts per run;
`--yes` skips the prompt for CI. Functions the scanner can't safely
gate (one-liners, module-level writes) are listed as `manual` for you
to handle by hand.

```bash
writ scan ./my-repo --apply
# apply 9 gate(s) to 2 file(s)? [y/N]
```

The generated code calls `POST /v1/check` with your verb before the
write runs. `ALLOW` proceeds; anything else raises `PermissionError`
before the write happens — so the receipt exists even for denied
writes.