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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
A cross-site request token. There is none on the sign-in form. SameSite=Lax on the cookie is the whole of that defence.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.