Files
flan aaed22785b
Build and Package / Build Binaries (push) Has been cancelled
Add read-only mode and unconditional secret redaction
Upstream is unsafe to point at a NAS that hosts anything of consequence:

- system_reboot is registered with an empty input schema and a handler that
  calls system.reboot immediately, so a model can take the host down in one
  unconfirmed tool call. The README's claim of dry-run on 'all write
  operations' is not accurate: dry-run is opt-in per call, and
  ExecuteWithDryRun() falls through to real execution when the argument is
  omitted.
- get_app_config returns app.config verbatim, putting database passwords,
  encryption keys and API tokens into the model's context and into any
  transcript that persists it.

-read-only is a fail-closed allowlist: 31 non-mutating tools are served, the
other 21 are refused, and anything not explicitly reviewed -- including tools
upstream adds later -- is refused by default. A denylist would silently admit
the next system_reboot. Refused tools are hidden from tools/list and rejected
at dispatch.

Redaction is unconditional, read-write mode included. A credential has no
business reaching the model, and relying on the operator to field-filter is
not a control.

Tests assert the mutating-tool list against the live registry, so an upstream
rename breaks the build instead of quietly widening the boundary.
2026-07-12 21:45:32 +00:00

132 lines
3.6 KiB
Go

package tools
import (
"encoding/json"
"strings"
"testing"
)
// The tools that must never be reachable in read-only mode. system_reboot is the
// motivating case: upstream registers it with an empty input schema and a handler
// that calls system.reboot immediately, so a single tool call takes the host down.
var mustBeRefused = []string{
"system_reboot",
"apply_update",
"download_update",
"delete_app",
"delete_boot_environment",
"delete_scrub_schedule",
"install_app",
"update_app",
"upgrade_app",
"start_app",
"stop_app",
"create_dataset",
"create_nfs_share",
"create_smb_share",
"create_scrub_schedule",
"run_scrub",
"configure_directory_service",
"leave_directory_service",
"refresh_directory_cache",
"dismiss_alert",
"restore_alert",
}
func readOnlyRegistry(t *testing.T) *Registry {
t.Helper()
// A nil client is fine: every assertion below is refused before dispatch.
r := NewRegistry(nil, nil)
r.SetReadOnly(true)
return r
}
func TestReadOnlyRefusesMutatingTools(t *testing.T) {
r := readOnlyRegistry(t)
for _, name := range mustBeRefused {
if _, ok := r.tools[name]; !ok {
t.Fatalf("%s is not registered upstream any more — this test is stale", name)
}
if _, err := r.CallTool(name, map[string]interface{}{}); err == nil {
t.Errorf("CallTool(%q) succeeded in read-only mode; it must be refused", name)
}
}
}
func TestReadOnlyHidesMutatingToolsFromListing(t *testing.T) {
r := readOnlyRegistry(t)
listed := make(map[string]bool)
for _, tool := range r.ListTools() {
listed[tool.Name] = true
}
for _, name := range mustBeRefused {
if listed[name] {
t.Errorf("ListTools() advertises %q in read-only mode; the model must not see it", name)
}
}
if len(listed) == 0 {
t.Fatal("read-only mode listed no tools at all")
}
}
// Fail closed: a tool upstream adds later, that we have not reviewed, must be
// refused rather than silently served.
func TestReadOnlyIsFailClosedForUnknownTools(t *testing.T) {
r := readOnlyRegistry(t)
if r.allowed("some_tool_upstream_adds_next_year") {
t.Error("an unreviewed tool was allowed; the allowlist is not fail-closed")
}
}
func TestReadWriteModeAllowsEverything(t *testing.T) {
r := NewRegistry(nil, nil)
if !r.allowed("system_reboot") {
t.Error("read-only gating leaked into read-write mode")
}
if len(r.ListTools()) != r.ToolCount() {
t.Error("ListTools() filtered tools while not in read-only mode")
}
}
// A realistic app.config payload — this is the shape that has leaked before.
func TestRedactJSONMasksCredentials(t *testing.T) {
payload := `{
"app_name": "immich",
"config": {
"db_password": "hunter2",
"encryption_key": "aabbccdd",
"redis_password": "swordfish",
"API_TOKEN": "tok_live_123",
"postgres": {"POSTGRES_PASSWORD": "nested-secret"},
"env": [{"name": "X", "access_key": "AKIA..."}],
"port": 30041,
"enabled": true
}
}`
got := RedactJSON(payload)
for _, leaked := range []string{"hunter2", "aabbccdd", "swordfish", "tok_live_123", "nested-secret", "AKIA..."} {
if strings.Contains(got, leaked) {
t.Errorf("secret %q survived redaction", leaked)
}
}
// Non-secret fields must be preserved, or the tool is useless.
for _, keep := range []string{"immich", "30041"} {
if !strings.Contains(got, keep) {
t.Errorf("non-secret value %q was destroyed by redaction", keep)
}
}
var v interface{}
if err := json.Unmarshal([]byte(got), &v); err != nil {
t.Fatalf("redacted output is not valid JSON: %v", err)
}
}
func TestRedactJSONPassesThroughNonJSON(t *testing.T) {
const plain = "pool ok, 3 datasets"
if got := RedactJSON(plain); got != plain {
t.Errorf("non-JSON response was mangled: %q", got)
}
}