Skip to content

Extend read_only config into all/prod/off modes - #195

Open
aprimakina wants to merge 4 commits into
mainfrom
read-only-modes
Open

Extend read_only config into all/prod/off modes#195
aprimakina wants to merge 4 commits into
mainfrom
read-only-modes

Conversation

@aprimakina

@aprimakina aprimakina commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

read_only was a bool: writes were refused either everywhere or nowhere. It now takes three modes, so an agent can be let loose on development services while production stays protected:

  • all: every service (equivalent to the old true)
  • prod: only services tagged PROD
  • off: nothing (equivalent to the old false, and still the default)

The booleans keep working - read_only: true in a config file, or TIGER_READ_ONLY=true, still mean all. The default stays off; flipping it to prod is a separate decision, since it would change behavior for production services - notably tiger db connect, which would start opening read-only sessions.

Because prod protects only services tagged PROD, every gate needs its target's environment tag. common.CheckReadOnly(cfg, tag) is the verdict, and CheckReadOnlyByServiceID fetches the service when only its ID is known - one extra API call, and only under prod. Both require a tag, so a gate cannot silently skip prod mode.

Notes on the individual surfaces:

  • MCP registration cannot express prod, so the write tools stay registered (they still work on DEV services) and refuse per call instead. The server instructions gained a prod variant so a refusal does not look like a bug, and ServiceDetail now reports each service's environment tag so the model can check before offering to modify one.
  • Database sessions reach the same verdict as the control-plane gate: a mode that refuses writes to a service also opens its connections read-only. That covers the connection strings embedded in service get/list output, not just the db commands.
  • A replica set is judged on its own environment tag rather than its primary's, on both planes, matching how the API tags replica sets individually.
  • db create role is gated too, and the db connect password-recovery menu no longer offers to rotate the password of a protected service.

First-login prompt

read_only defaults to off, so the protection above only reaches users who go looking for it. Flipping the default would change behaviour for every existing user — notably tiger db connect, which would start opening read-only sessions against PROD services, with Postgres reporting the refusal so nothing points at the setting.

A first interactive tiger auth login now offers it instead:

Protect services tagged PROD from writes? [Y/n]:
  • Triggered only when no credentials existed beforehand, so existing users are never asked and no default changes. The answer is written to the config file, so config show reflects a choice rather than a version-dependent default.
  • Both answers are recorded, so it asks exactly once. Declining stores read_only: off rather than leaving the key absent — absence can't record a negative answer, so a logout and second login would otherwise ask again. The guard tests key presence rather than the resolved value, since off is also the default.
  • Skipped when stdin isn't a terminal, so scripted and CI logins neither block nor change behaviour. Unlike the CLI's other TTY gates it skips silently rather than erroring, since nothing is being refused.

@aprimakina aprimakina self-assigned this Aug 20, 2026
@aprimakina
aprimakina force-pushed the read-only-modes branch 4 times, most recently from 281326f to 7b320c8 Compare August 20, 2026 15:19
aprimakina and others added 2 commits August 20, 2026 17:26
read_only was a bool: writes were refused either everywhere or nowhere. It now
takes three modes, so an agent can be let loose on development services while
production stays protected:

  all   every service (equivalent to the old `true`)
  prod  only services tagged PROD
  off   nothing (equivalent to the old `false`, and still the default)

The booleans keep working - `read_only: true` in a config file, or
TIGER_READ_ONLY=true, still mean `all`. config.Load runs whatever the source held
through ParseReadOnlyMode, which accepts the mode names plus every boolean
spelling viper's weak typing can produce ("1"/"0" for a YAML bool or int), so no
caller ever sees an unnormalized value and existing setups are unaffected. The
default stays `off`; flipping it to `prod` is a separate decision, since it would
change behavior for production services - notably `tiger db connect`, which would
start opening read-only sessions.

Because `prod` protects only services tagged PROD, every gate needs its target's
environment tag. common.CheckReadOnly(cfg, tag) is the verdict, and
CheckReadOnlyByServiceID fetches the service when only its ID is known - one
extra API call, and only under `prod`. Both require a tag, so a gate cannot
silently skip prod mode.

Notes on the individual surfaces:

- MCP registration cannot express `prod`, so the write tools stay registered
  (they still work on DEV services) and refuse per call instead. The server
  instructions gained a prod variant so a refusal does not look like a bug, and
  ServiceDetail now reports each service's environment tag so the model can check
  before offering to modify one.
- Database sessions reach the same verdict as the control-plane gate: a mode that
  refuses writes to a service also opens its connections read-only. That covers
  the connection strings embedded in service get/list output, not just the db
  commands.
- A replica set is judged on its own environment tag rather than its primary's,
  on both planes, matching how the API tags replica sets individually.
- `db create role` is gated too, and the `db connect` password-recovery menu no
  longer offers to rotate the password of a protected service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_only defaults to off, so the protection only reaches users who go looking
for it. Flipping the default would change behaviour for every existing user -
notably `tiger db connect`, which would start opening read-only sessions against
PROD services, with Postgres reporting the refusal so nothing points at the
setting.

A first interactive login is a better hook: it is per-user, unlike an install
script or package postinstall; every new user runs it; and GetStoredCredentials
distinguishes a first-ever login from a repeat one. Existing users are left
alone, and no default changes - the answer is written into the config file, so
`config show` reflects a choice rather than a version-dependent default.

Both answers are recorded, so the question is asked exactly once. Declining
stores read_only=off rather than leaving the key absent: absence cannot record a
negative answer, so a logout and second login would otherwise ask again. It also
means a later change to the default won't silently override someone who
explicitly declined. Testing key presence rather than the resolved value matters
here, since off is also the default.

The prompt is skipped when stdin isn't a terminal, so scripted and CI logins
neither block nor change behaviour. Unlike the CLI's other TTY gates it skips
silently rather than erroring, since nothing is being refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aprimakina and others added 2 commits August 20, 2026 17:46
Under read_only=prod the MCP server instructions tell the model to check a
service's environment field, naming both service_get and service_list. Only
ServiceDetail carried the field, so a model working from a list result saw no tag
on any service, concluded everything was writable, and got the refusal the
instruction exists to avoid - or issued a service_get per service to work around
it.

ServiceInfo now carries it too, through the same ServiceEnvironmentTag helper and
with the same schema description, so the field is advertised in tools/list rather
than merely present in the payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mode names were matched case-sensitively, for consistency with `output` and
`password_storage`. But the legacy boolean fallback goes through
strconv.ParseBool, which is already case-insensitive - so the real rule was
case-sensitive for the new vocabulary and case-insensitive for the old one:
TIGER_READ_ONLY=TRUE worked while TIGER_READ_ONLY=PROD was rejected.

PROD is how the environment tag is spelled in `service get` output, the MCP
server instructions and ErrReadOnlyProd's own message, so it is the spelling most
likely to be copied. Rejecting it fails the load, and config.Load runs for every
command, so it also takes out the `config set` that could repair the file.

Values are still normalized on write, so `config set read_only PROD` stores prod
and the file keeps one spelling. A genuine typo is still rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aprimakina
aprimakina marked this pull request as ready for review August 20, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant