Scanner
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.
(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(), DjangoOrder.objects.create()/get_or_create()/update_or_create()/update()/bulk_create()/bulk_update(), instanceorder.save()/user.delete()/user.delete_instance()(Django, SQLAlchemy, Peewee, Pony), and Pony@db_sessiontransactional boundaries - HTTP —
requests.post/put/patch,client.delete(url)/api.delete(...)on http-ish receivers, andrequeston http-ish receivers (client,session,api, …) - Third-party SDKs — Stripe
stripe.Refund.create()/stripe.Charge.create()/PaymentIntent.confirm()(anystripe.<Resource>.<action>exceptretrieve/list/search, plus.save()/.delete()on resources fetched from Stripe), Twilioclient.messages.create()/client.calls.create(), Resendresend.Emails.send(), SendGridsg.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 hintedsendon queue-ish receivers (sqs,kafka,redis, …) - Deferred tasks — Celery
@shared_task/@app.taskandtask.delay()/task.apply_async()call sites, Dramatiq@actor, RQqueue.enqueue(...). The task definition is flagged so--applygates 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 fromboto3.client("ec2")), so domain code likeorders.cancel_order(...)doesn’t collide. Regenerate withscripts/gen_boto_sinks.py(needs botocore, dev-time only). - Agent tool registries — LangChain / CrewAI
@tool, OpenAI Agents SDK@function_tool, CrewAIBaseToolsubclasses, OpenAI-style{"type": "function", ...}schema dicts, andAgent(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/evaltricks 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
polyglotextra 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
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).
Preferring your vocabulary
The scanner guesses the domain from file paths — your tenant names it
canonically. If you’ve defined 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.
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.”
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
- Policy —
writ-policy.jsonmaps every discovered verb torequire_grant, the gate’s default for unknown verbs. Review it, then--push-policy --key writ_KEYto PUT it directly. - Instrumentation diff — a unified diff inserting a
_writ_checkgate 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 readsWRIT_API_KEY,WRIT_SPONSOR,WRIT_AGENT, andWRIT_BASEfrom the environment and fails closed (DENY) on any error. - 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.
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.
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.