From 9ebdbb0b1cf2eef2ff6c660ab81b986ed0cd31b2 Mon Sep 17 00:00:00 2001 From: Ko Nagase Date: Thu, 25 Jun 2026 01:58:14 +0900 Subject: [PATCH 1/6] feat(config): add read_only field and REDMINE_READ_ONLY override --- internal/config/config.go | 4 ++++ internal/config/config_test.go | 15 +++++++++++++++ internal/config/types.go | 1 + 3 files changed, 20 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 1dad064..a301460 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -586,6 +586,10 @@ func applyEnvOverrides(cfg *Config, log *debug.Logger) { cfg.NoColor = true } + if val := os.Getenv("REDMINE_READ_ONLY"); val != "" { + cfg.ReadOnly = parseBoolEnv(val) + } + mcpListEnv := map[string]*[]string{ "REDMINE_MCP_ENABLE_GROUPS": &cfg.MCP.EnableGroups, "REDMINE_MCP_DISABLE_GROUPS": &cfg.MCP.DisableGroups, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 635d45c..6afdbc9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -622,3 +622,18 @@ func TestLoadProfilesTightensLegacyWorldReadableFile(t *testing.T) { t.Errorf("config mode after load = %o, want 600 (should be tightened on read)", got) } } + +func TestReadOnlyEnvOverride(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(cfgPath, []byte("profiles:\n default:\n server: https://x\n api_key: k\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("REDMINE_READ_ONLY", "1") + cfg, err := Load(cfgPath, "default", debug.New(nil)) + if err != nil { + t.Fatal(err) + } + if !cfg.ReadOnly { + t.Fatal("expected ReadOnly=true from REDMINE_READ_ONLY=1") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 14eb314..b2f3b33 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -18,6 +18,7 @@ type Config struct { DefaultProject string `mapstructure:"default_project" yaml:"default_project,omitempty"` OutputFormat string `mapstructure:"output_format" yaml:"output_format,omitempty"` // "table", "json", "csv" NoColor bool `mapstructure:"no_color" yaml:"no_color,omitempty"` + ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty"` MCP MCPConfig `mapstructure:"mcp" yaml:"mcp,omitempty"` } From fb5b2110a1d708d348f38ba134339e6228d1082a Mon Sep 17 00:00:00 2001 From: Ko Nagase Date: Thu, 25 Jun 2026 01:58:14 +0900 Subject: [PATCH 2/6] feat(api): enforce read-only mode at the HTTP transport --- internal/api/client.go | 6 ++++- internal/api/read_only.go | 34 +++++++++++++++++++++++++ internal/api/read_only_test.go | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 internal/api/read_only.go create mode 100644 internal/api/read_only_test.go diff --git a/internal/api/client.go b/internal/api/client.go index bb43be6..7427d0d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -119,8 +119,12 @@ func NewClient(cfg *config.Config, log *debug.Logger) (*Client, error) { host: host, } + var rt http.RoundTripper = transport + if cfg.ReadOnly { + rt = &readOnlyTransport{base: transport} + } c := &Client{ - httpClient: &http.Client{Transport: transport}, + httpClient: &http.Client{Transport: rt}, baseURL: baseURL, debugLog: log, } diff --git a/internal/api/read_only.go b/internal/api/read_only.go new file mode 100644 index 0000000..0255934 --- /dev/null +++ b/internal/api/read_only.go @@ -0,0 +1,34 @@ +package api + +import ( + "fmt" + "net/http" +) + +// ErrReadOnly is returned when a mutating request is attempted while the +// client is in read-only mode. The request is never sent. +type ErrReadOnly struct { + Method string + Path string +} + +func (e *ErrReadOnly) Error() string { + return fmt.Sprintf( + "read-only mode enabled: refusing %s %s (unset --read-only / REDMINE_READ_ONLY to allow writes)", + e.Method, e.Path, + ) +} + +// readOnlyTransport rejects any non-read HTTP method before delegating to base. +type readOnlyTransport struct { + base http.RoundTripper +} + +func (t *readOnlyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + switch req.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return t.base.RoundTrip(req) + default: + return nil, &ErrReadOnly{Method: req.Method, Path: req.URL.Path} + } +} diff --git a/internal/api/read_only_test.go b/internal/api/read_only_test.go new file mode 100644 index 0000000..87f4d31 --- /dev/null +++ b/internal/api/read_only_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/config" + "github.com/aarondpn/redmine-cli/v2/internal/debug" +) + +func TestReadOnlyBlocksWritesAllowsReads(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + cfg := &config.Config{Server: srv.URL, APIKey: "k", AuthMethod: "apikey", ReadOnly: true} + c, err := NewClient(cfg, debug.New(nil)) + if err != nil { + t.Fatal(err) + } + + // Write is refused before reaching the server. + err = c.Post(context.Background(), "/issues.json", map[string]any{}, nil) + var roErr *ErrReadOnly + if !errors.As(err, &roErr) { + t.Fatalf("Post error = %v, want *ErrReadOnly", err) + } + if hits != 0 { + t.Fatalf("server received %d requests, want 0", hits) + } + + // Read still works. + if err := c.Get(context.Background(), "/issues.json", nil, &struct{}{}); err != nil { + t.Fatalf("Get failed: %v", err) + } + if hits != 1 { + t.Fatalf("server received %d requests, want 1", hits) + } +} From 25a015b0f1c2bbb412e74f3d9822770c2954558c Mon Sep 17 00:00:00 2001 From: Ko Nagase Date: Thu, 25 Jun 2026 01:58:14 +0900 Subject: [PATCH 3/6] feat(cli): add --read-only global flag with flag>env>config order --- internal/cmd/root.go | 5 +++++ internal/cmdutil/factory.go | 7 ++++++ internal/cmdutil/factory_test.go | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f2d2362..fdc870a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -55,6 +55,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { verbose bool cfgFile string outputFormat string + readOnly bool ) cmd := &cobra.Command{ @@ -72,6 +73,9 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { } f.Verbose = verbose f.OutputFormat = outputFormat + if cmd.Flags().Changed("read-only") { + f.ReadOnly = &readOnly + } return nil }, SilenceUsage: true, @@ -86,6 +90,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging") cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file path (default ~/.redmine-cli.yaml)") cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "", "Output format: table, json, csv") + cmd.PersistentFlags().BoolVar(&readOnly, "read-only", false, "Refuse all write requests (GET/HEAD only)") _ = cmd.RegisterFlagCompletionFunc("output", cmdutil.CompleteOutputFormat) // Version diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index c48eb35..e6d971d 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -30,6 +30,10 @@ type Factory struct { APIKeyOverride string NoColorOverride bool + // ReadOnly, when non-nil, is the --read-only flag value and overrides + // both REDMINE_READ_ONLY and the per-profile config (highest precedence). + ReadOnly *bool + // OutputFormat is set by the root persistent --output/-o flag and used // as the default format when commands call Printer(""). OutputFormat string @@ -92,6 +96,9 @@ func (f *Factory) Config() (*config.Config, error) { if f.NoColorOverride { cfg.NoColor = true } + if f.ReadOnly != nil { + cfg.ReadOnly = *f.ReadOnly + } f.config = cfg return cfg, nil diff --git a/internal/cmdutil/factory_test.go b/internal/cmdutil/factory_test.go index d1ba222..3112e3a 100644 --- a/internal/cmdutil/factory_test.go +++ b/internal/cmdutil/factory_test.go @@ -337,3 +337,41 @@ func testFactoryWithConfig(t *testing.T, body string) *Factory { } var _ = config.Config{} + +func TestFactoryReadOnlyFlagBeatsConfig(t *testing.T) { + // File says read_only: true... + cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n read_only: true\n") + + // ...but the flag explicitly sets it false (highest precedence). + ff := false + f := NewFactory() + f.ConfigPath = cfgPath + f.ProfileOverride = "default" + f.ReadOnly = &ff + + cfg, err := f.Config() + if err != nil { + t.Fatal(err) + } + if cfg.ReadOnly { + t.Fatal("flag --read-only=false must override config read_only: true") + } +} + +func TestFactoryReadOnlyEnvWhenNoFlag(t *testing.T) { + cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n") + t.Setenv("REDMINE_READ_ONLY", "true") + + f := NewFactory() + f.ConfigPath = cfgPath + f.ProfileOverride = "default" + // f.ReadOnly stays nil (flag not set) + + cfg, err := f.Config() + if err != nil { + t.Fatal(err) + } + if !cfg.ReadOnly { + t.Fatal("REDMINE_READ_ONLY=true must apply when --read-only is not set") + } +} From 0a25f037abd61b534992cdefdaaa2cefc23f0629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20D=C3=B6ppner?= <58708656+aarondpn@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:30:25 +0200 Subject: [PATCH 4/6] feat(cli): surface read-only state in config output and clarify messages Show the read-only flag in 'redmine config' (table and JSON), name all three activation sources in the ErrReadOnly message, fix the --read-only help text (OPTIONS is also allowed), and extend test coverage to PUT, DELETE, raw PATCH, and the config-file-only and env-disables-config precedence paths. --- internal/api/read_only.go | 2 +- internal/api/read_only_test.go | 19 ++++++++++++++----- internal/cmd/root.go | 5 ++++- internal/config/config_test.go | 31 +++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/internal/api/read_only.go b/internal/api/read_only.go index 0255934..02d8632 100644 --- a/internal/api/read_only.go +++ b/internal/api/read_only.go @@ -14,7 +14,7 @@ type ErrReadOnly struct { func (e *ErrReadOnly) Error() string { return fmt.Sprintf( - "read-only mode enabled: refusing %s %s (unset --read-only / REDMINE_READ_ONLY to allow writes)", + "read-only mode enabled: refusing %s %s (set via --read-only, REDMINE_READ_ONLY, or read_only in the profile; pass --read-only=false to allow writes)", e.Method, e.Path, ) } diff --git a/internal/api/read_only_test.go b/internal/api/read_only_test.go index 87f4d31..a8b8b33 100644 --- a/internal/api/read_only_test.go +++ b/internal/api/read_only_test.go @@ -26,11 +26,20 @@ func TestReadOnlyBlocksWritesAllowsReads(t *testing.T) { t.Fatal(err) } - // Write is refused before reaching the server. - err = c.Post(context.Background(), "/issues.json", map[string]any{}, nil) - var roErr *ErrReadOnly - if !errors.As(err, &roErr) { - t.Fatalf("Post error = %v, want *ErrReadOnly", err) + // Writes are refused before reaching the server. + ctx := context.Background() + writes := map[string]error{ + "Post": c.Post(ctx, "/issues.json", map[string]any{}, nil), + "Put": c.Put(ctx, "/issues/1.json", map[string]any{}), + "Delete": c.Delete(ctx, "/issues/1.json"), + } + _, rawErr := c.DoRaw(ctx, http.MethodPatch, "/issues/1.json", nil, nil) + writes["DoRaw PATCH"] = rawErr + for name, err := range writes { + var roErr *ErrReadOnly + if !errors.As(err, &roErr) { + t.Errorf("%s error = %v, want *ErrReadOnly", name, err) + } } if hits != 0 { t.Fatalf("server received %d requests, want 0", hits) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index fdc870a..1086131 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "strconv" "github.com/spf13/cobra" @@ -90,7 +91,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging") cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file path (default ~/.redmine-cli.yaml)") cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "", "Output format: table, json, csv") - cmd.PersistentFlags().BoolVar(&readOnly, "read-only", false, "Refuse all write requests (GET/HEAD only)") + cmd.PersistentFlags().BoolVar(&readOnly, "read-only", false, "Refuse all requests that modify data on the server") _ = cmd.RegisterFlagCompletionFunc("output", cmdutil.CompleteOutputFormat) // Version @@ -153,6 +154,7 @@ func newCmdConfig(f *cmdutil.Factory) *cobra.Command { "auth_method": cfg.AuthMethod, "default_project": cfg.DefaultProject, "output_format": cfg.OutputFormat, + "read_only": strconv.FormatBool(cfg.ReadOnly), }) return nil } @@ -162,6 +164,7 @@ func newCmdConfig(f *cmdutil.Factory) *cobra.Command { {Key: "Auth Method", Value: cfg.AuthMethod}, {Key: "Default Project", Value: cfg.DefaultProject}, {Key: "Output Format", Value: cfg.OutputFormat}, + {Key: "Read Only", Value: strconv.FormatBool(cfg.ReadOnly)}, }) return nil }, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6afdbc9..3cf82c1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -623,6 +623,37 @@ func TestLoadProfilesTightensLegacyWorldReadableFile(t *testing.T) { } } +func TestReadOnlyFromConfigFile(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + body := "profiles:\n default:\n server: https://x\n api_key: k\n read_only: true\n" + if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(cfgPath, "default", debug.New(nil)) + if err != nil { + t.Fatal(err) + } + if !cfg.ReadOnly { + t.Fatal("expected ReadOnly=true from profile read_only: true") + } +} + +func TestReadOnlyEnvDisablesConfig(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + body := "profiles:\n default:\n server: https://x\n api_key: k\n read_only: true\n" + if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("REDMINE_READ_ONLY", "false") + cfg, err := Load(cfgPath, "default", debug.New(nil)) + if err != nil { + t.Fatal(err) + } + if cfg.ReadOnly { + t.Fatal("REDMINE_READ_ONLY=false must override profile read_only: true") + } +} + func TestReadOnlyEnvOverride(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(cfgPath, []byte("profiles:\n default:\n server: https://x\n api_key: k\n"), 0o600); err != nil { From 34859a9a8167e36f3eb33aef9f9d07bd3fb6178d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20D=C3=B6ppner?= <58708656+aarondpn@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:30:25 +0200 Subject: [PATCH 5/6] docs(read-only): document read-only mode in English and Chinese Add a read-only mode section to the configuration guide (flag, env var, per-profile setting, precedence), list --read-only and REDMINE_READ_ONLY in the flag and env tables, and cross-reference the HTTP-layer guarantee from the AI agents guide. --- .../docs/getting-started/configuration.mdx | 32 ++++++++++++++++++- docs/src/content/docs/guides/ai-agents.mdx | 2 +- .../zh-cn/getting-started/configuration.mdx | 32 ++++++++++++++++++- .../content/docs/zh-cn/guides/ai-agents.mdx | 2 +- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/getting-started/configuration.mdx b/docs/src/content/docs/getting-started/configuration.mdx index 4bebe14..1b7d312 100644 --- a/docs/src/content/docs/getting-started/configuration.mdx +++ b/docs/src/content/docs/getting-started/configuration.mdx @@ -118,6 +118,9 @@ export REDMINE_AUTH_METHOD=apikey # opt out of the startup update check export REDMINE_NO_UPDATE_CHECK=1 +# refuse all requests that modify data (read-only mode) +export REDMINE_READ_ONLY=1 + # hard-disable the optional system keyring export REDMINE_NO_KEYRING=1 ``` @@ -135,15 +138,42 @@ Flags take precedence over config values and environment variables. | `--profile` | Use a specific auth profile | | `--config` | Config file path (default `~/.redmine-cli.yaml`) | | `--no-color` | Disable colored output | +| `--read-only` | Refuse all requests that modify data on the server | | `-v, --verbose` | Enable debug logging | +## Read-only mode + +Point the CLI at a production Redmine without any risk of changing data. When enabled, every request that would modify the server is refused before it is sent; all listing, viewing, and searching commands keep working. + +Enable it in any of three ways (precedence: flag > environment variable > config): + +```bash +redmine --read-only issues list # single invocation +export REDMINE_READ_ONLY=1 # whole shell session +``` + +```yaml title="~/.redmine-cli.yaml" +profiles: + prod: + server: https://redmine.example.com + auth_method: apikey + api_key: your-api-key + read_only: true +``` + +Pass `--read-only=false` to override the environment variable or a `read_only: true` profile for a single invocation. + + + ## Inspect current config ```bash redmine config ``` -Prints the active profile, server, auth method, default project, and output format. +Prints the active profile, server, auth method, default project, output format, and read-only state. ## Next diff --git a/docs/src/content/docs/guides/ai-agents.mdx b/docs/src/content/docs/guides/ai-agents.mdx index 696e9a3..9285dfe 100644 --- a/docs/src/content/docs/guides/ai-agents.mdx +++ b/docs/src/content/docs/guides/ai-agents.mdx @@ -39,7 +39,7 @@ The full skill source: [`skills/redmine-cli/SKILL.md`](https://github.com/aarond - **Transport:** stdio by default. The host can spawn `redmine mcp serve` and talk JSON-RPC over its standard streams, or you can pass `--http :8080` to expose the same server over streamable HTTP. - **Authentication:** the active profile is used by default. Override with `--profile `, `--server` / `--api-key`, or the `REDMINE_*` environment variables -- exactly like every other subcommand. -- **Read-only by default.** Mutating tools (create / update / delete, comment, close, reopen, and similar write operations) are registered only when `--enable-writes` is passed. Without the flag they never appear in `tools/list`. +- **Read-only by default.** Mutating tools (create / update / delete, comment, close, reopen, and similar write operations) are registered only when `--enable-writes` is passed. Without the flag they never appear in `tools/list`. For a second, transport-level guarantee, set [read-only mode](/getting-started/configuration/#read-only-mode) (`read_only: true` in the profile or `REDMINE_READ_ONLY=1`) -- it blocks every write at the HTTP layer, even when `--enable-writes` is passed. - **Configurable surface.** `--enable-groups` / `--disable-groups` constrain the registered tools to specific categories (`issues`, `wiki`, `time`, ...). For sharper control, `--enable-tools` / `--disable-tools` allow- or deny-list individual tool names. Run `redmine mcp tools` to print the full catalog.