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.
+
+
+ The MCP server has its own gate: mutating tools are only registered when `--enable-writes` is passed (see [AI agents](/guides/ai-agents/)). A profile with `read_only: true` additionally blocks writes at the HTTP layer, even when `--enable-writes` is set.
+
+
## 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.
diff --git a/docs/src/content/docs/zh-cn/getting-started/configuration.mdx b/docs/src/content/docs/zh-cn/getting-started/configuration.mdx
index 4b23814..622d694 100644
--- a/docs/src/content/docs/zh-cn/getting-started/configuration.mdx
+++ b/docs/src/content/docs/zh-cn/getting-started/configuration.mdx
@@ -118,6 +118,9 @@ export REDMINE_AUTH_METHOD=apikey
# 关闭启动时的更新检查
export REDMINE_NO_UPDATE_CHECK=1
+# 拒绝所有修改数据的请求(只读模式)
+export REDMINE_READ_ONLY=1
+
# 彻底禁用可选的系统密钥环
export REDMINE_NO_KEYRING=1
```
@@ -135,15 +138,42 @@ export REDMINE_NO_KEYRING=1
| `--profile` | 使用指定的身份验证配置文件 |
| `--config` | 配置文件路径(默认为 `~/.redmine-cli.yaml`) |
| `--no-color` | 禁用彩色输出 |
+| `--read-only` | 拒绝所有修改服务器数据的请求 |
| `-v, --verbose` | 启用调试日志 |
+## 只读模式
+
+将 CLI 指向生产环境的 Redmine 而无需担心数据被修改。启用后,任何会修改服务器数据的请求都会在发送前被拒绝;所有列表、查看和搜索命令继续正常工作。
+
+可通过三种方式启用(优先级:标志 > 环境变量 > 配置文件):
+
+```bash
+redmine --read-only issues list # 单次调用
+export REDMINE_READ_ONLY=1 # 整个 shell 会话
+```
+
+```yaml title="~/.redmine-cli.yaml"
+profiles:
+ prod:
+ server: https://redmine.example.com
+ auth_method: apikey
+ api_key: your-api-key
+ read_only: true
+```
+
+传入 `--read-only=false` 可在单次调用中覆盖环境变量或配置文件中的 `read_only: true`。
+
+
+ MCP 服务器有自己的开关:只有传入 `--enable-writes` 时才会注册写入类工具(参见 [AI 代理](/zh-cn/guides/ai-agents/))。配置文件中的 `read_only: true` 会额外在 HTTP 层阻止写入,即使已传入 `--enable-writes`。
+
+
## 查看当前配置
```bash
redmine config
```
-输出活跃配置文件、服务器、身份验证方式、默认项目及输出格式。
+输出活跃配置文件、服务器、身份验证方式、默认项目、输出格式及只读状态。
## 下一步
diff --git a/docs/src/content/docs/zh-cn/guides/ai-agents.mdx b/docs/src/content/docs/zh-cn/guides/ai-agents.mdx
index 64303d5..69b5f22 100644
--- a/docs/src/content/docs/zh-cn/guides/ai-agents.mdx
+++ b/docs/src/content/docs/zh-cn/guides/ai-agents.mdx
@@ -39,7 +39,7 @@ redmine-cli 提供两种互补的集成方式,二者均与厂商无关:
- **传输方式:** 默认使用 stdio。宿主可生成 `redmine mcp serve` 进程并通过其标准流使用 JSON-RPC 通信;也可传入 `--http :8080` 通过 streamable HTTP 暴露同一个服务。
- **身份验证:** 默认使用当前活跃的配置文件。可通过 `--profile `、`--server` / `--api-key` 或 `REDMINE_*` 环境变量覆盖,与其他子命令完全一致。
-- **默认只读。** 修改类工具(创建 / 更新 / 删除、评论、关闭、重新打开及类似写操作)仅在传入 `--enable-writes` 时才注册。未传入该标志时,这些工具不会出现在 `tools/list` 中。
+- **默认只读。** 修改类工具(创建 / 更新 / 删除、评论、关闭、重新打开及类似写操作)仅在传入 `--enable-writes` 时才注册。未传入该标志时,这些工具不会出现在 `tools/list` 中。如需传输层的双重保障,可启用[只读模式](/zh-cn/getting-started/configuration/#只读模式)(在配置文件中设置 `read_only: true` 或设置 `REDMINE_READ_ONLY=1`)——它会在 HTTP 层阻止所有写入,即使已传入 `--enable-writes`。
- **可配置暴露范围。** `--enable-groups` / `--disable-groups` 可将注册的工具限制到特定类别(`issues`、`wiki`、`time` 等)。若需更细粒度的控制,可使用 `--enable-tools` / `--disable-tools` 对单个工具做 allow-list 或 deny-list。运行 `redmine mcp tools` 可打印完整工具目录。
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..02d8632
--- /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 (set via --read-only, REDMINE_READ_ONLY, or read_only in the profile; pass --read-only=false 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..a8b8b33
--- /dev/null
+++ b/internal/api/read_only_test.go
@@ -0,0 +1,55 @@
+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)
+ }
+
+ // 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)
+ }
+
+ // 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)
+ }
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index f2d2362..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"
@@ -55,6 +56,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) {
verbose bool
cfgFile string
outputFormat string
+ readOnly bool
)
cmd := &cobra.Command{
@@ -72,6 +74,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 +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 requests that modify data on the server")
_ = cmd.RegisterFlagCompletionFunc("output", cmdutil.CompleteOutputFormat)
// Version
@@ -148,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
}
@@ -157,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/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")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 1dad064..6bcfa2f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -433,14 +433,15 @@ func SaveProfile(name string, cfg *Config, path string) error {
return saveProfilesRemovingOrphan(pc, path, name, orphan)
}
-// reconcileProfileEntry inherits the existing KeyringID on re-login and
-// returns the previous config as an orphan when a profile leaves the keyring.
+// reconcileProfileEntry preserves settings that auth login does not manage
+// and returns the previous config as an orphan when a profile leaves the keyring.
func reconcileProfileEntry(pc *ProfileConfig, name string, cfg *Config) (Config, *Config) {
entry := *cfg
old, had := pc.Profiles[name]
if !had {
return entry, nil
}
+ entry.ReadOnly = old.ReadOnly
if entry.CredentialStore == CredentialStoreKeyring && entry.KeyringID == "" && old.KeyringID != "" {
entry.KeyringID = old.KeyringID
}
@@ -586,6 +587,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..70825f3 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -167,6 +167,38 @@ func TestSaveProfile(t *testing.T) {
}
}
+func TestSaveProfileReloginPreservesReadOnly(t *testing.T) {
+ cfgPath := filepath.Join(t.TempDir(), "config.yaml")
+ first := &Config{
+ Server: "https://redmine.example.com",
+ AuthMethod: "apikey",
+ APIKey: "old-secret",
+ ReadOnly: true,
+ }
+ if err := SaveProfile("test", first, cfgPath); err != nil {
+ t.Fatal(err)
+ }
+
+ // auth login rebuilds the profile from authentication fields and leaves
+ // settings it does not manage at their zero values.
+ relogin := &Config{
+ Server: "https://redmine.example.com",
+ AuthMethod: "apikey",
+ APIKey: "new-secret",
+ }
+ if err := SaveProfile("test", relogin, cfgPath); err != nil {
+ t.Fatal(err)
+ }
+
+ pc, err := LoadProfiles(cfgPath, debug.New(nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !pc.Profiles["test"].ReadOnly {
+ t.Fatal("read_only was disabled on re-login")
+ }
+}
+
func TestDeleteProfile(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
content := `active_profile: a
@@ -622,3 +654,49 @@ func TestLoadProfilesTightensLegacyWorldReadableFile(t *testing.T) {
t.Errorf("config mode after load = %o, want 600 (should be tightened on read)", got)
}
}
+
+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 {
+ 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"`
}