How it works Privacy Terms Docs

What is built, what is not, and where to send a bug.

Security · 2026-08-04

Written for the person who has to sign off on a vendor. Every line below is true of the code as it stands, and the second half is longer than the first.

Verbatim is a pre-production build by one person. It holds no customer data.

There is no certification of any kind — no SOC 2, no ISO 27001, no third-party audit, no penetration test. Nobody has assessed this but the person who wrote it.

What is implemented

Each of these is a mechanism in the code, not a policy in a document.

passwords

Stored as scrypt hashes from the standard library, with a fresh 16-byte random salt for each account and the cost settings written beside each hash, so raising the cost later breaks nobody. Minimum twelve characters. Every comparison of a secret runs in constant time; no password, token or hash is compared with an equals sign anywhere in the authentication code.

sessions

A row in the database plus a 256-bit random bearer token, not a signed cookie. Only a SHA-256 of the token is stored, so reading the whole table hands over hashes rather than working sessions. Signing out revokes the row on the server rather than clearing a cookie the browser might keep. The lifetime is twelve hours, written when the row is created and never extended, so a stolen token has a ceiling. Suspending an account stops its live sessions at the next request.

cookie

HttpOnly, SameSite=Lax, Secure, path /, expiring with the session. Secure comes off in exactly one case — plain HTTP addressed to the local machine, which is what a developer's own run serves — and the login page prints on the page when it has.

sign-in

One refusal message for an unknown address, a wrong password, a suspended account and a locked one alike. The password check runs even when the address has no account, so response time does not answer the question either. Five consecutive failures lock the account for fifteen minutes; a correct password does not walk through the lock.

the guard

One middleware resolves the cookie for every request and sends every anonymous one to the login page. Four things go through, and the list is in app/web/deps.py: the login page, the health check, everything under /static/ — four stylesheets and four scripts, since an unstyled login page is a broken one — and any path under /s/ — the share links, which have a section of their own below. The trailing slash on that prefix is the control, not decoration: startswith("/s") would make /settings public. A route added next week is protected because it exists, not because somebody remembered to decorate it.

tenant scope

Every company-scoped read runs through one function that refuses a query with no company id rather than answering it. It is imported everywhere it is used and never copied, so there is one place to audit.

audit trail

Append-only twice over. Application code has no path that updates or deletes a row — the attempt raises. A SHA-256 chain runs over every field of every row, numbered from one per company with no gaps, so a row altered or removed by other means is detected. Who acted, from which address and under which session are inside the hash, not merely beside it.

roles

Fifteen permission codes, fixed in code. Three roles ship with the product and nobody can edit them, because editing one would change what a word means for people who never asked. What an administrator can do is grant a single permission to a single person, with a reason on the record, and compose a new role and name it — and neither may hand on a permission the grantor does not hold. The control is the row that is missing: the analyst role holds no approval permission, so the person who interprets a change cannot approve the action that follows from it. The permissions screen shows every person against every code, and says of each mark whether it came from a role or from a direct grant.

what leaves

This site and the application load nothing from another host — no fonts, no scripts, no stylesheets, no content delivery network, no analytics. One thing does leave, on the request path. The assistant panel sits on every screen and posts to /chat; app/web/views/chat.py reaches app/chat/engine.py, which calls app/chat/agent.py, which sends the question, a short prompt naming the person and their permissions, and the results of any tool it ran to Anthropic's API. app/config.py reads ANTHROPIC_API_KEY from .env at start, and deploy/compose.yml passes an environment file into the container. With no key the assistant says it is unavailable and nothing is sent. This page used to say no model call ran; that stopped being true and the sentence stayed, which is the failure this product exists to catch. The sub-processors page lists what goes and what is held back.

secrets

One file, ignored by version control before it was created, read once at start. No credential is put into a page, an API response or a prompt, and the audit trail is written on the rule that a secret never goes into it — a failed sign-in records the address that was tried and never the password.

What is not implemented

Read this half harder than the one above. A reviewer finds these in an afternoon, and a page that omitted them would be worth less than no page.

not built

