Extend read_only config into all/prod/off modes - #195
Open
aprimakina wants to merge 4 commits into
Open
Conversation
aprimakina
force-pushed
the
read-only-modes
branch
4 times, most recently
from
August 20, 2026 15:19
281326f to
7b320c8
Compare
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
force-pushed
the
read-only-modes
branch
from
August 20, 2026 15:26
7b320c8 to
29060e0
Compare
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
marked this pull request as ready for review
August 20, 2026 15:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
read_onlywas 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:true)false, and still the default)The booleans keep working -
read_only: truein a config file, or TIGER_READ_ONLY=true, still meanall. The default staysoff; flipping it toprodis a separate decision, since it would change behavior for production services - notablytiger db connect, which would start opening read-only sessions.Because
prodprotects 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 underprod. Both require a tag, so a gate cannot silently skip prod mode.Notes on the individual surfaces:
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.db create roleis gated too, and thedb connectpassword-recovery menu no longer offers to rotate the password of a protected service.First-login prompt
read_onlydefaults tooff, so the protection above only reaches users who go looking for it. Flipping the default would change behaviour for every existing user — notablytiger db connect, which would start opening read-only sessions againstPRODservices, with Postgres reporting the refusal so nothing points at the setting.A first interactive
tiger auth loginnow offers it instead:config showreflects a choice rather than a version-dependent default.read_only: offrather 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, sinceoffis also the default.