Skip to navigation

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.

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

LayerRole today
writ scanDiscovery 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).

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, 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.

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.”

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.
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.

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.