Authorisation on the screens that matter. Permission checks now run on all eight administrative screens — the admin index, logins, permissions, approval routes, the share register, invitations, sources and the feedback queue — and somebody holding none of those codes is answered 403 at every one of them. The review and approval screens still do not call them: they take a reviewer's name typed into a form. So a signed-in person with no roles still reaches the review queue and the review centre, and one operator can still resolve work they raised. The check exists and is tested; it is not yet on that path.

not built

Encryption at rest. The data sits in a single SQLite file with whatever protection the host's disk gives it, and nothing more. No field-level encryption either.

not built

Enterprise sign-in. No SSO, no SAML, no OIDC, no directory integration, no multi-factor authentication, no password reset and no password-change path. Verbatim runs its own password store, which is the first component a regulated buyer asks to remove.

not built

A cross-site request token. There is none on the sign-in form. SameSite=Lax on the cookie is the whole of that defence.

not built

Rate limiting. None anywhere. The lockout counts failures per account, so guesses spread thinly across many addresses meet nothing. There is no firewall in front and no protection against flooding.

not built

Proof against someone who owns the host. The hash chain detects tampering; it does not prevent it. Anyone who can write the database file can recompute every hash forward from the row they changed and leave nothing to find. Closing that means publishing the chain's head where that same person cannot reach it. It is not done.

not built

Attribution on every write. Counted on today's tree: 82 places under app/ and scripts/ append an audit row. 47 of them, across 14 modules, name the account id. The other 35 record a machine acting with nobody named, even where a person was signed in — including writers on the decision path: app/state/review.py, app/web/views/review.py, app/web/views/review_centre.py, app/state/projects.py, app/pipeline.py, and 10 of the 18 writes in app/state/workflow.py. The session id is thinner still: 6 writes in three modules carry it — app/auth/sessions.py, app/state/rollback.py and app/web/views/chat.py. And the role paths, which a reader would expect to be the best attributed rows in the product, are among the worst: all four writes in app/state/identity.py — account created, account suspended or reinstated, role granted, role revoked — pass a display string and nothing else, so they record neither the account id nor the session, and the actor kind falls back to a machine. Those rows are honest about what was captured and useless for tying a decision to a sitting.

not built

Database-enforced tenant isolation. The chokepoint is an application promise, not an engine guarantee. One function bypassing it is a full breach. Row-level security needs a database that has it, and this build does not use one.

off by default

Deletion. A retention schedule exists in code, covering every table with a window and a reason, and the purges are tested. A scheduler can now reach them: app/jobs/runner.py calls app/state/retention.py. Two settings stand in front of it and both default to off — no job loop starts unless VERBATIM_JOBS_ENABLED is true, and the purge is a dry run unless VERBATIM_JOBS_RETENTION_DELETE arms it. Nothing in this repository sets either — deploy/compose.yml does not, and the container prints on every start which way it landed, so a reader of the log can tell "no job is scheduled" from "a job is scheduled and has not fired". There is still no erasure path for a user, a document or a claim. Two email addresses cannot be reached by any purge at all: a failed sign-in records the address that was tried, and an invitation records the address it was sent to, and both sit inside the hash chain, which cannot be rewritten. The privacy page sets out the schedule and what it means for an erasure request.

not running

Backups. A script exists and is tested. scripts/backup.py snapshots the database through SQLite's own backup call rather than copying the file, so a copy taken while the product is being written to is still consistent; it then opens that copy, runs PRAGMA integrity_check, counts the audit table and re-verifies every hash chain in it. A copy that fails any of those is renamed so nobody can restore it by mistake, and the command exits non-zero. Nothing schedules it. There is a job runner now — app/jobs/runner.py — and the backup is not one of its jobs: it knows three names, and they are the approval clock, the retention purge and a source fetch that is not built. No cron entry, no timer, no hosted job either. So no backup exists until a person runs it, and none has been taken of any deployment. It writes to a local directory you name, and nothing configures an off-host destination anywhere, so the copy lands on the same disk as the database: that survives a bad deploy and not a lost host. Nothing encrypts the copy, and no restore into a running system has ever been practised. A working script, not disaster recovery.

not built

The rest of the operational safety net. No incident-response plan, no breach-notification process, no logging pipeline, no monitoring, no dependency scanning, no security team and nobody on call. There is one person and one mailbox.

not built

A safe demonstration deployment. The seeded demonstration accounts share one password that the setup script prints and the login page displays. That is right for a workspace holding nothing but invented data and public record, and wrong for anything else. Any deployment holding real documents has to create its own accounts, and nothing automates that yet.

The share link, and what holding one gets you

One route in the product answers with no session: /s/<token>. It sends a single claim, with its citation, to somebody in Legal or Rates who has no account here. It is the only hole in the guard and it gets its own section because a buyer should not have to find it.

the token

32 random bytes in the path, and the path is the whole credential — there is nothing else to present. Only a SHA-256 of it is stored, and the presented token is compared against that digest in constant time. The link is minted once and shown once; there is no way to read it back, so a lost link is revoked and re-minted.

expiry

Seven days by default, thirty at most, and an expiry cannot be absent. The person who sent it can revoke it before then. Expired, revoked, and never existed all answer with the same sentence, so somebody holding a guess cannot learn which tokens are real. The company can switch sharing off, which closes every live link at once.

what it shows

One artifact, never a list. No masthead, no navigation, no company name, no sibling claims, no way to walk into the product. The page re-reads the stored source at the cited offsets on every open, so a claim whose citation has stopped verifying withdraws itself in front of the reader — and a withheld claim has no statement to leak, because the object carrying it has no such field.

three headers

Referrer-Policy: no-referrer, so following the sign-in link does not put a live token in a Referer header. Cache-Control: no-store, so a dead link is not answered from a shared cache and a verdict computed at open time is not replayed as though it were computed now. X-Robots-Tag: noindex, with the meta tag, so a crawler that reached a token does not put a claim and its source into a search index.

forwarding

Anyone holding the link can open it, and the product cannot tell who. There is no recipient, no address to check against, no second factor and no limit on opens. Forward the mail, paste it into a group chat, and every reader is the same anonymous caller to this server. What is recorded is the open: the time, the address the connection came from, and whether the citation held at that moment. That is a record of what happened, not a control that stops it.

no rate limit

Nothing slows a flood of opens against a real token. Guessing one is not a realistic attack at 32 random bytes; hammering one you already have is not slowed at all.

already fixed

Written down because a reader is owed the failure as well as the control. /s/<token> was not in the guard's public list, so an anonymous open answered 303 to /login?next=%2Fs%2F<token> — copying a live bearer token into a query string, where it lands in the access log, the Referer header and browser history. A redirect that carries the credential it was protecting is worse than no guard, because it looks like a guard working. app/web/deps.py treats the prefix as public now. Any token issued before that fix should be treated as one that may sit in a log.

Reporting a vulnerability

Write to jsahasi@gmail.com. Put "security" in the subject. One person reads it, and you will get a reply from a human.

Useful to include: what you did, what happened, what you expected, and the commit or the page you were on. A short reproduction beats a scanner report.

The same policy in machine-readable form sits at /.well-known/security.txt, in the format RFC 9116 sets out.

Read the scope rows before you start. Most of what a scanner flags on this build is either published on purpose or already listed above as not built, and reporting it costs you an afternoon and tells me nothing I have not written down myself.

in scope

Three things, and the first one changed on 2026-08-04. The live application at verbatim.citelocal.ai. The product is deployed behind that name now — nginx serves these pages at the root and proxies a named list of paths to the application container, so /login, /projects, /chat, /s, /healthz and the rest of that list are a live surface. deploy/nginx.conf is the list. An earlier version of this page said the application was not deployed there, which sent researchers away from the only running copy. The static pages themselves. And the application code in this repository, run on your own machine, which is the easiest place to work. What is worth your time: a way past the sign-in guard, a way to read or write another company's rows, a way to alter an audit row or a chain without the check catching it, anything that leaks a session token or a share token, and anything that makes the citation check assert a claim its source does not support. That last one is the product, so treat it as a security bug even though it does not look like one.

out of scope

Everything the section headed what is not implemented names as not built — the missing rate limiting, the missing cross-site request token, the missing permission check on the review and approval screens, and the rest of that list — plus the four limits named in the share-link section. Those are not findings; they are the page you have just read. The lists are not copied down here on purpose: a second copy would drift from the first, and those two sections are the ones of record. Two more, which are true of the static pages and not on either list: they send no HSTS, no content security policy and no frame-ancestors header, and they carry no script and set no cookie for one to protect. The rest of the machine is out. That host is shared, and it serves another project with commercial data on it; that project is not in scope and nothing here authorises you to touch it. Also out: anything needing physical access or a machine you already control, anything aimed at a third party's host, and any test that works by volume — one small virtual machine, one worker, and a flood is an outage rather than a finding.

published on purpose

The application runs at verbatim.citelocal.ai, and its demonstration accounts are real accounts sharing one password: verbatim-demo-2026. Anyone can sign in to the live copy with it, which is the intent. The login page prints it — app/web/templates/login.html renders it from the context app/web/views/auth.py builds, and the value comes from app/seed.py, which prints it again when it seeds. That is the right answer for a demonstration holding no confidential data and the wrong one everywhere else, it is named above as a weakness, and VERBATIM_DEMO_ACCOUNTS=0 turns the panel off. Neither corpus holds anything confidential, and they are not the same kind of thing. One is invented — a fictional commission, a fictional docket, fictional people. The other is 102 filings that eight state commissions publish themselves, by named companies and named witnesses, downloaded from their own document systems. Finding either is not a finding.

safe harbour

Research in good faith, inside this policy, and I will not pursue you for it and will not ask anyone else to. That covers the live application at verbatim.citelocal.ai as well as your own run — the boundary moved when the product was deployed, and this paragraph moved with it rather than being left describing a static site. If a question of authorised access comes up, this paragraph is the authorisation; ask and I will put it in writing over my own name. The terms of use say not to probe the service; this policy is the exception to that sentence, and the scope rows above are its edge. Now the limits, stated rather than buried. There is no company here, so this is one person's undertaking and it binds one person. It cannot bind the host the site sits on, the registrar, Anthropic, or anyone else whose terms you break on the way. It does not reach the other project on that shared machine, which is out of scope above. And it stops covering you if you take data that is not yours, degrade the service for somebody else, or hold a finding back for payment.

timing, as a target

A target is what one person aims at. It is not a service level, nobody is on call, and no clock runs. I aim to acknowledge inside three working days, and to tell you what I think it is inside ten. You get a decision — fixed, will not fix, or already known — and the reason for it. No fix date is promised, because there is nobody to promise it against. If the acknowledgement does not arrive, send it again: one person and one mailbox is exactly the failure you would expect.

publishing

Yours to decide. I would rather you waited for a fix or ninety days, whichever comes first, and I will tell you when the fix is out. That is a request, not a condition. Nothing here asks for your silence and there is nothing to sign.

no bounty

There is none, and there will not be one until the three things it needs exist: money to pay it, somebody to triage what it brings in, and a legal entity to stand behind the safe harbour above. This build has none of the three, and a reward announced without them is a debt rather than a programme. Credit is what can actually be given: say so in your mail and you are named in the fix commit and on this page, or left out if you would rather be.

Placeholder — required before production use A security contact on a domain the company owns, and a named legal entity behind the safe harbour above. Neither exists. The address is an ordinary Gmail mailbox and the undertaking is one person's word, which is worth exactly what one person's word is worth. The written policy is no longer missing — it is the rows above, and /.well-known/security.txt points at them.

Why the page reads like this

Verbatim's argument is that a claim without a citation behind it should refuse to assert itself. A security page claiming controls the code does not have would be exactly the failure the product exists to prevent — and the one a reviewer can check in five minutes.

So "not yet" appears here wherever "not yet" is the answer.

The harder failure is the other one, and this page has now had it twice: a sentence that was true when it was written and quietly stopped being true. This page said no model call ran and said the application was not deployed, and both were false for most of a day. Nothing broke, no test went red, and only reading the code against the page found it. That is why each row above names the file it can be checked against.