From 6eb4b8b0c4414dfa263a4b35f4e7e69d7bda943c Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 11:57:53 -0500 Subject: [PATCH 01/25] test(mcp): run_command boundary repros for resolved spellings and local paths Failing tests for five confirmed findings against `mcp serve` run_command: aliases and flag-first spellings of refused commands, `pro diff` against a foreign profile, local input-path flags, local output-path flags, and `jcds sync --dir --delete` on all three mounts. The dir-vs-dir diff test passes and guards what the fix must keep. Co-Authored-By: Claude Opus 5.5 --- .../commands/mcp_run_command_boundary_test.go | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 internal/commands/mcp_run_command_boundary_test.go diff --git a/internal/commands/mcp_run_command_boundary_test.go b/internal/commands/mcp_run_command_boundary_test.go new file mode 100644 index 00000000..cfa70a47 --- /dev/null +++ b/internal/commands/mcp_run_command_boundary_test.go @@ -0,0 +1,329 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Each argv is a spelling cobra resolves to a refused command: an alias, a +// persistent flag ahead of the command path, or a flag inside it. +var refusedCommandSpellings = []struct { + args []string + want string +}{ + {[]string{"cfg", "set-default", "x"}, "jamf-cli config set-default"}, + {[]string{"cfg", "add-profile"}, "jamf-cli config add-profile"}, + {[]string{"cfg", "remove-profile", "x"}, "jamf-cli config remove-profile"}, + {[]string{"cfg", "set-report-dir", "/x"}, "jamf-cli config set-report-dir"}, + {[]string{"--no-hints", "config", "set-default", "x"}, "jamf-cli config set-default"}, + {[]string{"--no-color", "multi", "--filter", "*", "--", "pro", "computers", "list"}, "jamf-cli multi"}, + {[]string{"-o", "json", "multi"}, "jamf-cli multi"}, + {[]string{"-q", "pro", "backup", "--output", "/x"}, "jamf-cli pro backup"}, + {[]string{"pro", "-q", "backup"}, "jamf-cli pro backup"}, + {[]string{"-v", "protect", "backup", "--output", "/x"}, "jamf-cli protect backup"}, +} + +func TestBuildChildArgs_RefusesEveryResolvedSpellingOfARefusedCommand(t *testing.T) { + root := NewRootCmd("test", "test", "test", "test") + for _, tc := range refusedCommandSpellings { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + found, _, err := root.Find(tc.args) + if err != nil || found.CommandPath() != tc.want { + t.Fatalf("precondition: cobra's Find resolves %q to %v (err %v), want %q", tc.args, found, err, tc.want) + } + traversed, _, err := root.Traverse(tc.args) + if err != nil || traversed.CommandPath() != tc.want { + t.Fatalf("precondition: cobra's Traverse resolves %q to %v (err %v), want %q", tc.args, traversed, err, tc.want) + } + if child, err := buildChildArgs("pinned", tc.args); err == nil { + t.Errorf("buildChildArgs accepted %q, which cobra runs as %q; child argv %q", tc.args, tc.want, child) + } + }) + } +} + +func TestRefuseReportThroughRunCommand_RefusesFlagFirstDashboard(t *testing.T) { + for _, args := range [][]string{ + {"--no-color", "dashboard"}, + {"-q", "db"}, + } { + if err := refuseReportThroughRunCommand(args); err == nil { + t.Errorf("refuseReportThroughRunCommand accepted %q, which cobra runs as `jamf-cli dashboard`", args) + } + } +} + +// `pro diff --source/--target` each take a backup directory or a config +// profile name, and a profile name makes the child resolve that profile's URL +// and credential. Over MCP only the pinned profile may be reached that way. + +func TestBuildChildArgs_RefusesDiffAgainstAForeignProfile(t *testing.T) { + refused := [][]string{ + {"pro", "diff", "--source", "/var/empty", "--target", "other"}, + {"pro", "diff", "--source", "prod", "--target", "other"}, + {"pro", "diff", "--source", "other", "--target", "./backup"}, + {"pro", "diff", "--source=/var/empty", "--target=other"}, + {"pro", "diff", "--target", "other", "--source", "prod", "--resources", "scripts"}, + } + for _, args := range refused { + if _, err := buildChildArgs("prod", args); err == nil { + t.Errorf("buildChildArgs(%q, %v) = nil error; a diff side naming a profile other than the pinned one must be refused", "prod", args) + } + } +} + +func TestBuildChildArgs_AllowsDiffWithinThePinnedProfile(t *testing.T) { + allowed := [][]string{ + {"pro", "diff", "--source", "/backups/a", "--target", "./backups/b"}, + {"pro", "diff", "--source", "prod", "--target", "/backups/a"}, + {"pro", "diff", "--source=~/backups/a", "--target=prod"}, + } + for _, args := range allowed { + if _, err := buildChildArgs("prod", args); err != nil { + t.Errorf("buildChildArgs(%q, %v) = %v; a diff between directories or against the pinned profile should be allowed", "prod", args, err) + } + } +} + +// A run_command child reads whatever local path the model names in an input +// flag, and -n / -vvv print the request body to stderr, which runChild returns +// as the tool result. The model must not obtain an arbitrary local file's bytes. + +const inputFileMarker = "F5-MARKER-a1b2c3-PRIVATE-KEY-BYTES" + +func TestBuildChildArgs_RefusesLocalInputPaths(t *testing.T) { + refused := [][]string{ + {"pro", "scripts", "create", "--script-file", "/etc/passwd", "-n"}, + {"pro", "scripts", "create", "--script-file=/etc/passwd"}, + {"pro", "classic-policies", "create", "--from-file", "/etc/passwd"}, + } + for _, args := range refused { + if _, err := buildChildArgs("", args); err == nil { + t.Errorf("buildChildArgs accepted %v; a local input path must be refused over MCP", args) + } + } +} + +func TestRunChild_DoesNotReturnLocalFileBytes(t *testing.T) { + if testing.Short() { + t.Skip("builds the jamf-cli binary") + } + + var mu sync.Mutex + var seen []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.Method+" "+r.URL.Path) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "token") { + _, _ = fmt.Fprint(w, `{"access_token":"fake","expires_in":3600,"token_type":"Bearer"}`) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, `{"id":"1","href":"x"}`) + })) + defer srv.Close() + + bin := filepath.Join(t.TempDir(), "jamf-cli") + build := exec.Command("go", "build", "-o", bin, "github.com/Jamf-Concepts/jamf-cli/cmd/jamf-cli") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + + secret := filepath.Join(t.TempDir(), "id_rsa") + if err := os.WriteFile(secret, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n"+inputFileMarker+"\n-----END OPENSSH PRIVATE KEY-----\n"), 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("JAMF_URL", srv.URL) + t.Setenv("JAMF_CLIENT_ID", "fakeid") + t.Setenv("JAMF_CLIENT_SECRET", "fakesecret") + t.Setenv("JAMF_PROFILE", "") + + cases := [][]string{ + {"pro", "scripts", "create", "--script-file", secret, "-n"}, + {"pro", "scripts", "create", "--script-file", secret, "-vvv"}, + {"pro", "classic-policies", "create", "--from-file", secret, "-n"}, + } + for _, args := range cases { + t.Run(strings.Join(args[len(args)-1:], ""), func(t *testing.T) { + mu.Lock() + seen = nil + mu.Unlock() + + res := runChild(context.Background(), bin, "", args) + text := mcpResultText(res) + + mu.Lock() + t.Logf("args=%v isError=%v server saw %d request(s): %v\n%s", args, res.IsError, len(seen), seen, text) + mu.Unlock() + + if strings.Contains(text, inputFileMarker) { + t.Errorf("run_command returned the local file's bytes to the model:\n%s", text) + } + }) + } +} + +// The path deliberately carries no 'p', so a short-token rule keyed on the +// --profile shorthand cannot mask the gap for an attached -O/path. +const modelChosenPath = "/Users/admin/.zshrc" + +func TestBuildChildArgs_RejectsLocalOutputPathFlags(t *testing.T) { + cases := [][]string{ + {"pro", "packages", "export", "--save-to", modelChosenPath}, + {"pro", "packages", "export", "--save-to=" + modelChosenPath}, + {"pro", "packages", "export", "-O", modelChosenPath}, + {"pro", "packages", "export", "-O" + modelChosenPath}, + {"pro", "scripts", "download", "1", "--save-to", modelChosenPath}, + {"protect", "downloads", "installer", "--output", modelChosenPath}, + {"protect", "downloads", "installer", "-O", modelChosenPath}, + {"protect", "plans", "config-profile", "x", "--output", modelChosenPath}, + {"pro", "jamf-cloud-distribution-service", "download", "f.pkg", "--output", modelChosenPath}, + } + for _, args := range cases { + if got, err := buildChildArgs("prod", args); err == nil { + t.Errorf("buildChildArgs(%q) accepted a model-chosen local output path; child argv: %q", args, got) + } + } +} + +// isLocalOutputPathFlag names every flag in the tree whose value is a path the +// command writes to (or, for sync --dir, writes into and may delete from). +func isLocalOutputPathFlag(f *pflag.Flag) bool { + switch f.Name { + case "save-to", "out-file": + return true + case "output": + return !strings.HasPrefix(strings.ToLower(f.Usage), "output format") + case "dir": + return strings.Contains(f.Usage, "sync into") + } + return false +} + +func TestBuildChildArgs_RejectsEveryLocalOutputPathFlagInTree(t *testing.T) { + root := NewRootCmd("test", "t", "t", "t") + checked := 0 + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + if c != root { + path := strings.Fields(strings.TrimPrefix(c.CommandPath(), root.Name()+" ")) + c.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { + if !isLocalOutputPathFlag(f) { + return + } + forms := [][]string{{"--" + f.Name, modelChosenPath}} + if f.Shorthand != "" { + forms = append(forms, []string{"-" + f.Shorthand, modelChosenPath}) + } + for _, form := range forms { + checked++ + args := append(append([]string{}, path...), form...) + if _, err := buildChildArgs("prod", args); err == nil { + t.Errorf("run_command would forward %q: %s writes to a model-chosen path", args, form[0]) + } + } + }) + } + for _, s := range c.Commands() { + walk(s) + } + } + walk(root) + if checked < 40 { + t.Fatalf("walk checked only %d output-path flag forms; the classifier has stopped matching the tree", checked) + } +} + +// jcds sync --dir is a model-chosen local directory, and --delete removes every +// file in it that the distribution point does not carry. +var jcdsSyncMounts = [][]string{ + {"pro", "jcds", "sync"}, + {"pro", "jamf-cloud-distribution-service", "sync"}, + {"pro", "packages", "sync"}, +} + +func TestBuildChildArgs_RefusesJcdsSyncLocalDestination(t *testing.T) { + for _, mount := range jcdsSyncMounts { + args := append(append([]string{}, mount...), "--dir", "/x", "--delete") + if got, err := buildChildArgs("prod", args); err == nil { + t.Errorf("%v was accepted as %v; --dir is a model-chosen local directory and --delete removes every file in it", args, got) + } + } +} + +func TestMCPChild_JcdsSyncDeleteLeavesOperatorFilesInPlace(t *testing.T) { + for _, mount := range jcdsSyncMounts { + t.Run(strings.Join(mount, " "), func(t *testing.T) { + isolated := t.TempDir() + victim := filepath.Join(isolated, "victim") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + names := []string{"notes.txt", "id_ed25519", "project.go"} + for _, n := range names { + if err := os.WriteFile(filepath.Join(victim, n), []byte("operator data"), 0o600); err != nil { + t.Fatal(err) + } + } + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/jcds/files", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]jcdsFileData{}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + resetGlobals() + t.Setenv("HOME", isolated) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, "config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(isolated, "cache")) + t.Setenv("JAMF_PROFILE", "") + t.Setenv("JAMF_URL", srv.URL) + t.Setenv("JAMF_TOKEN", "fake-token") + t.Setenv("JAMF_CLIENT_ID", "") + t.Setenv("JAMF_CLIENT_SECRET", "") + t.Setenv("JAMF_CLI_ARGS", "") + + args := append(append([]string{}, mount...), "--dir", victim, "--delete") + childArgs, err := buildChildArgs("", args) + if err != nil { + return + } + + root := NewRootCmd("test", "abc123", "2024-01-01", "unknown") + root.SetArgs(childArgs) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + runErr := root.Execute() + + var gone []string + for _, n := range names { + if _, err := os.Stat(filepath.Join(victim, n)); os.IsNotExist(err) { + gone = append(gone, n) + } + } + if len(gone) > 0 { + t.Errorf("MCP child %v (exit err: %v) deleted operator files %v with no confirmation", childArgs, runErr, gone) + } + }) + } +} From 1e6cdad424abac0491c30b219efdbe9d7fa75a72 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 12:21:27 -0500 Subject: [PATCH 02/25] fix(mcp): judge run_command on the command and flags cobra resolves run_command matched refused commands as a literal prefix of the model's argv and refused flags token by token. An alias (`cfg set-default`, `pro jcds sync`, `db`), a persistent flag ahead of the command path (`-q pro backup`), a `pro diff` side naming another profile, and every local input or output path flag all reached the child. buildChildArgs now resolves the child argv the way ExecuteC does (Find, then ParseAll with a callback that never calls Set). It judges the resolved CommandPath against mcpRefusedCommands: multi, mcp, the config writers, every setup, both backups, and jcds sync on both mounts. It judges each resolved flag against the credential prefixes and the local-path classification, where --dir is classified per command. `pro diff --source/--target` must be a directory or the pinned profile. Cobra's __complete is refused by name, because it is added only when invoked and parses no flags of its own. The resolve runs against the tree `mcp serve` is executing, installed at startup, and holds a lock for its whole length. Building a fresh NewRootCmd per call rebound every root flag variable in the serving process to its default, and main's error path reads those after serve returns. Tool calls run concurrently. The child repeats the check on its own parse in PersistentPreRunE when JAMF_CLI_MCP=1, against the pinned profile in JAMF_CLI_MCP_PROFILE, which childEnv strips from the inherited environment. Tree-walk tests fail on a stale refused entry, an unrefused setup, a flag-parsing-disabled command that calls an API, and any unclassified path-shaped flag. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/where-to-make-changes/SKILL.md | 2 +- internal/commands/agent_context.md | 11 +- internal/commands/mcp.go | 352 +++++++++++++----- internal/commands/mcp_resolver_state_test.go | 74 ++++ .../commands/mcp_run_command_guard_test.go | 256 +++++++++++++ internal/commands/root.go | 5 + 6 files changed, 612 insertions(+), 88 deletions(-) create mode 100644 internal/commands/mcp_resolver_state_test.go create mode 100644 internal/commands/mcp_run_command_guard_test.go diff --git a/.claude/skills/where-to-make-changes/SKILL.md b/.claude/skills/where-to-make-changes/SKILL.md index b9a65234..94c75ac0 100644 --- a/.claude/skills/where-to-make-changes/SKILL.md +++ b/.claude/skills/where-to-make-changes/SKILL.md @@ -146,5 +146,5 @@ description: Use when you know what to change but not where — a lookup table m | Change the dashboard's exit code or banner | `finishDashboard` (`internal/commands/dashboard.go`) — the seam both are tested through | | Change what each dashboard tier costs, or where a collector lives | `collectProDataFast` / `collectProDataFull` (`internal/commands/dashboard_pro.go`), and `fixedCostAuditChecks` / `fleetScaledAuditChecks` (`internal/commands/pro_audit.go`). State the cost through `dashboardCostNote` (`dashboard.go`) — it is the single source every surface quotes | | Change which objects a category's item count covers | `classicCategorySources` / `proCategorySources` (`internal/commands/dashboard_pro_categories.go`) | -| Change what an MCP child may not do | `blockedChildFlagPrefixes` / `blockedChildCommandPaths` / `refuseReportThroughRunCommand` (`internal/commands/mcp.go`) — prefix-matched, and the blocked set is swept from the assembled tree by a test | +| Change what an MCP child may not do | `mcpRefusedCommands` / `mcpLocalPathFlags` / `mcpDirFlags` / `blockedChildFlagPrefixes` / `refuseReportThroughRunCommand` (`internal/commands/mcp.go`) — judged on the command and flags cobra resolves, server-side in `buildChildArgs` and again in the child by `refuseInMCPChild`; tree-walk tests fail on an unclassified path-shaped flag or a stale entry | | Change what the MCP report tool returns | `runReportChild` (`internal/commands/mcp.go`) — exit 7 keeps the file, anything else removes it | diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index eedaddbd..036dbf17 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -103,10 +103,13 @@ tools: directory `jamf-cli config set-report-dir` designates, and return its path and size. Never the HTML. -The server is pinned to the profile it was launched with; per-command -credential- and target-selecting flags are rejected, as are `multi`, the config -write subcommands and the two `backup` commands, which choose their own target -or destination. +The server is pinned to the profile it was launched with, and `run_command` is +judged on the command and flags your arguments resolve to, aliases included. +Rejected: credential- and target-selecting flags; flags whose value is a local +file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, +`--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, the +config write subcommands, every `setup`, both `backup` commands and jcds `sync`; +and `pro diff` against any profile other than the pinned one. **`dashboard` output belongs in a file, not a tool result.** The command writes a 320–800 KB HTML document to stdout (80k–200k tokens), so `run_command` refuses diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 66f8abf6..8879d741 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -11,11 +11,14 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" + "sync" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/Jamf-Concepts/jamf-cli/internal/config" "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" @@ -85,11 +88,22 @@ fast one first and ask before the full one. So an administrator should expect a question about a "full report" rather than a long silence; what each tier costs is in 'jamf-cli dashboard --help'. -run_command refuses anything that picks its own instance or destination: any -flag naming a profile, URL, token, tenant, environment or output file, plus -'multi', the config write subcommands and the two 'backup' commands. It also -refuses 'dashboard', because it returns a command's stdout as text and the -report is a 320-800 KB document — generate_report writes that to a file +run_command judges the command and flags cobra resolves the arguments to, so +an alias or a flag ahead of the command path is judged the same as the plain +spelling. It refuses: + - 'multi', 'mcp', shell completion, the config write subcommands, every + 'setup', both 'backup' commands and 'jamf-cloud-distribution-service sync' + (also mounted as 'packages sync'), which pick their own instance or write + where the model says + - any flag naming a profile, URL, token, tenant, environment or output file + - any flag whose value is a local path: --from-file, --file, --script-file, + --mobileconfig-file, --appconfig-file, --custom-payload-file, --body-file, + --input, --password-file, --dir, --save-to, --report-dir, and a command's + own --output (the global -o/--output format flag stays available) + - 'pro diff' with a --source or --target that is neither a directory nor + this server's profile +It also refuses 'dashboard', because it returns a command's stdout as text and +the report is a 320-800 KB document — generate_report writes that to a file instead.`, Args: refuseStrayPositionals, RunE: func(cmd *cobra.Command, _ []string) error { @@ -111,6 +125,7 @@ instead.`, if !noHints { printMCPStartupHints(cmd.ErrOrStderr(), cfg) } + installMCPResolver(cmd.Root()) server := mcp.NewServer(&mcp.Implementation{ Name: "jamf-cli", @@ -144,10 +159,13 @@ instead.`, "args array, e.g. [\"pro\",\"computers\",\"list\"] or " + "[\"pro\",\"policies\",\"get\",\"--name\",\"My Policy\"]. Output defaults to " + "JSON. Do not include credentials. The server is pinned to the profile it " + - "was started with, so any flag naming a profile, URL, token, tenant, " + - "environment or output file is rejected, as are 'multi', the config write " + - "subcommands and the backup commands, which choose their own target or " + - "destination. Use generate_report rather than 'dashboard': this tool returns " + + "was started with and judges the command your args resolve to, aliases " + + "included. Rejected: any flag naming a profile, URL, token, tenant, " + + "environment or output file; any flag whose value is a local file or " + + "directory (--from-file, --file, --script-file, --save-to, --dir and the " + + "like; the -o/--output format flag is fine); 'multi', 'mcp', the config " + + "write subcommands, every 'setup', the backup commands and jcds sync; and " + + "'pro diff' against any profile but this server's. Use generate_report rather than 'dashboard': this tool returns " + "stdout as text and the dashboard writes a 320-800 KB HTML document there. " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + "require an explicit --yes in args or they will refuse to run.", @@ -222,17 +240,29 @@ type generateReportInput struct { // the model was told the report had been written. The server builds this argv; // nothing may be prepended to it. // -// JAMF_CLI_MCP=1 is appended so the child knows it is an MCP child. +// JAMF_CLI_MCP=1 is appended so the child knows it is an MCP child, and an +// inherited JAMF_CLI_MCP_PROFILE is dropped so only pinnedChildEnv sets it. func childEnv() []string { env := os.Environ() kept := make([]string, 0, len(env)+1) for _, kv := range env { - if name, _, ok := strings.Cut(kv, "="); ok && name == "JAMF_CLI_ARGS" { + if name, _, ok := strings.Cut(kv, "="); ok && (name == "JAMF_CLI_ARGS" || name == mcpPinnedProfileEnvVar) { continue } kept = append(kept, kv) } - return append(kept, "JAMF_CLI_MCP=1") + return append(kept, mcpChildEnvVar+"=1") +} + +const ( + mcpChildEnvVar = "JAMF_CLI_MCP" + mcpPinnedProfileEnvVar = "JAMF_CLI_MCP_PROFILE" +) + +// pinnedChildEnv is childEnv plus the profile the server is pinned to, which +// refuseInMCPChild compares the child's own parse against. +func pinnedChildEnv(serverProfile string) []string { + return append(childEnv(), mcpPinnedProfileEnvVar+"="+serverProfile) } type listCommandsInput struct { @@ -274,7 +304,7 @@ func listCommands(ctx context.Context, executable, serverProfile string, in list var stderr bytes.Buffer child := exec.CommandContext(ctx, executable, childArgs...) - child.Env = childEnv() + child.Env = pinnedChildEnv(serverProfile) child.Stderr = &stderr out, err := child.Output() if err != nil { @@ -332,7 +362,7 @@ func runChild(ctx context.Context, executable, serverProfile string, args []stri } child := exec.CommandContext(ctx, executable, childArgs...) - child.Env = childEnv() + child.Env = pinnedChildEnv(serverProfile) out, err := child.CombinedOutput() text := capChildOutput(out) @@ -400,9 +430,9 @@ func errorResult(text string) *mcp.CallToolResult { // mutually-exclusive gateway scope selectors and both redirect the request, so // blocking one without the other leaves the hole open. // -// A deny-list cannot be complete — 362 commands declare --from-file alone — so -// this is a floor rather than the boundary. blockedChildCommandPaths carries -// the namespaces no flag list can pin. +// A deny-list cannot be complete, so this is a floor rather than the boundary: +// mcpRefusedCommands carries the namespaces no flag list can pin, and +// mcpLocalPathFlags the flags whose value is a path on this machine. var blockedChildFlagPrefixes = []string{ "--profile", "--include-profile", @@ -413,38 +443,212 @@ var blockedChildFlagPrefixes = []string{ "--out-file", } -// blockedChildCommandPaths are command paths a model must not run, as -// space-joined prefixes of the argument list. +// mcpRefusedCommands are resolved command paths, each refused with everything +// beneath it, that choose their own instance, credential or local destination. +// `dashboard` is not here: generate_report shares buildChildArgs and must still +// spawn it, so the run_command handler refuses it instead. +var mcpRefusedCommands = []string{ + "jamf-cli multi", + "jamf-cli mcp", + "jamf-cli config set-default", + "jamf-cli config add-profile", + "jamf-cli config remove-profile", + "jamf-cli config set-report-dir", + "jamf-cli pro setup", + "jamf-cli platform setup", + "jamf-cli protect setup", + "jamf-cli school setup", + "jamf-cli security setup", + "jamf-cli pro backup", + "jamf-cli protect backup", + "jamf-cli pro jamf-cloud-distribution-service sync", + "jamf-cli pro packages sync", +} + +// isCompletionRequest reports whether name is cobra's hidden completion +// command, which parses no flags of its own and runs the target command's +// completion functions. Cobra adds it only when it is invoked, so a resolve +// against the tree cannot find it and it is matched by name instead. +func isCompletionRequest(name string) bool { + return name == cobra.ShellCompRequestCmd || name == cobra.ShellCompNoDescRequestCmd +} + +// localPathUse is what a command does with a flag whose value is a path on the +// machine running the server. +type localPathUse string + +const ( + pathRead localPathUse = "reads" + pathCredential localPathUse = "reads a credential from" + pathWrite localPathUse = "writes" +) + +// mcpLocalPathFlags classifies path flags by name. `output` reaches here only +// where a leaf declares its own; the root's persistent --output is the format +// selector. +var mcpLocalPathFlags = map[string]localPathUse{ + "from-file": pathRead, + "file": pathRead, + "script-file": pathRead, + "mobileconfig-file": pathRead, + "appconfig-file": pathRead, + "custom-payload-file": pathRead, + "body-file": pathRead, + "input": pathRead, + "password-file": pathCredential, + "save-to": pathWrite, + "output": pathWrite, + "report-dir": pathWrite, +} + +// mcpDirFlags classifies --dir per command, since it is an input on some and a +// destination that `--delete` empties on others. +var mcpDirFlags = map[string]localPathUse{ + "jamf-cli protect analytics import": pathRead, + "jamf-cli protect unified-logging-filters import": pathRead, + "jamf-cli pro jamf-cloud-distribution-service sync": pathWrite, + "jamf-cli pro packages sync": pathWrite, +} + +// localPathFlagUse classifies one flag occurrence on the command at path. A +// --dir on a command mcpDirFlags does not name is taken as a destination. +func localPathFlagUse(path string, s flagSetting) (localPathUse, bool) { + if s.rootOutput { + return "", false + } + if s.name == "dir" { + if use, ok := mcpDirFlags[path]; ok { + return use, true + } + return pathWrite, true + } + use, ok := mcpLocalPathFlags[s.name] + return use, ok +} + +const ( + proDiffPath = "jamf-cli pro diff" + dashboardPath = "jamf-cli dashboard" +) + +// childInvocation is what cobra resolves a child argv to. An empty path means +// cobra refuses the argv before running anything. +type childInvocation struct { + path string + settings []flagSetting +} + +// flagSetting is one occurrence of a flag on the command line, in order. A +// repeatable flag set twice is two settings. +type flagSetting struct { + name, value string + rootOutput bool +} + +// mcpResolver is the tree run_command argv is resolved against. Every resolve +// holds the lock for its whole length: Find merges persistent flags into the +// leaf, and ParseAll records its arguments on the leaf's flag set. +var mcpResolver struct { + sync.Mutex + root *cobra.Command +} + +// installMCPResolver makes later resolves use root. `mcp serve` installs the +// tree it is running in, because NewRootCmd rebinds every flag variable in this +// package to its default, and main reads those after serve returns. +func installMCPResolver(root *cobra.Command) { + mcpResolver.Lock() + defer mcpResolver.Unlock() + mcpResolver.root = root +} + +// resolveChildInvocation returns what a child process given args would run. // -// These resolve their own target or destination, so no flag list can pin them: -// `multi` takes its own --profiles and fans out across instances, and the -// config write subcommands persist a new default profile, a new credential or a -// new report directory to disk — after which every later child is pointed -// somewhere the operator never chose. `dashboard` is refused on the -// run_command path only, by the tool handler, because generate_report shares -// buildChildArgs and must still be able to spawn it. -var blockedChildCommandPaths = [][]string{ - {"multi"}, - {"config", "set-default"}, - {"config", "add-profile"}, - {"config", "remove-profile"}, - {"config", "set-report-dir"}, - // Both backup commands take --output as a destination *directory* rather - // than an output format, so they write a tree wherever the model says. - {"pro", "backup"}, - {"protect", "backup"}, +// It mirrors ExecuteC: Find, not Traverse, since the root does not set +// TraverseChildren. ParseAll hands each occurrence to a callback and never +// calls Set, so no flag variable changes. A parse error ends the list where the +// child's own parse stops, and the child's FlagErrorFunc always returns an +// error, so nothing after it can run. +func resolveChildInvocation(args []string) childInvocation { + mcpResolver.Lock() + defer mcpResolver.Unlock() + root := mcpResolver.root + if root == nil { + root = NewRootCmd(cliVersion, "", "", "") + root.InitDefaultHelpCmd() + root.InitDefaultCompletionCmd() + root.InitDefaultVersionFlag() + } + + cmd, rest, err := root.Find(args) + if err != nil { + return childInvocation{} + } + inv := childInvocation{path: cmd.CommandPath()} + if cmd.DisableFlagParsing { + return inv + } + cmd.InitDefaultHelpFlag() + rootOutput := root.PersistentFlags().Lookup("output") + _ = cmd.Flags().ParseAll(rest, func(f *pflag.Flag, value string) error { + inv.settings = append(inv.settings, flagSetting{name: f.Name, value: value, rootOutput: f == rootOutput}) + return nil + }) + return inv } -func isBlockedChildFlag(arg string) bool { - // Short-flag form (single dash, not "--"): pflag accepts the --profile - // shorthand -p attached (-pProd) or clustered after value-less bool - // shorthands (-np Prod), so any short token carrying 'p' can set the - // profile. Reject them all — 'p' is the only sensitive shorthand and no - // other global shorthand uses it. A rare false positive (e.g. -oplain) - // fails closed; the model can fall back to "-o plain". - if len(arg) >= 2 && arg[0] == '-' && arg[1] != '-' && strings.ContainsRune(arg, 'p') { - return true +// refuseOverMCP returns why inv is not available to an MCP client pinned to +// pinnedProfile, or nil. +func refuseOverMCP(inv childInvocation, pinnedProfile string) error { + for _, refused := range mcpRefusedCommands { + if inv.path == refused || strings.HasPrefix(inv.path, refused+" ") { + return fmt.Errorf("command %q is not available over MCP: it selects its own instance or writes to a path of its own, so the profile this server was started with cannot pin it", + strings.TrimPrefix(inv.path, "jamf-cli ")) + } + } + for _, s := range inv.settings { + if isBlockedChildFlag("--" + s.name) { + return fmt.Errorf("flag %q is not allowed: the MCP server is pinned to the configuration it was started with; the target instance, credentials, and output destination cannot be overridden per command", "--"+s.name) + } + if use, ok := localPathFlagUse(inv.path, s); ok { + return fmt.Errorf("flag --%s is not available over MCP: it %s a path on the machine running this server, which the connecting model must not choose", s.name, use) + } + if inv.path == proDiffPath && (s.name == "source" || s.name == "target") && !isDirectoryPath(s.value) && + (pinnedProfile == "" || s.value != pinnedProfile) { + if pinnedProfile == "" { + return fmt.Errorf("pro diff --%s %q names a config profile, and this server was started without one: over MCP each side must be a backup directory", s.name, s.value) + } + return fmt.Errorf("pro diff --%s %q names a config profile other than %q, the one this server is pinned to: over MCP each side must be a backup directory or that profile", s.name, s.value, pinnedProfile) + } + } + return nil +} + +// refuseInMCPChild applies refuseOverMCP to the command this process parsed, +// when it was spawned by `mcp serve`. The server's own --profile is not a +// setting the model made, so it is skipped when it names the pinned profile. +func refuseInMCPChild(cmd *cobra.Command) error { + if os.Getenv(mcpChildEnvVar) != "1" { + return nil } + if isCompletionRequest(cmd.Name()) { + return errCompletionOverMCP + } + pinned := os.Getenv(mcpPinnedProfileEnvVar) + inv := childInvocation{path: cmd.CommandPath()} + rootOutput := cmd.Root().PersistentFlags().Lookup("output") + cmd.Flags().Visit(func(f *pflag.Flag) { + if f.Name == "profile" && pinned != "" && f.Value.String() == pinned { + return + } + inv.settings = append(inv.settings, flagSetting{name: f.Name, value: f.Value.String(), rootOutput: f == rootOutput}) + }) + return refuseOverMCP(inv, pinned) +} + +var errCompletionOverMCP = errors.New("shell completion is not available over MCP: it runs another command's completion without the flag checks every command gets; use list_commands or --help instead") + +func isBlockedChildFlag(arg string) bool { if !strings.HasPrefix(arg, "--") { return false } @@ -458,49 +662,23 @@ func isBlockedChildFlag(arg string) bool { return false } -// blockedChildCommand returns the refused command path when args begins with -// one, or nil. Positional matching only: a flag cannot name a command, and the -// first tokens of a jamf-cli invocation are its command path. -func blockedChildCommand(args []string) []string { - for _, path := range blockedChildCommandPaths { - if len(args) < len(path) { - continue - } - match := true - for i, seg := range path { - if args[i] != seg { - match = false - break - } - } - if match { - return path - } - } - return nil -} - // buildChildArgs validates a model-supplied command and returns the full -// argument list for the child invocation. It rejects empty input and any -// instance-, credential-, or output-redirecting flag (see blockedChildFlags), -// drops any model-supplied --no-input, then injects the server's pinned profile -// and an enforced --no-input the model cannot disable. +// argument list for the child invocation: the server's pinned profile and an +// enforced --no-input, then the model's args with any --no-input of its own +// dropped. The refusal is judged on what cobra resolves that argv to. func buildChildArgs(serverProfile string, args []string) ([]string, error) { if len(args) == 0 { return nil, errors.New("args must not be empty; provide a command such as [\"pro\",\"computers\",\"list\"]") } - if path := blockedChildCommand(args); path != nil { - return nil, fmt.Errorf("command %q is not available over MCP: it selects its own instance or writes to a path of its own, so the profile this server was started with cannot pin it", strings.Join(path, " ")) - } - for _, a := range args { - if isBlockedChildFlag(a) { - return nil, fmt.Errorf("flag %q is not allowed: the MCP server is pinned to the configuration it was started with; the target instance, credentials, and output destination cannot be overridden per command", a) - } + if slices.ContainsFunc(args, isCompletionRequest) { + return nil, errCompletionOverMCP } childArgs := make([]string, 0, len(args)+3) + injected := 1 if serverProfile != "" { childArgs = append(childArgs, "--profile", serverProfile) + injected++ } // Enforce --no-input: inject our own and drop any the model supplied, so it // cannot re-enable prompting (e.g. --no-input=false) in a child that has no @@ -512,6 +690,17 @@ func buildChildArgs(serverProfile string, args []string) ([]string, error) { } childArgs = append(childArgs, a) } + + inv := resolveChildInvocation(childArgs) + if inv.path == "" { + return childArgs, nil + } + // The injected flags lead the argv, so they are the first settings; a + // model-supplied --profile naming the pinned profile is still refused. + inv.settings = inv.settings[min(injected, len(inv.settings)):] + if err := refuseOverMCP(inv, serverProfile); err != nil { + return nil, err + } return childArgs, nil } @@ -659,10 +848,7 @@ func buildReportArgs(in generateReportInput) ([]string, error) { // Refused here rather than in buildChildArgs, which runReportChild shares and // which must still be able to spawn it. func refuseReportThroughRunCommand(args []string) error { - if len(args) == 0 { - return nil - } - if args[0] != "dashboard" && args[0] != "db" { + if resolveChildInvocation(args).path != dashboardPath { return nil } return errors.New("use the generate_report tool for HTML reports: run_command returns stdout as tool text, " + @@ -751,7 +937,7 @@ func runReportChild(ctx context.Context, executable, serverProfile string, in ge var stderr bytes.Buffer child := exec.CommandContext(ctx, executable, childArgs...) - child.Env = childEnv() + child.Env = pinnedChildEnv(serverProfile) child.Stdout = f child.Stderr = &stderr diff --git a/internal/commands/mcp_resolver_state_test.go b/internal/commands/mcp_resolver_state_test.go new file mode 100644 index 00000000..28f07ef9 --- /dev/null +++ b/internal/commands/mcp_resolver_state_test.go @@ -0,0 +1,74 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "fmt" + "sync" + "testing" +) + +// rootFlagState is every root-bound package var, which main's error path reads +// after `mcp serve` returns. +type rootFlagState struct { + profile, outputFmt, outFile, fieldName, serverURL, tokenFile, tenantID, environmentID, cliVersion string + quiet, noHints, noInput, noColor, dryRun, wide, compact, allowPartialFailure, noVersionCheck, noUpdateCheck bool + verboseLevel int +} + +func snapshotRootFlagState() rootFlagState { + return rootFlagState{ + profile: profile, outputFmt: outputFmt, outFile: outFile, fieldName: fieldName, serverURL: serverURL, + tokenFile: tokenFile, tenantID: tenantID, environmentID: environmentID, cliVersion: cliVersion, + quiet: quiet, noHints: noHints, noInput: noInput, noColor: noColor, dryRun: dryRun, wide: wide, + compact: compact, allowPartialFailure: allowPartialFailure, noVersionCheck: noVersionCheck, + noUpdateCheck: noUpdateCheck, verboseLevel: verboseLevel, + } +} + +func setRootFlagSentinels() { + profile, outputFmt, outFile, fieldName, serverURL = "sentinel-profile", "yaml", "/sentinel/out", "sentinel", "https://sentinel.example" + tokenFile, tenantID, environmentID, cliVersion = "/sentinel/token", "sentinel-tenant", "sentinel-env", "sentinel-version" + quiet, noHints, noInput, noColor, dryRun, wide, compact = true, true, true, true, true, true, true + allowPartialFailure, noVersionCheck, noUpdateCheck, verboseLevel = true, true, true, 3 +} + +var concurrentRunCommandArgs = [][]string{ + {"pro", "computers", "list"}, + {"cfg", "set-default", "x"}, + {"-q", "pro", "backup", "--output", "/x"}, + {"pro", "scripts", "create", "--script-file", "/etc/passwd"}, + {"pro", "diff", "--source", "/a", "--target", "other"}, + {"-o", "json", "pro", "computers", "list", "-vvv", "-n"}, + {"-q", "db"}, + {"pro", "packages", "sync", "--dir", "/x", "--delete"}, +} + +// Resolving a run_command argv inside `mcp serve` must not rebind a root flag +// var: tool calls run concurrently, and main reads these after serve returns. +func TestResolveChildInvocation_LeavesTheServingProcessFlagStateAlone(t *testing.T) { + root := NewRootCmd("test", "t", "t", "t") + t.Cleanup(resetGlobals) + installMCPResolver(root) + t.Cleanup(func() { installMCPResolver(nil) }) + + setRootFlagSentinels() + want := snapshotRootFlagState() + + var wg sync.WaitGroup + for i := range 32 { + wg.Add(1) + go func() { + defer wg.Done() + args := concurrentRunCommandArgs[i%len(concurrentRunCommandArgs)] + _, _ = buildChildArgs("prod", args) + _ = refuseReportThroughRunCommand(args) + }() + } + wg.Wait() + + if got := snapshotRootFlagState(); got != want { + t.Errorf("resolving run_command argv changed the serving process's flag state:\n got %s\nwant %s", + fmt.Sprintf("%+v", got), fmt.Sprintf("%+v", want)) + } +} diff --git a/internal/commands/mcp_run_command_guard_test.go b/internal/commands/mcp_run_command_guard_test.go new file mode 100644 index 00000000..ad74e4ef --- /dev/null +++ b/internal/commands/mcp_run_command_guard_test.go @@ -0,0 +1,256 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func TestMCPRefusedCommands_EveryEntryNamesACommandInTheTree(t *testing.T) { + root := NewRootCmd("test", "t", "t", "t") + for _, path := range mcpRefusedCommands { + args := strings.Fields(strings.TrimPrefix(path, root.Name()+" ")) + found, _, err := root.Find(args) + if err != nil || found.CommandPath() != path { + t.Errorf("refused command %q resolves to %v (err %v); a stale entry refuses nothing", path, found, err) + } + } + for path := range mcpDirFlags { + args := strings.Fields(strings.TrimPrefix(path, root.Name()+" ")) + found, _, err := root.Find(args) + if err != nil || found.CommandPath() != path || found.LocalNonPersistentFlags().Lookup("dir") == nil { + t.Errorf("mcpDirFlags names %q, which does not resolve to a command declaring --dir (got %v, err %v)", path, found, err) + } + } +} + +func TestMCPRefusedCommands_CoverEverySetupCommand(t *testing.T) { + root := NewRootCmd("test", "t", "t", "t") + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + if c.Name() == "setup" { + if err := refuseOverMCP(childInvocation{path: c.CommandPath()}, "prod"); err == nil { + t.Errorf("%q writes configuration and is not refused over MCP; add it to mcpRefusedCommands", c.CommandPath()) + } + } + for _, s := range c.Commands() { + walk(s) + } + } + walk(root) +} + +// A command that parses no flags gets no flag check from run_command, so only +// a stub that makes no request may be one. +func TestMCPRefusedCommands_EveryUnparsedCommandIsANoAuthStub(t *testing.T) { + root := NewRootCmd("test", "t", "t", "t") + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + if c.DisableFlagParsing && c.Annotations[noAuthAnnotation] != "true" && + refuseOverMCP(childInvocation{path: c.CommandPath()}, "prod") == nil { + t.Errorf("%q parses no flags, calls an API, and is not refused over MCP", c.CommandPath()) + } + for _, s := range c.Commands() { + walk(s) + } + } + walk(root) +} + +func TestBuildChildArgs_RefusesShellCompletion(t *testing.T) { + for _, args := range [][]string{ + {"__complete", "--profile", "other", "pro", "computers", "get", ""}, + {"-q", "__completeNoDesc", "pro", "diff", "--source", "other", ""}, + } { + if got, err := buildChildArgs("prod", args); err == nil { + t.Errorf("buildChildArgs(%q) = %q; cobra's completion command parses no flags of its own", args, got) + } + } +} + +// isPathShapedFlag is the naming a flag whose value is a local path tends to +// take; each one must be classified before it ships. +func isPathShapedFlag(root *cobra.Command, f *pflag.Flag) bool { + n := f.Name + switch { + case n == "file", n == "dir", n == "save-to", n == "input": + return true + case strings.HasSuffix(n, "-file"), strings.HasSuffix(n, "-files"), strings.HasSuffix(n, "-dir"): + return true + case n == "output": + return f != root.PersistentFlags().Lookup("output") + } + return false +} + +func TestMCPLocalPathFlags_EveryPathShapedFlagIsRefused(t *testing.T) { + root := NewRootCmd("test", "t", "t", "t") + used := map[string]bool{} + checked := 0 + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + path := strings.Fields(strings.TrimPrefix(c.CommandPath(), root.Name())) + c.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { + _, byName := mcpLocalPathFlags[f.Name] + _, byCommand := mcpDirFlags[c.CommandPath()] + switch { + case f.Name == "dir" && byCommand, f.Name != "dir" && byName: + used[f.Name] = true + case !isPathShapedFlag(root, f) || isBlockedChildFlag("--"+f.Name): + return + default: + t.Errorf("--%s on %q looks like a local path and is not classified; add it to mcpLocalPathFlags or mcpDirFlags", f.Name, c.CommandPath()) + return + } + forms := [][]string{{"--" + f.Name, modelChosenPath}, {"--" + f.Name + "=" + modelChosenPath}} + if f.Shorthand != "" { + forms = append(forms, []string{"-" + f.Shorthand + modelChosenPath}) + } + for _, form := range forms { + checked++ + args := append(append([]string{}, path...), form...) + if _, err := buildChildArgs("prod", args); err == nil { + t.Errorf("run_command would forward %q", args) + } + } + }) + for _, s := range c.Commands() { + walk(s) + } + } + walk(root) + for name := range mcpLocalPathFlags { + if !used[name] { + t.Errorf("mcpLocalPathFlags names --%s, which no command declares; remove the stale entry", name) + } + } + if checked < 400 { + t.Fatalf("walk checked only %d path-flag forms; it has stopped matching the tree", checked) + } +} + +func TestBuildChildArgs_RefusesEverySpellingOfAnotherProfile(t *testing.T) { + for _, args := range [][]string{ + {"-pother", "pro", "computers", "list"}, + {"pro", "computers", "list", "-np", "other"}, + {"pro", "computers", "list", "-qp", "other"}, + {"--profile=other", "pro", "computers", "list"}, + {"--profile", "prod", "pro", "computers", "list"}, + } { + if got, err := buildChildArgs("prod", args); err == nil { + t.Errorf("buildChildArgs(%q) = %q; a model-supplied --profile must be refused, even one naming the pinned profile", args, got) + } + } + // Ahead of the command path a clustered -p cannot take the next token: + // cobra reads it as a command name and the child exits before running. + front := []string{"--profile", "prod", "--no-input", "-np", "other", "pro", "computers", "list"} + if _, _, err := NewRootCmd("test", "t", "t", "t").Find(front); err == nil { + t.Errorf("cobra resolves %q; it must refuse it as an unknown command", front) + } +} + +func TestBuildChildArgs_KeepsDryRunVerboseHelpAndFormat(t *testing.T) { + allowed := [][]string{ + {"pro", "classic-policies", "create", "--set", "general.name=x", "--dry-run"}, + {"pro", "classic-policies", "create", "--set", "general.name=x", "-n"}, + {"pro", "computers", "list", "-vvv"}, + {"pro", "computers", "list", "-v"}, + {"pro", "computers", "list", "--help"}, + {"pro", "computers", "list", "-oplain"}, + {"-o", "json", "pro", "computers", "list"}, + {"commands", "-o", "json"}, + {"help", "config", "set-default"}, + {"config", "list"}, + {"pro", "report", "software-installs", "--path"}, + } + for _, args := range allowed { + if _, err := buildChildArgs("prod", args); err != nil { + t.Errorf("buildChildArgs(%q) = %v; this form must stay available over MCP", args, err) + } + } +} + +func TestPinnedChildEnv_ReplacesAnInheritedPin(t *testing.T) { + t.Setenv(mcpPinnedProfileEnvVar, "attacker") + env := strings.Join(pinnedChildEnv("prod"), "\n") + if strings.Contains(env, mcpPinnedProfileEnvVar+"=attacker") { + t.Error("an inherited pin must not reach the child") + } + if !strings.Contains(env, mcpPinnedProfileEnvVar+"=prod") { + t.Errorf("the child must be told the pinned profile: %s", env) + } +} + +// isMCPRefusal matches the refusal wording itself, since a temp path in an +// unrelated error carries the test's name. +func isMCPRefusal(err error) bool { + return err != nil && (strings.Contains(err.Error(), "over MCP") || strings.Contains(err.Error(), "the MCP server is pinned")) +} + +// executeAsMCPChild runs args in-process as a child spawned by `mcp serve` +// pinned to pinned, without going through buildChildArgs. +func executeAsMCPChild(t *testing.T, pinned string, args ...string) error { + t.Helper() + resetGlobals() + t.Cleanup(resetGlobals) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("JAMF_CLI_ARGS", "") + t.Setenv(mcpChildEnvVar, "1") + t.Setenv(mcpPinnedProfileEnvVar, pinned) + root := NewRootCmd("test", "abc123", "2024-01-01", "unknown") + root.SetArgs(args) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + return root.Execute() +} + +func TestMCPChild_RefusesWhatTheServerRefuses(t *testing.T) { + outFile := filepath.Join(t.TempDir(), "hijacked.json") + refused := [][]string{ + {"--profile", "prod", "--no-input", "cfg", "set-default", "x"}, + {"--profile", "prod", "--no-input", "-q", "multi", "--", "pro", "computers", "list"}, + {"--profile", "other", "--no-input", "config", "list"}, + {"--no-input", "--profile", "prod", "pro", "diff", "--source", "/var/empty", "--target", "other"}, + {"--profile", "prod", "--no-input", "pro", "scripts", "create", "--script-file", "/etc/passwd", "-n"}, + {"--profile", "prod", "--no-input", "pro", "computers", "list", "--out-file", outFile}, + {"--profile", "prod", "--no-input", "__complete", "--profile", "other", "pro", "computers", "get", ""}, + } + for _, args := range refused { + err := executeAsMCPChild(t, "prod", args...) + if !isMCPRefusal(err) { + t.Errorf("MCP child ran %q (err %v); it must refuse on its own parse", args, err) + } + } + if _, err := os.Stat(outFile); err == nil { + t.Error("--out-file was created before the refusal ran") + } + if err := executeAsMCPChild(t, "prod", "--profile", "prod", "--no-input", "config", "list"); err != nil { + t.Errorf("the pinned profile's own --profile must not be refused in the child: %v", err) + } +} + +func TestMCPChild_JcdsSyncRefusedWithoutTheServer(t *testing.T) { + victim := t.TempDir() + if err := os.WriteFile(filepath.Join(victim, "id_ed25519"), []byte("operator data"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("JAMF_URL", "http://127.0.0.1:1") + t.Setenv("JAMF_TOKEN", "fake-token") + for _, mount := range jcdsSyncMounts { + args := append(append([]string{"--no-input"}, mount...), "--dir", victim, "--delete") + if err := executeAsMCPChild(t, "", args...); !isMCPRefusal(err) { + t.Errorf("MCP child ran %q (err %v)", args, err) + } + } + if _, err := os.Stat(filepath.Join(victim, "id_ed25519")); err != nil { + t.Errorf("operator file is gone: %v", err) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index 289ab687..99dc7486 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -759,6 +759,11 @@ in the config file. It never runs in CI, when output is piped, or under // client and return below. warnIfDeprecatedName(cmd) + // Ahead of the --out-file create and every auth-skip return below. + if err := refuseInMCPChild(cmd); err != nil { + return err + } + // Respect NO_COLOR env var (https://no-color.org) if _, ok := os.LookupEnv("NO_COLOR"); ok { noColor = true From 1837530b174529bbcba5bc8deb6dc1aa3ee53e42 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 12:29:06 -0500 Subject: [PATCH 03/25] feat(mcp): allow read-side path flags inside an operator-chosen --input-dir Commit 2 refuses every local path flag over run_command, which also refuses the legitimate case: a model asked to create a policy from a file the administrator prepared. `mcp serve --input-dir ` is an operator-only flag on serve, with no config key. The directory is resolved once at startup (Abs, then EvalSymlinks), and serve refuses to start when it is missing or is not a directory. With it set, a read-side path flag is accepted when its value exists and resolves inside the directory, symlinks followed: from-file, file, script-file, mobileconfig-file, appconfig-file, every custom-payload-file occurrence, body-file, input, and the --dir of the two protect imports. A `..` path, an absolute path elsewhere, a symlink out of the directory, an empty value and a nonexistent path are refused with a message naming the directory. With it unset, the refusal names --input-dir as the remedy. --password-file stays refused, as do --token-file and every write-side path flag (save-to, a leaf --output, report-dir, jcds sync --dir). The child enforces the same rule on its own parse. It reads the resolved directory from JAMF_CLI_MCP_INPUT_DIR, which childEnv strips from the inherited environment and pinnedChildEnv sets. The run_command description names the directory, so the model knows where it may point. Co-Authored-By: Claude Opus 5.5 --- internal/commands/agent_context.md | 4 +- internal/commands/mcp.go | 149 ++++++++++++--- internal/commands/mcp_input_dir_test.go | 170 ++++++++++++++++++ internal/commands/mcp_resolver_state_test.go | 4 +- .../commands/mcp_run_command_guard_test.go | 9 +- 5 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 internal/commands/mcp_input_dir_test.go diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 036dbf17..f386784c 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -109,7 +109,9 @@ Rejected: credential- and target-selecting flags; flags whose value is a local file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, `--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, the config write subcommands, every `setup`, both `backup` commands and jcds `sync`; -and `pro diff` against any profile other than the pinned one. +and `pro diff` against any profile other than the pinned one. An administrator +who starts the server with `--input-dir ` allows the read-side flags for +existing paths inside that directory; `run_command`'s description names it. **`dashboard` output belongs in a file, not a tool result.** The command writes a 320–800 KB HTML document to stdout (80k–200k tokens), so `run_command` refuses diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 8879d741..9f732656 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -62,7 +62,8 @@ choose. Set one with: jamf-cli config set-report-dir `, } func newMCPServeCmd() *cobra.Command { - return &cobra.Command{ + var inputDirFlag string + cmd := &cobra.Command{ Use: "serve", Short: "Start an MCP server on stdio", Long: `Start an MCP server that speaks JSON-RPC over stdin/stdout. @@ -99,12 +100,22 @@ spelling. It refuses: - any flag whose value is a local path: --from-file, --file, --script-file, --mobileconfig-file, --appconfig-file, --custom-payload-file, --body-file, --input, --password-file, --dir, --save-to, --report-dir, and a command's - own --output (the global -o/--output format flag stays available) + own --output (the global -o/--output format flag stays available), except + as --input-dir allows below - 'pro diff' with a --source or --target that is neither a directory nor this server's profile It also refuses 'dashboard', because it returns a command's stdout as text and the report is a 320-800 KB document — generate_report writes that to a file -instead.`, +instead. + +--input-dir lets the model pass files it needs to read: a path given to +--from-file, --file, --script-file, --mobileconfig-file, --appconfig-file, +--custom-payload-file, --body-file, --input, or the --dir of 'protect analytics +import' and 'protect unified-logging-filters import' is accepted when it +exists and resolves inside , symlinks followed. A relative path is taken +from the directory this server was started in. --password-file and every +write-side path flag stay refused. The directory must exist; there is no +config key for it.`, Args: refuseStrayPositionals, RunE: func(cmd *cobra.Command, _ []string) error { executable, err := os.Executable() @@ -122,10 +133,15 @@ instead.`, return err } + inputDir, err := resolveMCPInputDir(inputDirFlag) + if err != nil { + return err + } + if !noHints { printMCPStartupHints(cmd.ErrOrStderr(), cfg) } - installMCPResolver(cmd.Root()) + installMCPResolver(cmd.Root(), inputDir) server := mcp.NewServer(&mcp.Implementation{ Name: "jamf-cli", @@ -165,7 +181,8 @@ instead.`, "directory (--from-file, --file, --script-file, --save-to, --dir and the " + "like; the -o/--output format flag is fine); 'multi', 'mcp', the config " + "write subcommands, every 'setup', the backup commands and jcds sync; and " + - "'pro diff' against any profile but this server's. Use generate_report rather than 'dashboard': this tool returns " + + "'pro diff' against any profile but this server's. " + inputDirToolNote(inputDir) + + " Use generate_report rather than 'dashboard': this tool returns " + "stdout as text and the dashboard writes a 320-800 KB HTML document there. " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + "require an explicit --yes in args or they will refuse to run.", @@ -218,6 +235,40 @@ instead.`, return nil }, } + cmd.Flags().StringVar(&inputDirFlag, "input-dir", "", "directory the connecting model may name files inside for read-side path flags such as --from-file") + return cmd +} + +// inputDirToolNote tells the model where read-side path flags may point. +func inputDirToolNote(inputDir string) string { + if inputDir == "" { + return "Read-side path flags are refused too: this server allows no input directory." + } + return "Read-side path flags (--from-file, --file, --script-file and the like) are accepted for existing paths inside " + + inputDir + ", the only directory this server reads from; --password-file stays refused." +} + +// resolveMCPInputDir returns dir with every symlink resolved, or "" when no +// input directory is set. The server refuses to start on one it cannot use. +func resolveMCPInputDir(dir string) (string, error) { + if dir == "" { + return "", nil + } + abs, err := filepath.Abs(dir) + if err == nil { + abs, err = filepath.EvalSymlinks(abs) + } + if err != nil { + return "", fmt.Errorf("--input-dir %s is not accessible: %w", dir, err) + } + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("--input-dir %s is not accessible: %w", dir, err) + } + if !info.IsDir() { + return "", fmt.Errorf("--input-dir %s is not a directory", dir) + } + return abs, nil } type runCommandInput struct { @@ -241,12 +292,13 @@ type generateReportInput struct { // nothing may be prepended to it. // // JAMF_CLI_MCP=1 is appended so the child knows it is an MCP child, and an -// inherited JAMF_CLI_MCP_PROFILE is dropped so only pinnedChildEnv sets it. +// inherited JAMF_CLI_MCP_PROFILE or JAMF_CLI_MCP_INPUT_DIR is dropped so only +// pinnedChildEnv sets them. func childEnv() []string { env := os.Environ() kept := make([]string, 0, len(env)+1) for _, kv := range env { - if name, _, ok := strings.Cut(kv, "="); ok && (name == "JAMF_CLI_ARGS" || name == mcpPinnedProfileEnvVar) { + if name, _, ok := strings.Cut(kv, "="); ok && (name == "JAMF_CLI_ARGS" || name == mcpPinnedProfileEnvVar || name == mcpInputDirEnvVar) { continue } kept = append(kept, kv) @@ -257,12 +309,16 @@ func childEnv() []string { const ( mcpChildEnvVar = "JAMF_CLI_MCP" mcpPinnedProfileEnvVar = "JAMF_CLI_MCP_PROFILE" + mcpInputDirEnvVar = "JAMF_CLI_MCP_INPUT_DIR" ) -// pinnedChildEnv is childEnv plus the profile the server is pinned to, which -// refuseInMCPChild compares the child's own parse against. +// pinnedChildEnv is childEnv plus the profile the server is pinned to and the +// input directory it allows, which refuseInMCPChild judges the child's own +// parse against. func pinnedChildEnv(serverProfile string) []string { - return append(childEnv(), mcpPinnedProfileEnvVar+"="+serverProfile) + return append(childEnv(), + mcpPinnedProfileEnvVar+"="+serverProfile, + mcpInputDirEnvVar+"="+installedMCPInputDir()) } type listCommandsInput struct { @@ -532,10 +588,12 @@ const ( ) // childInvocation is what cobra resolves a child argv to. An empty path means -// cobra refuses the argv before running anything. +// cobra refuses the argv before running anything. inputDir is the one +// directory read-side path flags may name, "" when none is allowed. type childInvocation struct { path string settings []flagSetting + inputDir string } // flagSetting is one occurrence of a flag on the command line, in order. A @@ -545,21 +603,31 @@ type flagSetting struct { rootOutput bool } -// mcpResolver is the tree run_command argv is resolved against. Every resolve -// holds the lock for its whole length: Find merges persistent flags into the -// leaf, and ParseAll records its arguments on the leaf's flag set. +// mcpResolver is the tree run_command argv is resolved against and the input +// directory `mcp serve` allows reads from. Every resolve holds the lock for its +// whole length: Find merges persistent flags into the leaf, and ParseAll +// records its arguments on the leaf's flag set. var mcpResolver struct { sync.Mutex - root *cobra.Command + root *cobra.Command + inputDir string } -// installMCPResolver makes later resolves use root. `mcp serve` installs the -// tree it is running in, because NewRootCmd rebinds every flag variable in this -// package to its default, and main reads those after serve returns. -func installMCPResolver(root *cobra.Command) { +// installMCPResolver makes later resolves use root and inputDir, which must +// already be symlink-resolved. `mcp serve` installs the tree it is running in, +// because NewRootCmd rebinds every flag variable in this package to its +// default, and main reads those after serve returns. +func installMCPResolver(root *cobra.Command, inputDir string) { mcpResolver.Lock() defer mcpResolver.Unlock() mcpResolver.root = root + mcpResolver.inputDir = inputDir +} + +func installedMCPInputDir() string { + mcpResolver.Lock() + defer mcpResolver.Unlock() + return mcpResolver.inputDir } // resolveChildInvocation returns what a child process given args would run. @@ -584,7 +652,7 @@ func resolveChildInvocation(args []string) childInvocation { if err != nil { return childInvocation{} } - inv := childInvocation{path: cmd.CommandPath()} + inv := childInvocation{path: cmd.CommandPath(), inputDir: mcpResolver.inputDir} if cmd.DisableFlagParsing { return inv } @@ -611,7 +679,9 @@ func refuseOverMCP(inv childInvocation, pinnedProfile string) error { return fmt.Errorf("flag %q is not allowed: the MCP server is pinned to the configuration it was started with; the target instance, credentials, and output destination cannot be overridden per command", "--"+s.name) } if use, ok := localPathFlagUse(inv.path, s); ok { - return fmt.Errorf("flag --%s is not available over MCP: it %s a path on the machine running this server, which the connecting model must not choose", s.name, use) + if err := refuseLocalPath(use, s, inv.inputDir); err != nil { + return err + } } if inv.path == proDiffPath && (s.name == "source" || s.name == "target") && !isDirectoryPath(s.value) && (pinnedProfile == "" || s.value != pinnedProfile) { @@ -635,17 +705,50 @@ func refuseInMCPChild(cmd *cobra.Command) error { return errCompletionOverMCP } pinned := os.Getenv(mcpPinnedProfileEnvVar) - inv := childInvocation{path: cmd.CommandPath()} + inv := childInvocation{path: cmd.CommandPath(), inputDir: os.Getenv(mcpInputDirEnvVar)} rootOutput := cmd.Root().PersistentFlags().Lookup("output") cmd.Flags().Visit(func(f *pflag.Flag) { if f.Name == "profile" && pinned != "" && f.Value.String() == pinned { return } - inv.settings = append(inv.settings, flagSetting{name: f.Name, value: f.Value.String(), rootOutput: f == rootOutput}) + values := []string{f.Value.String()} + if sv, ok := f.Value.(pflag.SliceValue); ok { + values = sv.GetSlice() + } + for _, v := range values { + inv.settings = append(inv.settings, flagSetting{name: f.Name, value: v, rootOutput: f == rootOutput}) + } }) return refuseOverMCP(inv, pinned) } +// refuseLocalPath allows a read-side path only when it exists and resolves +// inside inputDir. The child opens the path again after this check, so a +// symlink swapped in between is not caught. +func refuseLocalPath(use localPathUse, s flagSetting, inputDir string) error { + if use != pathRead || inputDir == "" { + err := fmt.Errorf("flag --%s is not available over MCP: it %s a path on the machine running this server, which the connecting model must not choose", s.name, use) + if use == pathRead { + err = fmt.Errorf("%w; the administrator can allow reads from one directory with 'mcp serve --input-dir '", err) + } + return err + } + if s.value == "" { + return fmt.Errorf("flag --%s names no path; over MCP it must name an existing path inside the input directory %s", s.name, inputDir) + } + resolved, err := filepath.Abs(s.value) + if err == nil { + resolved, err = filepath.EvalSymlinks(resolved) + } + if err != nil { + return fmt.Errorf("flag --%s %q cannot be used over MCP: %v; it must name an existing path inside the input directory %s", s.name, s.value, err, inputDir) + } + if resolved != inputDir && !strings.HasPrefix(resolved, inputDir+string(filepath.Separator)) { + return fmt.Errorf("flag --%s %q is not available over MCP: it resolves to %s, outside the input directory %s", s.name, s.value, resolved, inputDir) + } + return nil +} + var errCompletionOverMCP = errors.New("shell completion is not available over MCP: it runs another command's completion without the flag checks every command gets; use list_commands or --help instead") func isBlockedChildFlag(arg string) bool { diff --git a/internal/commands/mcp_input_dir_test.go b/internal/commands/mcp_input_dir_test.go new file mode 100644 index 00000000..f5e3cb18 --- /dev/null +++ b/internal/commands/mcp_input_dir_test.go @@ -0,0 +1,170 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +type inputDirFixture struct { + dir, inside, spaced, sub, outside string +} + +// newInputDirFixture installs a resolver allowing reads from a fresh directory +// holding two files, with a symlink inside it pointing at a file outside. +func newInputDirFixture(t *testing.T) inputDirFixture { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + other, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + fx := inputDirFixture{ + dir: dir, + inside: filepath.Join(dir, "body.xml"), + spaced: filepath.Join(dir, "my payload.plist"), + sub: filepath.Join(dir, "imports"), + outside: filepath.Join(other, "id_ed25519"), + } + for _, f := range []string{fx.inside, fx.spaced, fx.outside} { + if err := os.WriteFile(f, []byte(""), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(fx.sub, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(fx.outside, filepath.Join(dir, "escape")); err != nil { + t.Fatal(err) + } + installMCPResolver(NewRootCmd("test", "t", "t", "t"), dir) + t.Cleanup(func() { installMCPResolver(nil, "") }) + t.Cleanup(resetGlobals) + return fx +} + +func TestBuildChildArgs_AllowsReadPathsInsideTheInputDir(t *testing.T) { + fx := newInputDirFixture(t) + t.Chdir(fx.dir) + for _, args := range [][]string{ + {"pro", "classic-policies", "create", "--from-file", fx.inside}, + {"pro", "classic-policies", "create", "--from-file=" + fx.inside}, + {"pro", "classic-policies", "create", "--from-file", "body.xml"}, + {"pro", "classic-macos-config-profiles", "create", "--custom-payload-file", fx.spaced, "--custom-payload-file=" + fx.inside}, + {"pro", "scripts", "create", "--script-file", "./body.xml"}, + {"protect", "analytics", "import", "--dir", fx.sub}, + } { + if _, err := buildChildArgs("prod", args); err != nil { + t.Errorf("buildChildArgs(%q) = %v; a read path inside the input directory is allowed", args, err) + } + } +} + +func TestBuildChildArgs_RefusesReadPathsOutsideTheInputDir(t *testing.T) { + fx := newInputDirFixture(t) + cases := map[string][]string{ + "outside": {"pro", "classic-policies", "create", "--from-file", fx.outside}, + "dot-dot": {"pro", "classic-policies", "create", "--from-file", fx.dir + "/../" + filepath.Base(filepath.Dir(fx.outside)) + "/id_ed25519"}, + "symlink escape": {"pro", "classic-policies", "create", "--from-file=" + filepath.Join(fx.dir, "escape")}, + "nonexistent": {"pro", "classic-policies", "create", "--from-file", filepath.Join(fx.dir, "missing.xml")}, + "empty": {"pro", "classic-policies", "create", "--from-file="}, + "one bad array value": {"pro", "classic-macos-config-profiles", "create", "--custom-payload-file", fx.inside, "--custom-payload-file", fx.outside}, + "password-file": {"pro", "computer-inventory", "set-auto-admin-password", "--password-file", fx.inside}, + "save-to": {"pro", "scripts", "download", "1", "--save-to", fx.inside}, + "sync --dir": {"pro", "jcds", "sync", "--dir", fx.sub}, + "leaf --output": {"protect", "downloads", "installer", "--output", fx.inside}, + } + for name, args := range cases { + if got, err := buildChildArgs("prod", args); err == nil { + t.Errorf("%s: buildChildArgs(%q) = %q; it must be refused even with an input directory", name, args, got) + } + } +} + +func TestBuildChildArgs_ReadPathRefusalNamesTheRemedy(t *testing.T) { + installMCPResolver(NewRootCmd("test", "t", "t", "t"), "") + t.Cleanup(func() { installMCPResolver(nil, "") }) + t.Cleanup(resetGlobals) + _, err := buildChildArgs("prod", []string{"pro", "classic-policies", "create", "--from-file", "/etc/hosts"}) + if err == nil || !strings.Contains(err.Error(), "--input-dir") { + t.Errorf("with no input directory a read path must be refused, naming --input-dir: %v", err) + } + + fx := newInputDirFixture(t) + _, err = buildChildArgs("prod", []string{"pro", "classic-policies", "create", "--from-file", fx.outside}) + if err == nil || !strings.Contains(err.Error(), fx.dir) { + t.Errorf("a read path outside the input directory must be refused, naming it: %v", err) + } +} + +func TestResolveMCPInputDir_RefusesADirectoryItCannotUse(t *testing.T) { + file := filepath.Join(t.TempDir(), "f") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + for _, dir := range []string{filepath.Join(t.TempDir(), "missing"), file} { + if got, err := resolveMCPInputDir(dir); err == nil { + t.Errorf("resolveMCPInputDir(%q) = %q; the server must not start on it", dir, got) + } + } + link := filepath.Join(t.TempDir(), "link") + target, _ := filepath.EvalSymlinks(t.TempDir()) + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if got, err := resolveMCPInputDir(link); err != nil || got != target { + t.Errorf("resolveMCPInputDir(%q) = %q, %v; want the resolved %q", link, got, err, target) + } +} + +func TestPinnedChildEnv_CarriesOnlyTheInstalledInputDir(t *testing.T) { + t.Setenv(mcpInputDirEnvVar, "/") + installMCPResolver(nil, "/allowed") + t.Cleanup(func() { installMCPResolver(nil, "") }) + env := pinnedChildEnv("prod") + var got []string + for _, kv := range env { + if strings.HasPrefix(kv, mcpInputDirEnvVar+"=") { + got = append(got, kv) + } + } + if len(got) != 1 || got[0] != mcpInputDirEnvVar+"=/allowed" { + t.Errorf("child input-dir env = %q; want only the installed directory", got) + } +} + +func TestMCPChild_EnforcesTheInputDir(t *testing.T) { + fx := newInputDirFixture(t) + installMCPResolver(nil, "") + t.Setenv(mcpInputDirEnvVar, fx.dir) + t.Setenv("JAMF_URL", "http://127.0.0.1:1") + t.Setenv("JAMF_TOKEN", "fake-token") + child := func(args ...string) error { + return executeAsMCPChild(t, "", append([]string{"--no-input", "-n"}, args...)...) + } + + for _, args := range [][]string{ + {"pro", "classic-policies", "create", "--from-file", fx.outside}, + {"pro", "classic-policies", "create", "--from-file", filepath.Join(fx.dir, "escape")}, + {"pro", "classic-macos-config-profiles", "create", "--custom-payload-file", fx.inside, "--custom-payload-file", fx.outside}, + {"pro", "computer-inventory", "set-auto-admin-password", "--password-file", fx.inside}, + } { + if err := child(args...); !isMCPRefusal(err) { + t.Errorf("MCP child ran %q (err %v); it must enforce the input directory on its own parse", args, err) + } + } + if err := child("pro", "classic-policies", "create", "--from-file", fx.inside); isMCPRefusal(err) { + t.Errorf("MCP child refused a read inside the input directory: %v", err) + } + + t.Setenv(mcpInputDirEnvVar, "") + if err := child("pro", "classic-policies", "create", "--from-file", fx.inside); err == nil || !strings.Contains(err.Error(), "--input-dir") { + t.Errorf("with no input directory the child must refuse every read path: %v", err) + } +} diff --git a/internal/commands/mcp_resolver_state_test.go b/internal/commands/mcp_resolver_state_test.go index 28f07ef9..61580ebd 100644 --- a/internal/commands/mcp_resolver_state_test.go +++ b/internal/commands/mcp_resolver_state_test.go @@ -49,8 +49,8 @@ var concurrentRunCommandArgs = [][]string{ func TestResolveChildInvocation_LeavesTheServingProcessFlagStateAlone(t *testing.T) { root := NewRootCmd("test", "t", "t", "t") t.Cleanup(resetGlobals) - installMCPResolver(root) - t.Cleanup(func() { installMCPResolver(nil) }) + installMCPResolver(root, "") + t.Cleanup(func() { installMCPResolver(nil, "") }) setRootFlagSentinels() want := snapshotRootFlagState() diff --git a/internal/commands/mcp_run_command_guard_test.go b/internal/commands/mcp_run_command_guard_test.go index ad74e4ef..4690119a 100644 --- a/internal/commands/mcp_run_command_guard_test.go +++ b/internal/commands/mcp_run_command_guard_test.go @@ -97,13 +97,18 @@ func TestMCPLocalPathFlags_EveryPathShapedFlagIsRefused(t *testing.T) { var walk func(c *cobra.Command) walk = func(c *cobra.Command) { path := strings.Fields(strings.TrimPrefix(c.CommandPath(), root.Name())) + refusedWhole := refuseOverMCP(childInvocation{path: c.CommandPath()}, "prod") != nil c.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { _, byName := mcpLocalPathFlags[f.Name] _, byCommand := mcpDirFlags[c.CommandPath()] + classified := f.Name == "dir" && byCommand || f.Name != "dir" && byName switch { - case f.Name == "dir" && byCommand, f.Name != "dir" && byName: + case classified: used[f.Name] = true - case !isPathShapedFlag(root, f) || isBlockedChildFlag("--"+f.Name): + if refusedWhole { + return + } + case refusedWhole, !isPathShapedFlag(root, f), isBlockedChildFlag("--" + f.Name): return default: t.Errorf("--%s on %q looks like a local path and is not classified; add it to mcpLocalPathFlags or mcpDirFlags", f.Name, c.CommandPath()) From 1a498d478fbc59e45a09b25bae06dcef69d6a654 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 12:35:59 -0500 Subject: [PATCH 04/25] fix(mcp): hold pro diff directory sides to the --input-dir rule A `pro diff --source/--target` directory is a local read: the diff renders the backup files under whatever directory the model names. Until now any directory was accepted over run_command. A directory side now follows the same rule as the other read-side path flags. It is accepted only when it exists and resolves inside --input-dir (Abs, then EvalSymlinks, then a prefix check). It is refused when --input-dir is unset, with a message naming --input-dir. A side naming the pinned profile stays allowed. The server and the child both enforce this. `~/` is expanded by expandDiffDir, which loadSnapshotFromDirectory now shares, so the check judges the path the child opens. TestBuildChildArgs_AllowsDiffWithinThePinnedProfile keeps its three spellings and gains an input directory holding them. Co-Authored-By: Claude Opus 5.5 --- internal/commands/agent_context.md | 7 +-- internal/commands/mcp.go | 43 ++++++++++----- internal/commands/mcp_input_dir_test.go | 52 +++++++++++++++++++ .../commands/mcp_run_command_boundary_test.go | 19 ++++++- internal/commands/pro_diff.go | 23 +++++--- 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index f386784c..72a89157 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -109,9 +109,10 @@ Rejected: credential- and target-selecting flags; flags whose value is a local file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, `--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, the config write subcommands, every `setup`, both `backup` commands and jcds `sync`; -and `pro diff` against any profile other than the pinned one. An administrator -who starts the server with `--input-dir ` allows the read-side flags for -existing paths inside that directory; `run_command`'s description names it. +and `pro diff` against anything but the pinned profile. An administrator who +starts the server with `--input-dir ` allows the read-side flags, and a +`pro diff` side that is a directory, for existing paths inside that directory; +`run_command`'s description names it. **`dashboard` output belongs in a file, not a tool result.** The command writes a 320–800 KB HTML document to stdout (80k–200k tokens), so `run_command` refuses diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 9f732656..618f3373 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -102,18 +102,19 @@ spelling. It refuses: --input, --password-file, --dir, --save-to, --report-dir, and a command's own --output (the global -o/--output format flag stays available), except as --input-dir allows below - - 'pro diff' with a --source or --target that is neither a directory nor - this server's profile + - 'pro diff' with a --source or --target that is neither this server's + profile nor a directory inside --input-dir It also refuses 'dashboard', because it returns a command's stdout as text and the report is a 320-800 KB document — generate_report writes that to a file instead. --input-dir lets the model pass files it needs to read: a path given to --from-file, --file, --script-file, --mobileconfig-file, --appconfig-file, ---custom-payload-file, --body-file, --input, or the --dir of 'protect analytics -import' and 'protect unified-logging-filters import' is accepted when it -exists and resolves inside , symlinks followed. A relative path is taken -from the directory this server was started in. --password-file and every +--custom-payload-file, --body-file, --input, a 'pro diff' --source or --target +directory, or the --dir of 'protect analytics import' and 'protect +unified-logging-filters import' is accepted when it exists and resolves inside +, symlinks followed. A relative path is taken from the directory this +server was started in. --password-file and every write-side path flag stay refused. The directory must exist; there is no config key for it.`, Args: refuseStrayPositionals, @@ -181,7 +182,8 @@ config key for it.`, "directory (--from-file, --file, --script-file, --save-to, --dir and the " + "like; the -o/--output format flag is fine); 'multi', 'mcp', the config " + "write subcommands, every 'setup', the backup commands and jcds sync; and " + - "'pro diff' against any profile but this server's. " + inputDirToolNote(inputDir) + + "'pro diff' against anything but this server's profile or a directory the " + + "input directory allows. " + inputDirToolNote(inputDir) + " Use generate_report rather than 'dashboard': this tool returns " + "stdout as text and the dashboard writes a 320-800 KB HTML document there. " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + @@ -683,12 +685,10 @@ func refuseOverMCP(inv childInvocation, pinnedProfile string) error { return err } } - if inv.path == proDiffPath && (s.name == "source" || s.name == "target") && !isDirectoryPath(s.value) && - (pinnedProfile == "" || s.value != pinnedProfile) { - if pinnedProfile == "" { - return fmt.Errorf("pro diff --%s %q names a config profile, and this server was started without one: over MCP each side must be a backup directory", s.name, s.value) + if inv.path == proDiffPath && (s.name == "source" || s.name == "target") { + if err := refuseDiffSide(s, pinnedProfile, inv.inputDir); err != nil { + return err } - return fmt.Errorf("pro diff --%s %q names a config profile other than %q, the one this server is pinned to: over MCP each side must be a backup directory or that profile", s.name, s.value, pinnedProfile) } } return nil @@ -722,6 +722,25 @@ func refuseInMCPChild(cmd *cobra.Command) error { return refuseOverMCP(inv, pinned) } +// refuseDiffSide judges one `pro diff` side: a directory is a local read under +// the input-directory rule, and a profile must be the pinned one. +func refuseDiffSide(s flagSetting, pinnedProfile, inputDir string) error { + if isDirectoryPath(s.value) { + dir, err := expandDiffDir(s.value) + if err != nil { + return fmt.Errorf("pro diff --%s %q is not available over MCP: %w", s.name, s.value, err) + } + return refuseLocalPath(pathRead, flagSetting{name: s.name, value: dir}, inputDir) + } + if pinnedProfile == "" { + return fmt.Errorf("pro diff --%s %q names a config profile, and this server was started without one: over MCP each side must be a backup directory inside --input-dir", s.name, s.value) + } + if s.value != pinnedProfile { + return fmt.Errorf("pro diff --%s %q names a config profile other than %q, the one this server is pinned to: over MCP each side must be that profile or a backup directory inside --input-dir", s.name, s.value, pinnedProfile) + } + return nil +} + // refuseLocalPath allows a read-side path only when it exists and resolves // inside inputDir. The child opens the path again after this check, so a // symlink swapped in between is not caught. diff --git a/internal/commands/mcp_input_dir_test.go b/internal/commands/mcp_input_dir_test.go index f5e3cb18..cf072080 100644 --- a/internal/commands/mcp_input_dir_test.go +++ b/internal/commands/mcp_input_dir_test.go @@ -168,3 +168,55 @@ func TestMCPChild_EnforcesTheInputDir(t *testing.T) { t.Errorf("with no input directory the child must refuse every read path: %v", err) } } + +func TestBuildChildArgs_RefusesDiffDirectoryOutsideTheInputDir(t *testing.T) { + fx := newInputDirFixture(t) + outsideDir := filepath.Dir(fx.outside) + if err := os.Symlink(outsideDir, filepath.Join(fx.dir, "escape-dir")); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", outsideDir) + for name, args := range map[string][]string{ + "outside": {"pro", "diff", "--source", outsideDir, "--target", "prod"}, + "inside vs out": {"pro", "diff", "--source", fx.sub, "--target=" + outsideDir}, + "dot-dot": {"pro", "diff", "--source", fx.dir + "/../" + filepath.Base(outsideDir), "--target", "prod"}, + "symlink escape": {"pro", "diff", "--source", filepath.Join(fx.dir, "escape-dir"), "--target", "prod"}, + "home outside": {"pro", "diff", "--source", "~/", "--target", "prod"}, + "nonexistent": {"pro", "diff", "--source", filepath.Join(fx.dir, "missing"), "--target", "prod"}, + "foreign profile": {"pro", "diff", "--source", fx.sub, "--target", "other"}, + } { + if got, err := buildChildArgs("prod", args); !isMCPRefusal(err) { + t.Errorf("%s: buildChildArgs(%q) = %q, %v; a diff directory outside the input directory must be refused", name, args, got, err) + } + } + if _, err := buildChildArgs("prod", []string{"pro", "diff", "--source", fx.sub, "--target", "prod"}); err != nil { + t.Errorf("a diff directory inside the input directory must be allowed: %v", err) + } +} + +func TestBuildChildArgs_RefusesDiffDirectoryWithNoInputDir(t *testing.T) { + installMCPResolver(NewRootCmd("test", "t", "t", "t"), "") + t.Cleanup(func() { installMCPResolver(nil, "") }) + t.Cleanup(resetGlobals) + _, err := buildChildArgs("prod", []string{"pro", "diff", "--source", t.TempDir(), "--target", "prod"}) + if !isMCPRefusal(err) || !strings.Contains(err.Error(), "--input-dir") { + t.Errorf("with no input directory a diff directory must be refused, naming --input-dir: %v", err) + } +} + +func TestMCPChild_EnforcesTheInputDirOnDiffSides(t *testing.T) { + fx := newInputDirFixture(t) + installMCPResolver(nil, "") + t.Setenv(mcpInputDirEnvVar, fx.dir) + outside := filepath.Dir(fx.outside) + if err := executeAsMCPChild(t, "prod", "--profile", "prod", "--no-input", "pro", "diff", "--source", outside, "--target", "prod"); !isMCPRefusal(err) { + t.Errorf("MCP child diffed a directory outside the input directory (err %v)", err) + } + if err := executeAsMCPChild(t, "prod", "--profile", "prod", "--no-input", "pro", "diff", "--source", fx.sub, "--target", fx.sub); isMCPRefusal(err) { + t.Errorf("MCP child refused a diff inside the input directory: %v", err) + } + t.Setenv(mcpInputDirEnvVar, "") + if err := executeAsMCPChild(t, "prod", "--profile", "prod", "--no-input", "pro", "diff", "--source", fx.sub, "--target", "prod"); !isMCPRefusal(err) { + t.Errorf("with no input directory the child must refuse a diff directory (err %v)", err) + } +} diff --git a/internal/commands/mcp_run_command_boundary_test.go b/internal/commands/mcp_run_command_boundary_test.go index cfa70a47..9bb775ff 100644 --- a/internal/commands/mcp_run_command_boundary_test.go +++ b/internal/commands/mcp_run_command_boundary_test.go @@ -88,9 +88,24 @@ func TestBuildChildArgs_RefusesDiffAgainstAForeignProfile(t *testing.T) { } func TestBuildChildArgs_AllowsDiffWithinThePinnedProfile(t *testing.T) { + inputDir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, d := range []string{"a", "b"} { + if err := os.MkdirAll(filepath.Join(inputDir, "backups", d), 0o700); err != nil { + t.Fatal(err) + } + } + t.Chdir(inputDir) + t.Setenv("HOME", inputDir) + installMCPResolver(NewRootCmd("test", "t", "t", "t"), inputDir) + t.Cleanup(func() { installMCPResolver(nil, "") }) + t.Cleanup(resetGlobals) + allowed := [][]string{ - {"pro", "diff", "--source", "/backups/a", "--target", "./backups/b"}, - {"pro", "diff", "--source", "prod", "--target", "/backups/a"}, + {"pro", "diff", "--source", filepath.Join(inputDir, "backups", "a"), "--target", "./backups/b"}, + {"pro", "diff", "--source", "prod", "--target", filepath.Join(inputDir, "backups", "a")}, {"pro", "diff", "--source=~/backups/a", "--target=prod"}, } for _, args := range allowed { diff --git a/internal/commands/pro_diff.go b/internal/commands/pro_diff.go index 57ea2e9c..dd15e420 100644 --- a/internal/commands/pro_diff.go +++ b/internal/commands/pro_diff.go @@ -95,6 +95,19 @@ func isDirectoryPath(s string) bool { s == "." || s == "~" } +// expandDiffDir returns the directory a diff side names, with a leading ~/ +// expanded to the home directory. +func expandDiffDir(dir string) (string, error) { + if !strings.HasPrefix(dir, "~/") { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expanding ~: %w", err) + } + return filepath.Join(home, dir[2:]), nil +} + // resourceSnapshot maps resource type name → (object name → stripped fields). type resourceSnapshot map[string]map[string]map[string]any @@ -109,13 +122,9 @@ func loadSourceSnapshot(ctx context.Context, source string, nameFilter []string) // loadSnapshotFromDirectory reads YAML/JSON backup files written by `backup`. // The directory layout is: //.yaml (or .json). func loadSnapshotFromDirectory(dir string, nameFilter []string) (resourceSnapshot, error) { - // Expand ~ to home directory. - if strings.HasPrefix(dir, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("expanding ~: %w", err) - } - dir = filepath.Join(home, dir[2:]) + dir, err := expandDiffDir(dir) + if err != nil { + return nil, err } info, err := os.Stat(dir) From 7111d7e7467cf74dab0f0bc9210de979b51b258d Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 15:16:08 -0500 Subject: [PATCH 05/25] fix(mcp): address review findings Keep credentials of every profile out of run_command results. `config show` printed each profile's token, client ID and client secret verbatim; in an MCP child it now shows each as . `doctor` echoed each credential field and probed another profile's URL, and `config validate` resolves every profile's secrets and probes their URLs, so both are refused over MCP, with `completion`, server-side and child-side. `config list --status` in an MCP child checks only the pinned profile. Judge containment with filepath.Rel, so `--input-dir /` accepts every path beneath it. An MCP child with an input directory now refuses a `pro diff` backup file, or a `protect analytics` / `unified-logging-filters import --dir` entry, or a `protect restore --input` file, singleton or resource directory, whose symlink resolves outside it. `protect plans config-profile` keeps a plan name as its default file name, and refuses one that is not a single path segment (a separator, empty, "." or "..") with a pointer to -O/--output. Tests cover a sibling directory sharing the input directory's prefix on every side, the child environment runChild passes, `mcp serve` and the credential readers on both sides, and a usage-text path heuristic whose matches are each classified. Co-Authored-By: Claude Opus 5.5 --- internal/commands/agent_context.md | 8 +- internal/commands/config.go | 71 +++++-- internal/commands/mcp.go | 109 ++++++++--- internal/commands/mcp_input_dir_test.go | 179 +++++++++++++++++- .../commands/mcp_run_command_boundary_test.go | 9 + .../commands/mcp_run_command_guard_test.go | 178 ++++++++++++++++- internal/commands/pro_diff.go | 23 ++- internal/commands/protect_analytics.go | 5 + internal/commands/protect_backup.go | 6 + internal/commands/protect_plans.go | 20 +- .../protect_plans_config_profile_test.go | 21 ++ internal/commands/protect_ulf.go | 5 + 12 files changed, 569 insertions(+), 65 deletions(-) create mode 100644 internal/commands/protect_plans_config_profile_test.go diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 72a89157..570ebc95 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -107,9 +107,11 @@ The server is pinned to the profile it was launched with, and `run_command` is judged on the command and flags your arguments resolve to, aliases included. Rejected: credential- and target-selecting flags; flags whose value is a local file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, -`--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, the -config write subcommands, every `setup`, both `backup` commands and jcds `sync`; -and `pro diff` against anything but the pinned profile. An administrator who +`--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, +`completion`, the config write subcommands, `config validate`, `doctor`, every +`setup`, both `backup` commands and jcds `sync`; and `pro diff` against +anything but the pinned profile. `config show` runs with every credential field +shown as ``. An administrator who starts the server with `--input-dir ` allows the read-side flags, and a `pro diff` side that is a directory, for existing paths inside that directory; `run_command`'s description names it. diff --git a/internal/commands/config.go b/internal/commands/config.go index 5ad2396d..f83c62f9 100644 --- a/internal/commands/config.go +++ b/internal/commands/config.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "slices" "strings" "sync" "time" @@ -204,6 +205,38 @@ func activeProfileName(cfg *config.Config) string { return active } +// configShowRows is `config show`'s profile list. In a child of `mcp serve` a +// token, client ID or client secret is shown as "", since a literal +// value in the config would otherwise reach the connecting model. +func configShowRows(cfg *config.Config, active string) []configProfileRow { + credential := func(v string) string { return v } + if os.Getenv(mcpChildEnvVar) == "1" { + credential = func(v string) string { + if v == "" { + return "" + } + return "" + } + } + names := sortedProfileNames(cfg) + rows := make([]configProfileRow, 0, len(names)) + for _, name := range names { + p := cfg.Profiles[name] + rows = append(rows, configProfileRow{ + Name: name, + URL: p.URL, + AuthMethod: p.AuthMethod, + TenantID: p.TenantID, + EnvironmentID: p.EnvironmentID, + Default: name == active, + Token: credential(p.Token), + ClientID: credential(p.ClientID), + ClientSecret: credential(p.ClientSecret), + }) + } + return rows +} + func newConfigShowCmd(cliCtx *registry.CLIContext) *cobra.Command { return &cobra.Command{ Use: "show", @@ -214,23 +247,7 @@ func newConfigShowCmd(cliCtx *registry.CLIContext) *cobra.Command { return err } - active := activeProfileName(cfg) - names := sortedProfileNames(cfg) - profiles := make([]configProfileRow, 0, len(names)) - for _, name := range names { - p := cfg.Profiles[name] - profiles = append(profiles, configProfileRow{ - Name: name, - URL: p.URL, - AuthMethod: p.AuthMethod, - TenantID: p.TenantID, - EnvironmentID: p.EnvironmentID, - Default: name == active, - Token: p.Token, - ClientID: p.ClientID, - ClientSecret: p.ClientSecret, - }) - } + profiles := configShowRows(cfg, activeProfileName(cfg)) out := map[string]any{ "config-file": config.ConfigPath(), @@ -260,6 +277,19 @@ func newConfigPathCmd() *cobra.Command { } } +// profilesToProbe is the profiles `config list --status` checks. A child of +// `mcp serve` checks only the pinned one, so the connecting model cannot reach +// the other configured instances. +func profilesToProbe(names []string) []string { + if os.Getenv(mcpChildEnvVar) != "1" { + return names + } + if pinned := os.Getenv(mcpPinnedProfileEnvVar); slices.Contains(names, pinned) { + return []string{pinned} + } + return nil +} + // healthResult holds the outcome of a single health check. type healthResult struct { Status string @@ -320,7 +350,7 @@ func newConfigListCmd(cliCtx *registry.CLIContext) *cobra.Command { results = make(map[string]healthResult, len(names)) var mu sync.Mutex var wg sync.WaitGroup - for _, name := range names { + for _, name := range profilesToProbe(names) { wg.Add(1) go func(n string) { defer wg.Done() @@ -344,11 +374,12 @@ func newConfigListCmd(cliCtx *registry.CLIContext) *cobra.Command { EnvironmentID: p.EnvironmentID, Default: name == active, } - if status { - r := results[name] + if r, ok := results[name]; ok { row.Status = r.Status healthy := r.Healthy row.Healthy = &healthy + } else if status { + row.Status = "not checked over MCP" } rows = append(rows, row) } diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 618f3373..a91e63ad 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -92,10 +92,14 @@ is in 'jamf-cli dashboard --help'. run_command judges the command and flags cobra resolves the arguments to, so an alias or a flag ahead of the command path is judged the same as the plain spelling. It refuses: - - 'multi', 'mcp', shell completion, the config write subcommands, every - 'setup', both 'backup' commands and 'jamf-cloud-distribution-service sync' - (also mounted as 'packages sync'), which pick their own instance or write - where the model says + - 'multi', 'mcp', 'completion' and shell completion, the config write + subcommands, every 'setup', both 'backup' commands and + 'jamf-cloud-distribution-service sync' (also mounted as 'packages sync'), + which pick their own instance or write where the model says + - 'config validate' and 'doctor', which resolve or report every profile's + credentials and probe other profiles' URLs ('config show' runs, with each + token, client ID and client secret shown as , and 'config list + --status' checks only this server's profile) - any flag naming a profile, URL, token, tenant, environment or output file - any flag whose value is a local path: --from-file, --file, --script-file, --mobileconfig-file, --appconfig-file, --custom-payload-file, --body-file, @@ -104,6 +108,11 @@ spelling. It refuses: as --input-dir allows below - 'pro diff' with a --source or --target that is neither this server's profile nor a directory inside --input-dir +Some allowed commands write a file into the directory this server was started +in, named by Jamf or by the command's own argument: 'protect downloads' and +'pro jcds download' without -O, and 'protect plans config-profile'. Start the +server from a directory where that is acceptable. + It also refuses 'dashboard', because it returns a command's stdout as text and the report is a 320-800 KB document — generate_report writes that to a file instead. @@ -180,8 +189,9 @@ config key for it.`, "included. Rejected: any flag naming a profile, URL, token, tenant, " + "environment or output file; any flag whose value is a local file or " + "directory (--from-file, --file, --script-file, --save-to, --dir and the " + - "like; the -o/--output format flag is fine); 'multi', 'mcp', the config " + - "write subcommands, every 'setup', the backup commands and jcds sync; and " + + "like; the -o/--output format flag is fine); 'multi', 'mcp', 'completion', " + + "the config write subcommands, 'config validate', 'doctor', " + + "every 'setup', the backup commands and jcds sync; and " + "'pro diff' against anything but this server's profile or a directory the " + "input directory allows. " + inputDirToolNote(inputDir) + " Use generate_report rather than 'dashboard': this tool returns " + @@ -502,27 +512,39 @@ var blockedChildFlagPrefixes = []string{ } // mcpRefusedCommands are resolved command paths, each refused with everything -// beneath it, that choose their own instance, credential or local destination. -// `dashboard` is not here: generate_report shares buildChildArgs and must still -// spawn it, so the run_command handler refuses it instead. -var mcpRefusedCommands = []string{ - "jamf-cli multi", - "jamf-cli mcp", - "jamf-cli config set-default", - "jamf-cli config add-profile", - "jamf-cli config remove-profile", - "jamf-cli config set-report-dir", - "jamf-cli pro setup", - "jamf-cli platform setup", - "jamf-cli protect setup", - "jamf-cli school setup", - "jamf-cli security setup", - "jamf-cli pro backup", - "jamf-cli protect backup", - "jamf-cli pro jamf-cloud-distribution-service sync", - "jamf-cli pro packages sync", +// beneath it, and why. `dashboard` is not here: generate_report shares +// buildChildArgs and must still spawn it, so the run_command handler refuses it +// instead. +var mcpRefusedCommands = []refusedCommand{ + {"jamf-cli multi", refusedPicksTarget}, + {"jamf-cli mcp", refusedPicksTarget}, + {"jamf-cli completion", "'completion install' writes into this machine's shell configuration, and the rest print a script no MCP client can use"}, + {"jamf-cli config set-default", refusedPicksTarget}, + {"jamf-cli config add-profile", refusedPicksTarget}, + {"jamf-cli config remove-profile", refusedPicksTarget}, + {"jamf-cli config set-report-dir", refusedPicksTarget}, + {"jamf-cli config validate", refusedReadsCredentials}, + {"jamf-cli doctor", refusedReadsCredentials}, + {"jamf-cli pro setup", refusedPicksTarget}, + {"jamf-cli platform setup", refusedPicksTarget}, + {"jamf-cli protect setup", refusedPicksTarget}, + {"jamf-cli school setup", refusedPicksTarget}, + {"jamf-cli security setup", refusedPicksTarget}, + {"jamf-cli pro backup", refusedPicksTarget}, + {"jamf-cli protect backup", refusedPicksTarget}, + {"jamf-cli pro jamf-cloud-distribution-service sync", refusedPicksTarget}, + {"jamf-cli pro packages sync", refusedPicksTarget}, +} + +type refusedCommand struct { + path, why string } +const ( + refusedPicksTarget = "it selects its own instance or writes to a path of its own, so the profile this server was started with cannot pin it" + refusedReadsCredentials = "it resolves or reports the credentials of profiles other than the one this server is pinned to, and probes their URLs" +) + // isCompletionRequest reports whether name is cobra's hidden completion // command, which parses no flags of its own and runs the target command's // completion functions. Cobra adds it only when it is invoked, so a resolve @@ -671,9 +693,8 @@ func resolveChildInvocation(args []string) childInvocation { // pinnedProfile, or nil. func refuseOverMCP(inv childInvocation, pinnedProfile string) error { for _, refused := range mcpRefusedCommands { - if inv.path == refused || strings.HasPrefix(inv.path, refused+" ") { - return fmt.Errorf("command %q is not available over MCP: it selects its own instance or writes to a path of its own, so the profile this server was started with cannot pin it", - strings.TrimPrefix(inv.path, "jamf-cli ")) + if inv.path == refused.path || strings.HasPrefix(inv.path, refused.path+" ") { + return fmt.Errorf("command %q is not available over MCP: %s", strings.TrimPrefix(inv.path, "jamf-cli "), refused.why) } } for _, s := range inv.settings { @@ -762,12 +783,42 @@ func refuseLocalPath(use localPathUse, s flagSetting, inputDir string) error { if err != nil { return fmt.Errorf("flag --%s %q cannot be used over MCP: %v; it must name an existing path inside the input directory %s", s.name, s.value, err, inputDir) } - if resolved != inputDir && !strings.HasPrefix(resolved, inputDir+string(filepath.Separator)) { + if !insideDir(inputDir, resolved) { return fmt.Errorf("flag --%s %q is not available over MCP: it resolves to %s, outside the input directory %s", s.name, s.value, resolved, inputDir) } return nil } +// insideDir reports whether path is dir or lies beneath it. Both must be +// absolute and symlink-resolved. +func insideDir(dir, path string) bool { + rel, err := filepath.Rel(dir, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// refuseMCPChildReadOutsideInputDir refuses a file a command found by listing +// a directory when it resolves outside the input directory of the `mcp serve` +// that spawned this process. Outside an MCP child it allows everything. +func refuseMCPChildReadOutsideInputDir(path string) error { + inputDir := os.Getenv(mcpInputDirEnvVar) + if os.Getenv(mcpChildEnvVar) != "1" || inputDir == "" { + return nil + } + resolved, err := filepath.Abs(path) + if err == nil { + resolved, err = filepath.EvalSymlinks(resolved) + } + if err != nil { + return fmt.Errorf("%s is %w: %v", path, errMCPChildReadRefused, err) + } + if !insideDir(inputDir, resolved) { + return fmt.Errorf("%s is %w: it resolves to %s, outside the input directory %s", path, errMCPChildReadRefused, resolved, inputDir) + } + return nil +} + +var errMCPChildReadRefused = errors.New("not readable over MCP") + var errCompletionOverMCP = errors.New("shell completion is not available over MCP: it runs another command's completion without the flag checks every command gets; use list_commands or --help instead") func isBlockedChildFlag(arg string) bool { diff --git a/internal/commands/mcp_input_dir_test.go b/internal/commands/mcp_input_dir_test.go index cf072080..574756db 100644 --- a/internal/commands/mcp_input_dir_test.go +++ b/internal/commands/mcp_input_dir_test.go @@ -3,6 +3,7 @@ package commands import ( + "errors" "os" "path/filepath" "strings" @@ -11,10 +12,12 @@ import ( type inputDirFixture struct { dir, inside, spaced, sub, outside string + adjacentFile, adjacentDir string } // newInputDirFixture installs a resolver allowing reads from a fresh directory -// holding two files, with a symlink inside it pointing at a file outside. +// holding two files, with a symlink inside it pointing at a file outside, and a +// sibling `-adjacent` whose name the input directory is a string prefix of. func newInputDirFixture(t *testing.T) inputDirFixture { t.Helper() dir, err := filepath.EvalSymlinks(t.TempDir()) @@ -32,7 +35,14 @@ func newInputDirFixture(t *testing.T) inputDirFixture { sub: filepath.Join(dir, "imports"), outside: filepath.Join(other, "id_ed25519"), } - for _, f := range []string{fx.inside, fx.spaced, fx.outside} { + adjacent := dir + "-adjacent" + fx.adjacentFile = filepath.Join(adjacent, "body.xml") + fx.adjacentDir = filepath.Join(adjacent, "imports") + if err := os.MkdirAll(fx.adjacentDir, 0o700); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(adjacent) }) + for _, f := range []string{fx.inside, fx.spaced, fx.outside, fx.adjacentFile} { if err := os.WriteFile(f, []byte(""), 0o600); err != nil { t.Fatal(err) } @@ -79,6 +89,7 @@ func TestBuildChildArgs_RefusesReadPathsOutsideTheInputDir(t *testing.T) { "save-to": {"pro", "scripts", "download", "1", "--save-to", fx.inside}, "sync --dir": {"pro", "jcds", "sync", "--dir", fx.sub}, "leaf --output": {"protect", "downloads", "installer", "--output", fx.inside}, + "report-dir": {"pro", "setup", "--report-dir", fx.sub}, } for name, args := range cases { if got, err := buildChildArgs("prod", args); err == nil { @@ -220,3 +231,167 @@ func TestMCPChild_EnforcesTheInputDirOnDiffSides(t *testing.T) { t.Errorf("with no input directory the child must refuse a diff directory (err %v)", err) } } + +func TestBuildChildArgs_RefusesASiblingSharingTheInputDirPrefix(t *testing.T) { + fx := newInputDirFixture(t) + for name, args := range map[string][]string{ + "file": {"pro", "classic-policies", "create", "--from-file", fx.adjacentFile}, + "import dir": {"protect", "analytics", "import", "--dir", fx.adjacentDir}, + "diff side": {"pro", "diff", "--source", fx.adjacentDir, "--target", "prod"}, + } { + if got, err := buildChildArgs("prod", args); !isMCPRefusal(err) { + t.Errorf("%s: buildChildArgs(%q) = %q, %v; %s is outside the input directory %s", name, args, got, err, fx.adjacentFile, fx.dir) + } + } +} + +func TestMCPChild_RefusesASiblingSharingTheInputDirPrefix(t *testing.T) { + fx := newInputDirFixture(t) + installMCPResolver(nil, "") + t.Setenv(mcpInputDirEnvVar, fx.dir) + t.Setenv("JAMF_URL", "http://127.0.0.1:1") + t.Setenv("JAMF_TOKEN", "fake-token") + for _, args := range [][]string{ + {"--no-input", "-n", "pro", "classic-policies", "create", "--from-file", fx.adjacentFile}, + {"--profile", "prod", "--no-input", "pro", "diff", "--source", fx.adjacentDir, "--target", "prod"}, + } { + if err := executeAsMCPChild(t, "prod", args...); !isMCPRefusal(err) { + t.Errorf("MCP child ran %q (err %v); a sibling sharing the input directory's prefix is outside it", args, err) + } + } +} + +func TestInsideDir(t *testing.T) { + for _, tc := range []struct { + dir, path string + want bool + }{ + {"/in", "/in", true}, + {"/in", "/in/a/b", true}, + {"/in", "/in/..hidden", true}, + {"/in", "/in-adjacent/a", false}, + {"/in", "/", false}, + {"/in", "/in/../out", false}, + {"/", "/etc/hosts", true}, + {"/", "/", true}, + } { + if got := insideDir(tc.dir, tc.path); got != tc.want { + t.Errorf("insideDir(%q, %q) = %v, want %v", tc.dir, tc.path, got, tc.want) + } + } +} + +func TestBuildChildArgs_AllowsAnyExistingPathUnderARootInputDir(t *testing.T) { + fx := newInputDirFixture(t) + installMCPResolver(NewRootCmd("test", "t", "t", "t"), string(filepath.Separator)) + if _, err := buildChildArgs("prod", []string{"pro", "classic-policies", "create", "--from-file", fx.outside}); err != nil { + t.Errorf("with --input-dir / every existing path is inside it: %v", err) + } +} + +// newDiffBackupWithSymlink returns a backup directory inside the fixture's +// input directory whose one resource file is a symlink to target. +func newDiffBackupWithSymlink(t *testing.T, fx inputDirFixture, target string) string { + t.Helper() + backup := filepath.Join(fx.sub, "backup") + res := filepath.Join(backup, "custom-things") + if err := os.MkdirAll(res, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(res, "leak.yaml")); err != nil { + t.Fatal(err) + } + return backup +} + +func TestLoadSnapshotFromDirectory_RefusesASymlinkOutOfTheInputDirInAnMCPChild(t *testing.T) { + fx := newInputDirFixture(t) + backup := newDiffBackupWithSymlink(t, fx, fx.outside) + + t.Setenv(mcpChildEnvVar, "1") + t.Setenv(mcpInputDirEnvVar, fx.dir) + if _, err := loadSnapshotFromDirectory(backup, nil); !errors.Is(err, errMCPChildReadRefused) || !strings.Contains(err.Error(), fx.outside) { + t.Errorf("an MCP child diffed a file resolving to %s outside the input directory (err %v)", fx.outside, err) + } + + t.Setenv(mcpChildEnvVar, "") + if _, err := loadSnapshotFromDirectory(backup, nil); err != nil { + t.Errorf("outside MCP a symlinked backup file must still be read as before: %v", err) + } +} + +func TestLoadSnapshotFromDirectory_FollowsASymlinkInsideTheInputDirInAnMCPChild(t *testing.T) { + fx := newInputDirFixture(t) + backup := newDiffBackupWithSymlink(t, fx, fx.inside) + t.Setenv(mcpChildEnvVar, "1") + t.Setenv(mcpInputDirEnvVar, fx.dir) + if _, err := loadSnapshotFromDirectory(backup, nil); err != nil { + t.Errorf("a symlink resolving inside the input directory must be read: %v", err) + } +} + +func TestRefuseMCPChildReadOutsideInputDir(t *testing.T) { + fx := newInputDirFixture(t) + escape := filepath.Join(fx.dir, "escape") + t.Setenv(mcpChildEnvVar, "") + t.Setenv(mcpInputDirEnvVar, fx.dir) + if err := refuseMCPChildReadOutsideInputDir(escape); err != nil { + t.Errorf("outside an MCP child nothing is refused: %v", err) + } + t.Setenv(mcpChildEnvVar, "1") + for _, path := range []string{escape, fx.adjacentFile, filepath.Join(fx.dir, "missing.yaml")} { + if err := refuseMCPChildReadOutsideInputDir(path); !errors.Is(err, errMCPChildReadRefused) { + t.Errorf("an MCP child read %s, which is not inside %s (err %v)", path, fx.dir, err) + } + } + if err := refuseMCPChildReadOutsideInputDir(fx.inside); err != nil { + t.Errorf("a file inside the input directory must be readable: %v", err) + } +} + +func TestCollectProtectRestoreFiles_RefusesSymlinksOutOfTheInputDirInAnMCPChild(t *testing.T) { + fx := newInputDirFixture(t) + outsideDir := filepath.Dir(fx.outside) + selected, err := protectSelectResources("", "") + if err != nil { + t.Fatal(err) + } + newBackup := func(t *testing.T, name string) string { + t.Helper() + backup := filepath.Join(fx.sub, name) + if err := os.MkdirAll(filepath.Join(backup, "analytics"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backup, "analytics", "Custom.yaml"), []byte("name: Custom\n"), 0o600); err != nil { + t.Fatal(err) + } + return backup + } + t.Setenv(mcpChildEnvVar, "1") + t.Setenv(mcpInputDirEnvVar, fx.dir) + + for name, link := range map[string]func(backup string) error{ + "analytic file": func(b string) error { return os.Symlink(fx.outside, filepath.Join(b, "analytics", "Leak.yaml")) }, + "singleton": func(b string) error { return os.Symlink(fx.outside, filepath.Join(b, "insights.yaml")) }, + "resource dir": func(b string) error { + if err := os.WriteFile(filepath.Join(outsideDir, "Stolen.yaml"), []byte("name: Stolen\n"), 0o600); err != nil { + return err + } + return os.Symlink(outsideDir, filepath.Join(b, "plans")) + }, + } { + backup := newBackup(t, strings.ReplaceAll(name, " ", "-")) + if err := link(backup); err != nil { + t.Fatal(err) + } + if files, _, err := collectProtectRestoreFiles(backup, selected, true); !errors.Is(err, errMCPChildReadRefused) { + t.Errorf("%s: an MCP child collected %v (err %v) through a symlink out of the input directory", name, files, err) + } + } + + backup := newBackup(t, "regular") + files, _, err := collectProtectRestoreFiles(backup, selected, true) + if err != nil || len(files) != 1 || files[0].Path != filepath.Join(backup, "analytics", "Custom.yaml") { + t.Errorf("a regular file inside the input directory must still be collected: %v, %v", files, err) + } +} diff --git a/internal/commands/mcp_run_command_boundary_test.go b/internal/commands/mcp_run_command_boundary_test.go index 9bb775ff..dd08b6fe 100644 --- a/internal/commands/mcp_run_command_boundary_test.go +++ b/internal/commands/mcp_run_command_boundary_test.go @@ -36,6 +36,15 @@ var refusedCommandSpellings = []struct { {[]string{"-q", "pro", "backup", "--output", "/x"}, "jamf-cli pro backup"}, {[]string{"pro", "-q", "backup"}, "jamf-cli pro backup"}, {[]string{"-v", "protect", "backup", "--output", "/x"}, "jamf-cli protect backup"}, + {[]string{"mcp", "serve"}, "jamf-cli mcp serve"}, + {[]string{"-q", "mcp", "serve"}, "jamf-cli mcp serve"}, + {[]string{"-o", "json", "config", "validate", "--connectivity"}, "jamf-cli config validate"}, + {[]string{"doctor"}, "jamf-cli doctor"}, + {[]string{"doctor", "other"}, "jamf-cli doctor"}, + {[]string{"-q", "doctor", "prod"}, "jamf-cli doctor"}, + {[]string{"completion", "install"}, "jamf-cli completion install"}, + {[]string{"completion", "zsh"}, "jamf-cli completion zsh"}, + {[]string{"--no-color", "completion"}, "jamf-cli completion"}, } func TestBuildChildArgs_RefusesEveryResolvedSpellingOfARefusedCommand(t *testing.T) { diff --git a/internal/commands/mcp_run_command_guard_test.go b/internal/commands/mcp_run_command_guard_test.go index 4690119a..0b2472e8 100644 --- a/internal/commands/mcp_run_command_guard_test.go +++ b/internal/commands/mcp_run_command_guard_test.go @@ -3,19 +3,28 @@ package commands import ( + "context" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" + "regexp" "strings" + "sync/atomic" "testing" + "time" "github.com/spf13/cobra" "github.com/spf13/pflag" + + "github.com/Jamf-Concepts/jamf-cli/internal/config" ) func TestMCPRefusedCommands_EveryEntryNamesACommandInTheTree(t *testing.T) { root := NewRootCmd("test", "t", "t", "t") - for _, path := range mcpRefusedCommands { + for _, refused := range mcpRefusedCommands { + path := refused.path args := strings.Fields(strings.TrimPrefix(path, root.Name()+" ")) found, _, err := root.Find(args) if err != nil || found.CommandPath() != path { @@ -86,13 +95,35 @@ func isPathShapedFlag(root *cobra.Command, f *pflag.Flag) bool { return true case n == "output": return f != root.PersistentFlags().Lookup("output") + case f.Value.Type() == "bool": + return false } - return false + return pathShapedUsage.MatchString(f.Usage) +} + +var pathShapedUsage = regexp.MustCompile(`(?i)\b(path|file|directory)\b`) + +// notALocalPathFlags are flags whose usage text mentions a path, file or +// directory without taking a path on this machine, each with the reason. +var notALocalPathFlags = map[string]string{ + "set": "a body field assignment; its usage names --from-file as the exclusive alternative", + "custom-payload-domain": "a preference domain; its usage names --custom-payload-file", + "name": "a Jamf object or remote file name, looked up on the server", + "file-name": "a file name in a distribution point, looked up on the server", + "type": "an enum naming a file or exception type", + "export-labels": "column labels for a server-side export", + "user": "a directory-service username in a scope", + "user-group": "a directory-service group in a scope", + "prefix": "a command path in the catalog", + "search": "words matched against command paths", + "source": "a pro diff side, judged by refuseDiffSide", + "target": "a pro diff side, judged by refuseDiffSide", } func TestMCPLocalPathFlags_EveryPathShapedFlagIsRefused(t *testing.T) { root := NewRootCmd("test", "t", "t", "t") used := map[string]bool{} + exempted := map[string]bool{} checked := 0 var walk func(c *cobra.Command) walk = func(c *cobra.Command) { @@ -110,6 +141,9 @@ func TestMCPLocalPathFlags_EveryPathShapedFlagIsRefused(t *testing.T) { } case refusedWhole, !isPathShapedFlag(root, f), isBlockedChildFlag("--" + f.Name): return + case notALocalPathFlags[f.Name] != "": + exempted[f.Name] = true + return default: t.Errorf("--%s on %q looks like a local path and is not classified; add it to mcpLocalPathFlags or mcpDirFlags", f.Name, c.CommandPath()) return @@ -136,6 +170,11 @@ func TestMCPLocalPathFlags_EveryPathShapedFlagIsRefused(t *testing.T) { t.Errorf("mcpLocalPathFlags names --%s, which no command declares; remove the stale entry", name) } } + for name := range notALocalPathFlags { + if !exempted[name] { + t.Errorf("notALocalPathFlags exempts --%s, which no longer looks like a local path; remove the stale entry", name) + } + } if checked < 400 { t.Fatalf("walk checked only %d path-flag forms; it has stopped matching the tree", checked) } @@ -259,3 +298,138 @@ func TestMCPChild_JcdsSyncRefusedWithoutTheServer(t *testing.T) { t.Errorf("operator file is gone: %v", err) } } + +func TestMCPChild_RefusesCredentialReaders(t *testing.T) { + for _, args := range [][]string{ + {"--profile", "prod", "--no-input", "config", "validate"}, + {"--profile", "prod", "--no-input", "doctor"}, + {"--profile", "prod", "--no-input", "doctor", "other"}, + } { + if err := executeAsMCPChild(t, "prod", args...); !isMCPRefusal(err) { + t.Errorf("MCP child ran %q (err %v); it prints or probes other profiles' credentials", args, err) + } + } +} + +func TestMCPChild_RefusesMCPServe(t *testing.T) { + done := make(chan error, 1) + go func() { + done <- executeAsMCPChild(t, "prod", "--profile", "prod", "--no-input", "mcp", "serve") + }() + select { + case err := <-done: + if !isMCPRefusal(err) { + t.Errorf("MCP child ran `mcp serve` (err %v)", err) + } + case <-time.After(10 * time.Second): + t.Fatal("MCP child started `mcp serve` and is serving on stdin") + } +} + +func TestRunChild_TellsTheChildItsPinAndInputDir(t *testing.T) { + t.Setenv(mcpChildEnvVar, "0") + t.Setenv(mcpPinnedProfileEnvVar, "attacker") + t.Setenv(mcpInputDirEnvVar, "/") + inputDir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + installMCPResolver(NewRootCmd("test", "t", "t", "t"), inputDir) + t.Cleanup(func() { installMCPResolver(nil, "") }) + t.Cleanup(resetGlobals) + + path := filepath.Join(t.TempDir(), "echo-env.sh") + script := "#!/bin/sh\nprintf 'MCP=[%s] PROFILE=[%s] INPUT=[%s]' \"$JAMF_CLI_MCP\" \"$JAMF_CLI_MCP_PROFILE\" \"$JAMF_CLI_MCP_INPUT_DIR\"\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + res := runChild(context.Background(), path, "prod", []string{"pro", "computers", "list"}) + if res == nil || res.IsError { + t.Fatalf("expected success, got %+v", res) + } + got := mcpResultText(res) + want := "MCP=[1] PROFILE=[prod] INPUT=[" + inputDir + "]" + if got != want { + t.Errorf("child saw %q, want %q", got, want) + } +} + +func TestConfigShowRows_RedactsCredentialsInAnMCPChild(t *testing.T) { + const marker = "F5-MARKER-literal-client-secret" + cfg := &config.Config{Profiles: map[string]config.Profile{ + "prod": {URL: "https://prod.example", ClientID: marker + "-id", ClientSecret: marker}, + "other": {URL: "https://other.example", Token: marker + "-token"}, + "empty": {URL: "https://empty.example"}, + }} + + t.Setenv(mcpChildEnvVar, "1") + for _, r := range configShowRows(cfg, "prod") { + for field, v := range map[string]string{"token": r.Token, "client-id": r.ClientID, "client-secret": r.ClientSecret} { + if strings.Contains(v, marker) { + t.Errorf("config show in an MCP child printed profile %q's %s: %q", r.Name, field, v) + } + } + if r.Name == "prod" && r.ClientSecret != "" { + t.Errorf("profile prod's client-secret = %q; want it marked ", r.ClientSecret) + } + if r.Name == "empty" && r.Token+r.ClientID+r.ClientSecret != "" { + t.Errorf("an unset credential must stay unset, got %+v", r) + } + } + + t.Setenv(mcpChildEnvVar, "") + for _, r := range configShowRows(cfg, "prod") { + if r.Name == "prod" && r.ClientSecret != marker { + t.Errorf("outside MCP config show must print the configured value, got %q", r.ClientSecret) + } + } +} + +func TestConfigListStatus_ProbesOnlyThePinnedProfileInAnMCPChild(t *testing.T) { + newServer := func() (*httptest.Server, *atomic.Int32) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("[]")) + })) + t.Cleanup(srv.Close) + return srv, &hits + } + pinnedSrv, pinnedHits := newServer() + otherSrv, otherHits := newServer() + + run := func(t *testing.T, mcpChild string) { + t.Helper() + resetGlobals() + t.Cleanup(resetGlobals) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("JAMF_CLI_ARGS", "") + t.Setenv(mcpChildEnvVar, mcpChild) + t.Setenv(mcpPinnedProfileEnvVar, "prod") + if err := config.Save(&config.Config{Profiles: map[string]config.Profile{ + "prod": {URL: pinnedSrv.URL}, + "other": {URL: otherSrv.URL}, + }}); err != nil { + t.Fatal(err) + } + root := NewRootCmd("test", "abc123", "2024-01-01", "unknown") + root.SetArgs([]string{"--profile", "prod", "--no-input", "-o", "json", "config", "list", "--status"}) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + if err := root.Execute(); err != nil { + t.Fatalf("config list --status: %v", err) + } + } + + run(t, "1") + if pinnedHits.Load() != 1 || otherHits.Load() != 0 { + t.Errorf("in an MCP child: pinned server hit %d times, other %d; only the pinned profile may be probed", pinnedHits.Load(), otherHits.Load()) + } + + pinnedHits.Store(0) + run(t, "") + if pinnedHits.Load() != 1 || otherHits.Load() != 1 { + t.Errorf("outside MCP every profile is probed once, got pinned %d, other %d", pinnedHits.Load(), otherHits.Load()) + } +} diff --git a/internal/commands/pro_diff.go b/internal/commands/pro_diff.go index dd15e420..d23b8cc2 100644 --- a/internal/commands/pro_diff.go +++ b/internal/commands/pro_diff.go @@ -154,27 +154,31 @@ func loadSnapshotFromDirectory(dir string, nameFilter []string) (resourceSnapsho // which is what makes the two comparable. nameField is the resource's // BackupEndpoint.NameField, so an object read off disk is keyed by the same // field live mode reads off the list item. - readInto := func(resourceName, nameField, path string) { + readInto := func(resourceName, nameField, path string) error { if len(allowedResources) > 0 && !allowedResources[resourceName] { - return + return nil } objects, err := readObjectsFromSubdir(path, nameField) + if errors.Is(err, errMCPChildReadRefused) { + return err + } if err != nil { // A curated resource absent from this backup is not a problem — // only an unreadable directory is. if !errors.Is(err, fs.ErrNotExist) { fmt.Fprintf(os.Stderr, "WARNING: reading %s: %v\n", path, err) } - return + return nil } if len(objects) == 0 { - return + return nil } if existing, ok := snapshot[resourceName]; ok { maps.Copy(existing, objects) } else { snapshot[resourceName] = objects } + return nil } // First, directories in the backup root that no curated resource claims, @@ -205,7 +209,9 @@ func loadSnapshotFromDirectory(dir string, nameFilter []string) (resourceSnapsho if !entryIsDir(dir, entry) { continue } - readInto(name, nonStandardBackupNameField(name), filepath.Join(dir, name)) + if err := readInto(name, nonStandardBackupNameField(name), filepath.Join(dir, name)); err != nil { + return nil, err + } } // Then every curated resource, read at the path `backup` writes it to and @@ -221,7 +227,9 @@ func loadSnapshotFromDirectory(dir string, nameFilter []string) (resourceSnapsho return nil, err } for _, def := range defs { - readInto(def.FilterName, def.NameField, filepath.Join(dir, filepath.FromSlash(def.SubDir))) + if err := readInto(def.FilterName, def.NameField, filepath.Join(dir, filepath.FromSlash(def.SubDir))); err != nil { + return nil, err + } } return snapshot, nil @@ -266,6 +274,9 @@ func readObjectsFromSubdir(subDir, nameField string) (map[string]map[string]any, } path := filepath.Join(subDir, name) + if err := refuseMCPChildReadOutsideInputDir(path); err != nil { + return nil, err + } data, err := os.ReadFile(path) if err != nil { fmt.Fprintf(os.Stderr, "WARNING: reading file %s: %v\n", path, err) diff --git a/internal/commands/protect_analytics.go b/internal/commands/protect_analytics.go index 60eca1bf..637dad12 100644 --- a/internal/commands/protect_analytics.go +++ b/internal/commands/protect_analytics.go @@ -271,6 +271,11 @@ Use --file for a single YAML file or --dir for a directory of YAML files.`, if len(files) == 0 { return fmt.Errorf("no YAML files found") } + for _, f := range files { + if err := refuseMCPChildReadOutsideInputDir(f); err != nil { + return err + } + } // Build name->UUID map for upsert detection existing, err := cliCtx.ProtectClient.ListAnalytics(ctx) diff --git a/internal/commands/protect_backup.go b/internal/commands/protect_backup.go index 022f1ba4..77b41513 100644 --- a/internal/commands/protect_backup.go +++ b/internal/commands/protect_backup.go @@ -1770,6 +1770,9 @@ func collectProtectRestoreFiles(inputDir string, selected []protectResource, inc for _, ext := range protectRestoreExts { path := filepath.Join(inputDir, res.Name+ext) if _, err := os.Stat(path); err == nil { + if err := refuseMCPChildReadOutsideInputDir(path); err != nil { + return nil, nil, err + } files = append(files, protectRestoreFile{Resource: res, Path: path}) break } @@ -1798,6 +1801,9 @@ func collectProtectRestoreFiles(inputDir string, selected []protectResource, inc // Deterministic order so a restore is reproducible and its log diffable. sort.Strings(names) for _, n := range names { + if err := refuseMCPChildReadOutsideInputDir(filepath.Join(dir, n)); err != nil { + return nil, nil, err + } objectName := protectObjectNameFromFile(filepath.Join(dir, n)) if !includeDefaults && isProtectDefaultObject(res.Name, objectName) { skipped = append(skipped, fmt.Sprintf("%s/%s: a tenant default, already present in the target (--include-defaults to apply anyway)", res.Name, objectName)) diff --git a/internal/commands/protect_plans.go b/internal/commands/protect_plans.go index 78ae11ce..1803c163 100644 --- a/internal/commands/protect_plans.go +++ b/internal/commands/protect_plans.go @@ -226,6 +226,16 @@ func newProtectPlansDeleteCmd(cliCtx *registry.CLIContext) *cobra.Command { return cmd } +// planConfigProfileFileName is the default file a plan's profile is saved to, +// the plan name unchanged. A name that is not one path segment is refused, so +// the default can never leave the working directory. +func planConfigProfileFileName(plan string) (string, error) { + if plan == "" || plan == "." || plan == ".." || strings.ContainsAny(plan, `/\`) { + return "", fmt.Errorf("plan name %q cannot be used as a file name in the working directory; pass -O/--output to choose where the profile is saved", plan) + } + return plan + ".mobileconfig", nil +} + func newProtectPlansConfigProfileCmd(cliCtx *registry.CLIContext) *cobra.Command { var ( outPath string @@ -250,6 +260,13 @@ By default, all payload components are included. Use --no-* flags to exclude specific payloads. Use --sign to cryptographically sign the profile.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if outPath == "" { + name, err := planConfigProfileFileName(args[0]) + if err != nil { + return err + } + outPath = name + } ctx := cmd.Context() r := protect.NewResolver(cliCtx.ProtectClient) @@ -287,9 +304,6 @@ exclude specific payloads. Use --sign to cryptographically sign the profile.`, return fmt.Errorf("decoding profile: %w", err) } - if outPath == "" { - outPath = fmt.Sprintf("%s.mobileconfig", args[0]) - } if err := os.WriteFile(outPath, decoded, 0o644); err != nil { return fmt.Errorf("writing profile: %w", err) } diff --git a/internal/commands/protect_plans_config_profile_test.go b/internal/commands/protect_plans_config_profile_test.go new file mode 100644 index 00000000..8bd2dbf2 --- /dev/null +++ b/internal/commands/protect_plans_config_profile_test.go @@ -0,0 +1,21 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "strings" + "testing" +) + +func TestPlanConfigProfileFileName_StaysInTheWorkingDirectory(t *testing.T) { + for _, plan := range []string{"Default", "My Plan: v2", "..hidden", "a.b"} { + if got, err := planConfigProfileFileName(plan); err != nil || got != plan+".mobileconfig" { + t.Errorf("planConfigProfileFileName(%q) = %q, %v; an ordinary plan name must be kept byte-for-byte", plan, got, err) + } + } + for _, plan := range []string{"../../.ssh/authorized_keys", "/etc/passwd", "/", "a/b", `a\b`, `..\x`, "", ".", ".."} { + if got, err := planConfigProfileFileName(plan); err == nil || !strings.Contains(err.Error(), "-O/--output") { + t.Errorf("planConfigProfileFileName(%q) = %q, %v; a name that is not one path segment must be refused, naming -O/--output", plan, got, err) + } + } +} diff --git a/internal/commands/protect_ulf.go b/internal/commands/protect_ulf.go index 4c470b54..3cdb2c43 100644 --- a/internal/commands/protect_ulf.go +++ b/internal/commands/protect_ulf.go @@ -244,6 +244,11 @@ Use --file for a single YAML file or --dir for a directory of YAML files.`, if len(files) == 0 { return fmt.Errorf("no YAML files found") } + for _, f := range files { + if err := refuseMCPChildReadOutsideInputDir(f); err != nil { + return err + } + } // Build name->UUID map for upsert detection existing, err := cliCtx.ProtectClient.ListUnifiedLoggingFilters(ctx) From f77cc3278cca413e5efebbee9885e42140ed610c Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 16:02:16 -0500 Subject: [PATCH 06/25] fix(mcp): refuse the commands that print an access token run_command still returned a live bearer token from `platform auth token`, `pro auth token`, `protect auth token` and `pro api-authentication token`, `oauth-token` and `keep-alive`. The token works outside mcp serve and every refusal it applies until it expires, so the pinned profile stops mattering once the model holds one. All six are now refused with a reason that names the token; `invalidate-token` prints none and stays allowed. CHANGELOG records the breaking MCP boundary change with --input-dir as the migration, and the protect plans config-profile plan-name refusal. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 43 +++++++++++++++++++ internal/commands/agent_context.md | 4 +- internal/commands/mcp.go | 12 ++++++ .../commands/mcp_run_command_boundary_test.go | 6 +++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdffab73..931da92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,49 @@ commit types the repo already uses (`feat!`/`build!` for a breaking change). ## Unreleased +### Breaking — MCP `run_command` refuses local paths and credential output unless the operator allows them + +`run_command` used to compare the model's raw argument list against a short +deny-list. A command alias (`cfg`), a leading flag (`--no-color multi`) or a +flag inside the path (`pro -q backup`) got past it. Flags that name a local +file were not on the list, so the model could read, write or delete files on +the machine running `mcp serve`, and `pro diff --target ` used +another profile's credentials. + +The server now judges the command and the flags that cobra resolves, and the +child process checks again before it runs. These now fail over MCP: + +- A flag whose value is a local file to read (`--from-file`, `--file`, + `--script-file`, `--input` and the like), unless the path is inside the + directory the operator passes to `mcp serve --input-dir `. + `--password-file` is always refused. +- A flag whose value is a local path to write (`--save-to`, a command's own + `--output`, `--report-dir`, `--dir` on `sync`). The `-o/--output` format + flag is not affected. +- `pro diff` with a side that is neither the server's profile nor a directory + inside `--input-dir`. +- `multi`, `mcp`, `completion`, the `config` write subcommands, + `config validate`, `doctor`, every `setup`, both `backup` commands and + `jcds sync`. +- The commands that print an access token: `auth token` under `platform`, + `pro` and `protect`, and `pro api-authentication token`, `oauth-token` and + `keep-alive`. + +`config show` still runs over MCP, with each token, client ID and client +secret shown as ``. `config list --status` checks only the server's +profile. + +**Migration:** if an agent sends file bodies through `run_command` (for +example `pro scripts create --script-file …`), start the server with +`mcp serve --input-dir ` and keep those files in that directory. + +### Behaviour — `protect plans config-profile` refuses a plan name that is not a file name + +Without `-O`, the command saves `.mobileconfig` in the working +directory. It now refuses a plan name that contains `/` or `\`, or is empty, +`.` or `..`, because that name would put the file somewhere else. Pass +`-O ` for such a plan. Every other name is saved as before. + ### Behaviour — the MCP `list_commands` tool browses and searches the catalog `list_commands` returned the whole catalog in one result. That result was diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 570ebc95..38360e51 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -108,7 +108,9 @@ judged on the command and flags your arguments resolve to, aliases included. Rejected: credential- and target-selecting flags; flags whose value is a local file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, `--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, -`completion`, the config write subcommands, `config validate`, `doctor`, every +`completion`, the config write subcommands, `config validate`, `doctor`, the +commands that print an access token (`auth token` under `platform`, `pro` and +`protect`; `pro api-authentication token`, `oauth-token` and `keep-alive`), every `setup`, both `backup` commands and jcds `sync`; and `pro diff` against anything but the pinned profile. `config show` runs with every credential field shown as ``. An administrator who diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index a91e63ad..0bffef86 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -100,6 +100,9 @@ spelling. It refuses: credentials and probe other profiles' URLs ('config show' runs, with each token, client ID and client secret shown as , and 'config list --status' checks only this server's profile) + - 'auth token' under 'platform', 'pro' and 'protect', and 'pro + api-authentication token', 'oauth-token' and 'keep-alive', which print a + live access token - any flag naming a profile, URL, token, tenant, environment or output file - any flag whose value is a local path: --from-file, --file, --script-file, --mobileconfig-file, --appconfig-file, --custom-payload-file, --body-file, @@ -191,6 +194,8 @@ config key for it.`, "directory (--from-file, --file, --script-file, --save-to, --dir and the " + "like; the -o/--output format flag is fine); 'multi', 'mcp', 'completion', " + "the config write subcommands, 'config validate', 'doctor', " + + "every command that prints an access token ('auth token', " + + "'pro api-authentication token', 'oauth-token', 'keep-alive'), " + "every 'setup', the backup commands and jcds sync; and " + "'pro diff' against anything but this server's profile or a directory the " + "input directory allows. " + inputDirToolNote(inputDir) + @@ -525,6 +530,12 @@ var mcpRefusedCommands = []refusedCommand{ {"jamf-cli config set-report-dir", refusedPicksTarget}, {"jamf-cli config validate", refusedReadsCredentials}, {"jamf-cli doctor", refusedReadsCredentials}, + {"jamf-cli platform auth token", refusedPrintsToken}, + {"jamf-cli pro auth token", refusedPrintsToken}, + {"jamf-cli protect auth token", refusedPrintsToken}, + {"jamf-cli pro api-authentication token", refusedPrintsToken}, + {"jamf-cli pro api-authentication oauth-token", refusedPrintsToken}, + {"jamf-cli pro api-authentication keep-alive", refusedPrintsToken}, {"jamf-cli pro setup", refusedPicksTarget}, {"jamf-cli platform setup", refusedPicksTarget}, {"jamf-cli protect setup", refusedPicksTarget}, @@ -543,6 +554,7 @@ type refusedCommand struct { const ( refusedPicksTarget = "it selects its own instance or writes to a path of its own, so the profile this server was started with cannot pin it" refusedReadsCredentials = "it resolves or reports the credentials of profiles other than the one this server is pinned to, and probes their URLs" + refusedPrintsToken = "it prints a live access token, which works outside this server and every refusal it applies until it expires" ) // isCompletionRequest reports whether name is cobra's hidden completion diff --git a/internal/commands/mcp_run_command_boundary_test.go b/internal/commands/mcp_run_command_boundary_test.go index dd08b6fe..bf1aebf3 100644 --- a/internal/commands/mcp_run_command_boundary_test.go +++ b/internal/commands/mcp_run_command_boundary_test.go @@ -45,6 +45,12 @@ var refusedCommandSpellings = []struct { {[]string{"completion", "install"}, "jamf-cli completion install"}, {[]string{"completion", "zsh"}, "jamf-cli completion zsh"}, {[]string{"--no-color", "completion"}, "jamf-cli completion"}, + {[]string{"platform", "auth", "token", "-o", "json"}, "jamf-cli platform auth token"}, + {[]string{"pro", "auth", "token"}, "jamf-cli pro auth token"}, + {[]string{"-q", "protect", "auth", "token"}, "jamf-cli protect auth token"}, + {[]string{"pro", "api-authentication", "token"}, "jamf-cli pro api-authentication token"}, + {[]string{"pro", "api-authentication", "oauth-token"}, "jamf-cli pro api-authentication oauth-token"}, + {[]string{"pro", "-o", "json", "api-authentication", "keep-alive"}, "jamf-cli pro api-authentication keep-alive"}, } func TestBuildChildArgs_RefusesEveryResolvedSpellingOfARefusedCommand(t *testing.T) { From a5ba4142a49c2a8c49d06aeb1528fe0c2f4a2d55 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 21:28:51 -0500 Subject: [PATCH 07/25] test(mcp): third-party secrets and minted credentials reach the model protect action-configs get/apply print report-client header values, data-forwarding get/update print the Sentinel shared key, and api-clients get prints its password field in an MCP child. action-configs export, pro api-integrations client-credentials and protect api-clients apply are not refused by run_command. Co-Authored-By: Claude Opus 5.5 --- .../mcp_protect_report_client_secret_test.go | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 internal/commands/mcp_protect_report_client_secret_test.go diff --git a/internal/commands/mcp_protect_report_client_secret_test.go b/internal/commands/mcp_protect_report_client_secret_test.go new file mode 100644 index 00000000..395e46d2 --- /dev/null +++ b/internal/commands/mcp_protect_report_client_secret_test.go @@ -0,0 +1,182 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +const ( + fakeReportClientBearer = "Bearer report-client-secret-7f3a" + fakeSentinelSharedKey = "sentinel-shared-key-9c1d" + fakeAPIClientPassword = "protect-api-client-password-41be" +) + +const fakeActionConfigJSON = `{"id":"ac-1","name":"siem","clients":[{"id":"c-1","type":"Http","supportedReports":["AlertV2"],"params":{"headers":[{"header":"Authorization","value":"` + fakeReportClientBearer + `"}],"method":"POST","url":"https://siem.example.invalid/ingest"}}]}` + +const fakeDataForwardingJSON = `{"uuid":"org-1","forward":{"sentinel":{"enabled":true,"customerId":"cust-1","sharedKey":"` + fakeSentinelSharedKey + `","logType":"jamf","domain":"ods.opinsights.azure.com"}}}` + +func newFakeProtectServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body, _ := io.ReadAll(r.Body) + q := string(body) + switch { + case r.URL.Path == "/token": + _, _ = io.WriteString(w, `{"access_token":"fake-protect-token","expires_in":3600,"token_type":"Bearer"}`) + case strings.Contains(q, "updateActionConfigs"): + _, _ = io.WriteString(w, `{"data":{"updateActionConfigs":`+fakeActionConfigJSON+`}}`) + case strings.Contains(q, "listActionConfigs"): + _, _ = io.WriteString(w, `{"data":{"listActionConfigs":{"items":[{"id":"ac-1","name":"siem"}],"pageInfo":{"next":null,"total":1}}}}`) + case strings.Contains(q, "getActionConfigs"): + _, _ = io.WriteString(w, `{"data":{"getActionConfigs":`+fakeActionConfigJSON+`}}`) + case strings.Contains(q, "updateOrganizationForward"): + _, _ = io.WriteString(w, `{"data":{"updateOrganizationForward":`+fakeDataForwardingJSON+`}}`) + case strings.Contains(q, "sharedKey"): + _, _ = io.WriteString(w, `{"data":{"getOrganization":`+fakeDataForwardingJSON+`}}`) + case strings.Contains(q, "listApiClients"): + _, _ = io.WriteString(w, `{"data":{"listApiClients":{"items":[{"clientId":"cid-1","name":"agent","created":"","assignedRoles":[],"password":""}],"pageInfo":{"next":null,"total":1}}}}`) + case strings.Contains(q, "getApiClient"): + _, _ = io.WriteString(w, `{"data":{"getApiClient":{"clientId":"cid-1","name":"agent","created":"","assignedRoles":[],"password":"`+fakeAPIClientPassword+`"}}}`) + default: + t.Errorf("unexpected Protect request %s: %s", r.URL.Path, q) + _, _ = io.WriteString(w, `{"data":{}}`) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// useFakeProtect points the Protect credentials at the fake server with an +// isolated HOME, so the SDK's token cache (os.UserCacheDir ignores +// XDG_CACHE_HOME on darwin) stays out of the operator's home. +func useFakeProtect(t *testing.T) { + t.Helper() + srv := newFakeProtectServer(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("JAMFPROTECT_URL", srv.URL) + t.Setenv("JAMFPROTECT_CLIENT_ID", "fake-client-id") + t.Setenv("JAMFPROTECT_CLIENT_SECRET", "fake-client-secret") +} + +func runProtectAsMCPChild(t *testing.T, args ...string) (string, error) { + t.Helper() + useFakeProtect(t) + var err error + out := captureStdout(t, func() { + err = executeAsMCPChild(t, "", append([]string{"--no-input", "-o", "json"}, args...)...) + }) + return out, err +} + +// writeMCPInput writes body into a fresh directory the MCP child is told it may +// read from, and returns the file's path. +func writeMCPInput(t *testing.T, body string) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "input.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(mcpInputDirEnvVar, dir) + return path +} + +func TestMCP_ProtectThirdPartySecretsDoNotReachTheModel(t *testing.T) { + for _, tc := range []struct { + name string + args func(t *testing.T) []string + secret string + }{ + {"action-configs get", func(*testing.T) []string { return []string{"protect", "action-configs", "get", "siem"} }, fakeReportClientBearer}, + {"action-configs apply", func(t *testing.T) []string { + return []string{"protect", "action-configs", "apply", "--yes", "--from-file", writeMCPInput(t, `{"name":"siem","description":""}`)} + }, fakeReportClientBearer}, + {"data-forwarding get", func(*testing.T) []string { return []string{"protect", "data-forwarding", "get"} }, fakeSentinelSharedKey}, + {"data-forwarding update", func(t *testing.T) []string { + return []string{"protect", "data-forwarding", "update", "--from-file", writeMCPInput(t, `{}`)} + }, fakeSentinelSharedKey}, + {"api-clients get", func(*testing.T) []string { return []string{"protect", "api-clients", "get", "agent"} }, fakeAPIClientPassword}, + } { + t.Run(tc.name, func(t *testing.T) { + args := tc.args(t) + // The server judges --from-file against its own --input-dir, which this test + // does not start; the child judges it against the directory writeMCPInput set. + if !slices.Contains(args, "--from-file") { + if _, err := buildChildArgs("prod", args); err != nil { + t.Fatalf("%q is an inspection path the operator keeps over MCP: %v", args, err) + } + } + out, err := runProtectAsMCPChild(t, args...) + if err != nil { + t.Fatalf("%q failed in the MCP child: %v\n%s", args, err, out) + } + if strings.Contains(out, tc.secret) { + t.Errorf("%q printed a credential to the model in an MCP child:\n%s", args, out) + } + if !strings.Contains(out, protectRedacted) { + t.Errorf("%q should mark the withheld value as %s, so the model knows one is set:\n%s", args, protectRedacted, out) + } + }) + } + + out, err := runProtectAsMCPChild(t, "protect", "action-configs", "list") + if err != nil || !strings.Contains(out, `"siem"`) { + t.Errorf("control: `protect action-configs list` must still work in an MCP child (err %v):\n%s", err, out) + } +} + +func TestProtectActionConfigsGet_OutsideMCPPrintsTheHeaderValue(t *testing.T) { + useFakeProtect(t) + resetGlobals() + t.Cleanup(resetGlobals) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("JAMF_CLI_ARGS", "") + t.Setenv(mcpChildEnvVar, "") + var err error + out := captureStdout(t, func() { + root := NewRootCmd("test", "abc123", "2024-01-01", "unknown") + root.SetArgs([]string{"--no-input", "-o", "json", "protect", "action-configs", "get", "siem"}) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + err = root.Execute() + }) + if err != nil || !strings.Contains(out, fakeReportClientBearer) { + t.Errorf("outside an MCP child the operator's own output is unchanged and carries the header value (err %v):\n%s", err, out) + } +} + +func TestMCP_RefusesCommandsWhoseOutputIsACredential(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"protect", "action-configs", "export", "siem"}, "header"}, + {[]string{"protect", "ac", "export", "siem"}, "header"}, + {[]string{"pro", "api-integrations", "client-credentials", "7"}, "client secret"}, + {[]string{"pro", "ai", "client-credentials", "--name", "x"}, "client secret"}, + {[]string{"protect", "api-clients", "apply"}, "password"}, + {[]string{"-o", "json", "protect", "apic", "apply"}, "password"}, + } { + _, err := buildChildArgs("prod", tc.args) + if !isMCPRefusal(err) { + t.Errorf("run_command accepts %q (err %v); its output carries a credential the model must not receive", tc.args, err) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("refusal of %q should name the %s it withholds: %v", tc.args, tc.want, err) + } + } +} From 41f1f70f92633eca3ea67fc9068bed1463f29926 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Wed, 30 Sep 2026 21:32:28 -0500 Subject: [PATCH 08/25] fix(mcp): keep third-party secrets and minted credentials from the model run_command now refuses protect action-configs export (an apply-ready document whose redacted copy would overwrite the real header values), pro api-integrations client-credentials and protect api-clients apply, which mint a credential and print it. In an MCP child, protect action-configs get and apply print each report client's header values as , data-forwarding get and update the Sentinel shared key, and api-clients get the password. Outside MCP the output is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++- internal/commands/agent_context.md | 13 +++++-- internal/commands/mcp.go | 25 +++++++++++-- .../mcp_protect_report_client_secret_test.go | 21 ++++++++++- internal/commands/protect_action_configs.go | 37 +++++++++++++++++-- internal/commands/protect_api_clients.go | 5 +++ internal/commands/protect_org.go | 13 ++++++- 7 files changed, 111 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 931da92a..3b75138a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,10 +38,19 @@ child process checks again before it runs. These now fail over MCP: - The commands that print an access token: `auth token` under `platform`, `pro` and `protect`, and `pro api-authentication token`, `oauth-token` and `keep-alive`. +- The commands that mint a credential and print it: + `pro api-integrations client-credentials` (a new client secret) and + `protect api-clients apply` (a new API client's password). +- `protect action-configs export`, whose document carries each report + client's header values, such as a SIEM or webhook bearer token. A redacted + copy would overwrite the real credential when applied. `config show` still runs over MCP, with each token, client ID and client secret shown as ``. `config list --status` checks only the server's -profile. +profile. These Protect commands also run over MCP with the credential shown as +``: `action-configs get` and `apply` (each report client's header +values), `data-forwarding get` and `update` (the Sentinel shared key) and +`api-clients get` (the password). Outside MCP their output is unchanged. **Migration:** if an agent sends file bodies through `run_command` (for example `pro scripts create --script-file …`), start the server with diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 38360e51..1c60b232 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -110,10 +110,15 @@ file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, `--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, `completion`, the config write subcommands, `config validate`, `doctor`, the commands that print an access token (`auth token` under `platform`, `pro` and -`protect`; `pro api-authentication token`, `oauth-token` and `keep-alive`), every -`setup`, both `backup` commands and jcds `sync`; and `pro diff` against -anything but the pinned profile. `config show` runs with every credential field -shown as ``. An administrator who +`protect`; `pro api-authentication token`, `oauth-token` and `keep-alive`), the +commands that mint and print a credential (`pro api-integrations +client-credentials`, `protect api-clients apply`), `protect action-configs +export`, every `setup`, both `backup` commands and jcds `sync`; and `pro diff` +against anything but the pinned profile. `config show` runs with every +credential field shown as ``, and so do the report-client header +values of `protect action-configs get` and `apply`, the Sentinel shared key of +`protect data-forwarding get` and `update`, and the password of `protect +api-clients get`. An administrator who starts the server with `--input-dir ` allows the read-side flags, and a `pro diff` side that is a directory, for existing paths inside that directory; `run_command`'s description names it. diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 0bffef86..e4de5fc5 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -103,6 +103,10 @@ spelling. It refuses: - 'auth token' under 'platform', 'pro' and 'protect', and 'pro api-authentication token', 'oauth-token' and 'keep-alive', which print a live access token + - 'pro api-integrations client-credentials' and 'protect api-clients + apply', which mint a new client secret or password and print it + - 'protect action-configs export', whose document carries each report + client's header values, the SIEM or webhook credential, verbatim - any flag naming a profile, URL, token, tenant, environment or output file - any flag whose value is a local path: --from-file, --file, --script-file, --mobileconfig-file, --appconfig-file, --custom-payload-file, --body-file, @@ -111,6 +115,10 @@ spelling. It refuses: as --input-dir allows below - 'pro diff' with a --source or --target that is neither this server's profile nor a directory inside --input-dir +Some allowed commands print a third-party credential, shown as +here: the report-client header values of 'protect action-configs get' and +'apply', the Sentinel shared key of 'protect data-forwarding get' and 'update', +and the password of 'protect api-clients get'. Some allowed commands write a file into the directory this server was started in, named by Jamf or by the command's own argument: 'protect downloads' and 'pro jcds download' without -O, and 'protect plans config-profile'. Start the @@ -196,11 +204,16 @@ config key for it.`, "the config write subcommands, 'config validate', 'doctor', " + "every command that prints an access token ('auth token', " + "'pro api-authentication token', 'oauth-token', 'keep-alive'), " + + "the commands that mint and print a credential ('pro api-integrations " + + "client-credentials', 'protect api-clients apply'), " + + "'protect action-configs export', " + "every 'setup', the backup commands and jcds sync; and " + "'pro diff' against anything but this server's profile or a directory the " + "input directory allows. " + inputDirToolNote(inputDir) + " Use generate_report rather than 'dashboard': this tool returns " + "stdout as text and the dashboard writes a 320-800 KB HTML document there. " + + "Report-client header values, the Sentinel shared key and Protect API " + + "client passwords print as . " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + "require an explicit --yes in args or they will refuse to run.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in runCommandInput) (*mcp.CallToolResult, any, error) { @@ -536,6 +549,9 @@ var mcpRefusedCommands = []refusedCommand{ {"jamf-cli pro api-authentication token", refusedPrintsToken}, {"jamf-cli pro api-authentication oauth-token", refusedPrintsToken}, {"jamf-cli pro api-authentication keep-alive", refusedPrintsToken}, + {"jamf-cli pro api-integrations client-credentials", refusedMintsClientSecret}, + {"jamf-cli protect api-clients apply", refusedMintsProtectPassword}, + {"jamf-cli protect action-configs export", refusedExportsHeaders}, {"jamf-cli pro setup", refusedPicksTarget}, {"jamf-cli platform setup", refusedPicksTarget}, {"jamf-cli protect setup", refusedPicksTarget}, @@ -552,9 +568,12 @@ type refusedCommand struct { } const ( - refusedPicksTarget = "it selects its own instance or writes to a path of its own, so the profile this server was started with cannot pin it" - refusedReadsCredentials = "it resolves or reports the credentials of profiles other than the one this server is pinned to, and probes their URLs" - refusedPrintsToken = "it prints a live access token, which works outside this server and every refusal it applies until it expires" + refusedPicksTarget = "it selects its own instance or writes to a path of its own, so the profile this server was started with cannot pin it" + refusedReadsCredentials = "it resolves or reports the credentials of profiles other than the one this server is pinned to, and probes their URLs" + refusedPrintsToken = "it prints a live access token, which works outside this server and every refusal it applies until it expires" + refusedMintsClientSecret = "it mints a new client secret for the API integration and prints it, a credential that works outside this server until it is rotated" + refusedMintsProtectPassword = "creating an API client mints a new password and prints it, a credential that works outside this server until the client is deleted" + refusedExportsHeaders = "its document carries each report client's header values verbatim (the SIEM or webhook bearer token), and a redacted copy would overwrite the real credential when applied; 'protect action-configs get' shows the configuration with them redacted" ) // isCompletionRequest reports whether name is cobra's hidden completion diff --git a/internal/commands/mcp_protect_report_client_secret_test.go b/internal/commands/mcp_protect_report_client_secret_test.go index 395e46d2..be07a6da 100644 --- a/internal/commands/mcp_protect_report_client_secret_test.go +++ b/internal/commands/mcp_protect_report_client_secret_test.go @@ -11,6 +11,8 @@ import ( "slices" "strings" "testing" + + "github.com/Jamf-Concepts/jamfprotect-go-sdk/jamfprotect" ) const ( @@ -125,7 +127,7 @@ func TestMCP_ProtectThirdPartySecretsDoNotReachTheModel(t *testing.T) { if strings.Contains(out, tc.secret) { t.Errorf("%q printed a credential to the model in an MCP child:\n%s", args, out) } - if !strings.Contains(out, protectRedacted) { + if !strings.Contains(out, protectRedacted) && !strings.Contains(out, `\u003credacted\u003e`) { t.Errorf("%q should mark the withheld value as %s, so the model knows one is set:\n%s", args, protectRedacted, out) } }) @@ -180,3 +182,20 @@ func TestMCP_RefusesCommandsWhoseOutputIsACredential(t *testing.T) { } } } + +func TestRedactReportClientHeaders_LeavesTheFetchedConfigIntact(t *testing.T) { + original := jamfprotect.ActionConfig{Clients: []jamfprotect.ReportClient{ + {Params: jamfprotect.ReportClientParams{Headers: []jamfprotect.ReportClientHeader{{Header: "Authorization", Value: fakeReportClientBearer}, {Header: "X-Empty"}}}}, + {Type: "JamfCloud"}, + }} + got := redactReportClientHeaders(original) + if v := original.Clients[0].Params.Headers[0].Value; v != fakeReportClientBearer { + t.Errorf("redaction wrote through to the caller's config: %q", v) + } + if h := got.Clients[0].Params.Headers; h[0].Value != protectRedacted || h[0].Header != "Authorization" || h[1].Value != "" { + t.Errorf("want the header name kept, a set value redacted and an unset one left empty: %+v", h) + } + if got.Clients[1].Params.Headers != nil { + t.Errorf("a client with no headers should still print null, not []: %+v", got.Clients[1].Params.Headers) + } +} diff --git a/internal/commands/protect_action_configs.go b/internal/commands/protect_action_configs.go index d9048cd4..58e33070 100644 --- a/internal/commands/protect_action_configs.go +++ b/internal/commands/protect_action_configs.go @@ -68,7 +68,7 @@ func newProtectActionConfigsGetCmd(cliCtx *registry.CLIContext) *cobra.Command { if err != nil { return err } - return protect.PrintOne(cliCtx.Output, item) + return printActionConfig(cliCtx.Output, item) }, } } @@ -110,7 +110,7 @@ func newProtectActionConfigsApplyCmd(cliCtx *registry.CLIContext) *cobra.Command return err } fmt.Fprintf(os.Stderr, "Created action configuration %q\n", input.Name) - return protect.PrintOne(cliCtx.Output, result) + return printActionConfig(cliCtx.Output, &result) } // Found — confirm before replacing @@ -127,7 +127,7 @@ func newProtectActionConfigsApplyCmd(cliCtx *registry.CLIContext) *cobra.Command return err } fmt.Fprintf(os.Stderr, "Updated action configuration %q\n", input.Name) - return protect.PrintOne(cliCtx.Output, result) + return printActionConfig(cliCtx.Output, &result) }, } cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to JSON input file (or pipe JSON to stdin)") @@ -194,6 +194,37 @@ func newProtectActionConfigsExportCmd(cliCtx *registry.CLIContext) *cobra.Comman } } +// printActionConfig prints an action configuration. In a child of `mcp serve` +// each report client's header values are shown as "": they hold the +// bearer token or API key the tenant forwards alerts to a SIEM or webhook with. +func printActionConfig(out registry.OutputFormatter, a *jamfprotect.ActionConfig) error { + if a != nil && os.Getenv(mcpChildEnvVar) == "1" { + a = redactReportClientHeaders(*a) + } + return protect.PrintOne(out, a) +} + +func redactReportClientHeaders(a jamfprotect.ActionConfig) *jamfprotect.ActionConfig { + if a.Clients != nil { + clients := make([]jamfprotect.ReportClient, len(a.Clients)) + for i, c := range a.Clients { + if c.Params.Headers != nil { + headers := make([]jamfprotect.ReportClientHeader, len(c.Params.Headers)) + for j, h := range c.Params.Headers { + if h.Value != "" { + h.Value = protectRedacted + } + headers[j] = h + } + c.Params.Headers = headers + } + clients[i] = c + } + a.Clients = clients + } + return &a +} + // actionConfigToInput converts an ActionConfig response to an ActionConfigInput, stripping server-only fields. // AlertConfig and Clients use map[string]any in the input type, so we marshal/unmarshal to convert. func actionConfigToInput(a *jamfprotect.ActionConfig) (jamfprotect.ActionConfigInput, error) { diff --git a/internal/commands/protect_api_clients.go b/internal/commands/protect_api_clients.go index a6b6c302..21267947 100644 --- a/internal/commands/protect_api_clients.go +++ b/internal/commands/protect_api_clients.go @@ -88,6 +88,11 @@ func newProtectApiClientsGetCmd(cliCtx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if os.Getenv(mcpChildEnvVar) == "1" && item.Password != "" { + redacted := *item + redacted.Password = protectRedacted + item = &redacted + } return printResult(cliCtx.Output, item, flattenApiClient(*item)) }, } diff --git a/internal/commands/protect_org.go b/internal/commands/protect_org.go index a4d919ab..2c7e653a 100644 --- a/internal/commands/protect_org.go +++ b/internal/commands/protect_org.go @@ -45,11 +45,20 @@ func newProtectDataForwardingGetCmd(cliCtx *registry.CLIContext) *cobra.Command if err != nil { return err } - return protect.PrintOne(cliCtx.Output, item) + return printDataForwarding(cliCtx.Output, item) }, } } +// printDataForwarding prints the forwarding settings, with the Sentinel shared +// key shown as "" in a child of `mcp serve`. +func printDataForwarding(out registry.OutputFormatter, r jamfprotect.DataForwardingResult) error { + if os.Getenv(mcpChildEnvVar) == "1" { + r = redactDataForwarding(r) + } + return protect.PrintOne(out, r) +} + func newProtectDataForwardingUpdateCmd(cliCtx *registry.CLIContext) *cobra.Command { var fromFile string @@ -70,7 +79,7 @@ func newProtectDataForwardingUpdateCmd(cliCtx *registry.CLIContext) *cobra.Comma if err != nil { return err } - return protect.PrintOne(cliCtx.Output, item) + return printDataForwarding(cliCtx.Output, item) }, } From 9ca8c830d9a133ab1635a1a054f1650c8a9df3a9 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 08:26:26 -0500 Subject: [PATCH 09/25] test(mcp): credential printers and Classic secret fields reach the model A tree walk fails on any leaf whose name or help names a token, secret, credential, password or private key and that is neither refused over MCP nor exempted with a reason. Four leaves fail it today: the SSO session token reader, the cloud distribution point patch (its CloudFront signing key is readable), and the two commands that set a Jamf Pro login password to a value the model chooses. A sweep over every string-typed credential field the Classic generator refuses for --set shows that an MCP-child get prints each one verbatim, in every output format. The same reads outside MCP must stay byte-identical. Also pins the nice-to-have fixes: the Protect .p12 downloads, an empty --input-dir, the --output and relative-path refusal wording, the plan-name remedy over MCP, and report-client URL userinfo and query. Co-Authored-By: Claude Opus 5.5 --- internal/commands/mcp_refusal_wording_test.go | 100 ++++++++ internal/commands/mcp_secret_leaves_test.go | 236 ++++++++++++++++++ .../generated/classic_read_redaction_test.go | 177 +++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 internal/commands/mcp_refusal_wording_test.go create mode 100644 internal/commands/mcp_secret_leaves_test.go create mode 100644 internal/commands/pro/generated/classic_read_redaction_test.go diff --git a/internal/commands/mcp_refusal_wording_test.go b/internal/commands/mcp_refusal_wording_test.go new file mode 100644 index 00000000..199c60a8 --- /dev/null +++ b/internal/commands/mcp_refusal_wording_test.go @@ -0,0 +1,100 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "strings" + "testing" + + "github.com/Jamf-Concepts/jamfprotect-go-sdk/jamfprotect" +) + +func TestMCPServe_RefusesAnEmptyInputDir(t *testing.T) { + resetGlobals() + t.Cleanup(resetGlobals) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("JAMF_CLI_ARGS", "") + t.Setenv(mcpChildEnvVar, "") + for _, k := range []string{"JAMF_PROFILE", "JAMF_URL", "JAMF_TOKEN", "JAMF_CLIENT_ID", "JAMF_CLIENT_SECRET"} { + t.Setenv(k, "") + } + for _, args := range [][]string{ + {"mcp", "serve", "--input-dir", ""}, + {"mcp", "serve", "--input-dir="}, + } { + root := NewRootCmd("test", "t", "t", "t") + root.SetArgs(args) + root.SetOut(&strings.Builder{}) + root.SetErr(&strings.Builder{}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "--input-dir") || !strings.Contains(err.Error(), "empty") { + t.Errorf("%q = %v; an empty --input-dir (an unset $DIR) must be refused by name, not start a server that allows no reads", args, err) + } + } +} + +func TestBuildChildArgs_LeafOutputRefusalPointsAtTheFormatFlag(t *testing.T) { + _, err := buildChildArgs("prod", []string{"protect", "plans", "config-profile", "Default", "-O", modelChosenPath}) + if !isMCPRefusal(err) { + t.Fatalf("a leaf's own --output must be refused: %v", err) + } + if !strings.Contains(err.Error(), "-o ") { + t.Errorf("the refusal should say the global -o flag is still accepted: %v", err) + } + _, err = buildChildArgs("prod", []string{"pro", "packages", "export", "-O", modelChosenPath}) + if err == nil || strings.Contains(err.Error(), "-o ") { + t.Errorf("--save-to has no format alternative, so its refusal must not offer one: %v", err) + } +} + +func TestBuildChildArgs_RelativePathOutsideTheInputDirAsksForAnAbsoluteOne(t *testing.T) { + fx := newInputDirFixture(t) + t.Chdir(fx.adjacentDir) + _, err := buildChildArgs("prod", []string{"pro", "classic-policies", "create", "--from-file", "../body.xml"}) + if !isMCPRefusal(err) { + t.Fatalf("a relative path resolving outside the input directory must be refused: %v", err) + } + if !strings.Contains(err.Error(), "absolute path") || !strings.Contains(err.Error(), "started in") { + t.Errorf("the refusal should say a relative path resolves against the server's start directory and ask for an absolute path: %v", err) + } + _, err = buildChildArgs("prod", []string{"pro", "classic-policies", "create", "--from-file", fx.outside}) + if err == nil || strings.Contains(err.Error(), "absolute path") { + t.Errorf("an absolute path outside the input directory needs no hint about relative paths: %v", err) + } +} + +func TestPlanConfigProfileFileName_OverMCPNamesNoRefusedFlag(t *testing.T) { + t.Setenv(mcpChildEnvVar, "1") + _, err := planConfigProfileFileName("a/b") + if err == nil { + t.Fatal("a plan name that is not one path segment must still be refused") + } + if strings.Contains(err.Error(), "-O") { + t.Errorf("over MCP the refusal must not suggest -O, which run_command refuses: %v", err) + } + if !strings.Contains(err.Error(), "run_command") { + t.Errorf("over MCP the refusal should say the plan cannot be saved through run_command: %v", err) + } +} + +func TestRedactReportClientHeaders_MasksURLUserinfoAndQuery(t *testing.T) { + for _, tc := range []struct{ in, want, secret string }{ + {"https://user:hunter2@hec.example.invalid:8088/services/collector?token=abc123", "https://@hec.example.invalid:8088/services/collector?", "hunter2"}, + {"https://teams.example.invalid/webhook?sig=abc123&x=1", "https://teams.example.invalid/webhook?", "abc123"}, + {"https://siem.example.invalid/ingest", "https://siem.example.invalid/ingest", ""}, + {"", "", ""}, + } { + a := jamfprotect.ActionConfig{Clients: []jamfprotect.ReportClient{{Params: jamfprotect.ReportClientParams{URL: tc.in}}}} + got := redactReportClientHeaders(a).Clients[0].Params.URL + if got != tc.want { + t.Errorf("redacted URL %q = %q, want %q", tc.in, got, tc.want) + } + if tc.secret != "" && strings.Contains(got, tc.secret) { + t.Errorf("redacted URL %q still carries %q", got, tc.secret) + } + if a.Clients[0].Params.URL != tc.in { + t.Errorf("redaction wrote through to the caller's config: %q", a.Clients[0].Params.URL) + } + } +} diff --git a/internal/commands/mcp_secret_leaves_test.go b/internal/commands/mcp_secret_leaves_test.go new file mode 100644 index 00000000..e2a3da1a --- /dev/null +++ b/internal/commands/mcp_secret_leaves_test.go @@ -0,0 +1,236 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +var namesASecret = regexp.MustCompile(`(?i)token|secret|credential|password|private.?key`) + +// proseNamingACredential are sentences the generators add to many Longs. Each +// names a credential without the command printing one, so it is removed before +// a leaf is judged and the leaf is still judged on everything else it says. +var proseNamingACredential = map[string]string{ + "Requires a profile pointed at a Jamf Pro instance (auth-method oauth2 or token).": "names the auth methods a profile can use", + "resolved from the access token": "says the gateway reads the organization from this server's own token", + "The credential must also be organization-scoped": "names the scope level this server's credential needs", +} + +const ( + exemptDeviceSecret = "allowed by the operator's decision: a secret of the pinned tenant, readable on purpose" + exemptSetsDeviceSecret = "allowed by the operator's decision: sets or clears a secret of the pinned tenant's devices, readable over MCP anyway" + exemptJCDS = "allowed by the operator's decision: upload credentials for the pinned tenant's JCDS bucket" + exemptWriteOnly = "sends a credential in its request body and prints none back: the spec declares that field writeOnly" + exemptClassicRead = "a Classic read: its credential fields print as in an MCP child" + exemptClassicWrite = "a Classic write: --set refuses credential fields, --from-file is held to --input-dir, and the response is the record ID" + exemptOpensURL = "prints or opens the product's web URL and makes no API request" +) + +// notACredentialPrinter are the leaves whose name or help names a token, +// secret, credential or password and that run over MCP anyway, each with the +// reason. Keyed by command path without the root name. A stale entry fails. +var notACredentialPrinter = map[string]string{ + "dashboard": "refused by the run_command handler; generate_report runs it and returns only a path", + + "pro computer-inventory view-recovery-lock-password": exemptDeviceSecret, + "pro local-admin-password password": exemptDeviceSecret, + "pro local-admin-password password-by-guid": exemptDeviceSecret, + "pro local-admin-password audit": exemptDeviceSecret, + "pro local-admin-password audit-by-guid": exemptDeviceSecret, + + "pro computer-inventory set-auto-admin-password": exemptSetsDeviceSecret, + "pro computer-inventory set-recovery-lock": exemptSetsDeviceSecret, + "pro local-admin-password set-password": exemptSetsDeviceSecret, + "pro mobile-devices clear-restrictions-password": exemptSetsDeviceSecret, + "pro mobile-devices patch": exemptSetsDeviceSecret, + + "pro jamf-cloud-distribution-service renew-credentials": exemptJCDS, + "pro jamf-cloud-distribution-service-files create": exemptJCDS, + + "pro adcs-settings patch": exemptWriteOnly, + "pro adcs-settings validate-client-certificate": exemptWriteOnly, + "pro computer-prestages update": exemptWriteOnly, + "pro digicert patch": exemptWriteOnly, + "pro digicert validate-client-certificate": exemptWriteOnly, + "pro distribution-point patch": exemptWriteOnly, + "pro distribution-point update": exemptWriteOnly, + "pro enrollment update": exemptWriteOnly, + "pro gsx-connection patch": exemptWriteOnly, + "pro gsx-connection update": exemptWriteOnly, + "pro smtp-server update": exemptWriteOnly, + "pro sso-settings cert update": exemptWriteOnly, + "pro sso-settings-cert update": exemptWriteOnly, + "pro sso-settings oidc-broker-config update": exemptWriteOnly, + "pro team-viewer-remote-administration patch": exemptWriteOnly, + "pro venafi patch": exemptWriteOnly, + "pro volume-purchasing-locations create": exemptWriteOnly, + "pro volume-purchasing-locations patch": exemptWriteOnly, + "pro device-enrollments create": "uploads an Automated Device Enrollment server token; the response is the instance, which carries only the token's expiry", + "pro jamf-pro-initialization initialize-database-connection": "sends the database password during first-run setup and prints nothing back", + + "pro adcs-settings get": "the spec says the response carries no password information", + "pro sso-settings oidc-broker-config get": "the spec says secret fields are never included in the response", + "pro api-authentication current": "prints the current token's authorization details, not the token", + "pro api-authentication list": "prints the current token's authorization details, not the token", + "pro api-authentication invalidate-token": "invalidates this server's own token and prints nothing", + "pro api-integrations update": "names the access token lifetime setting; client-credentials, which prints a secret, is refused", + "pro csa delete": "deletes the CSA token exchange and prints nothing", + "pro csa token delete": "deletes the CSA token exchange and prints nothing", + "pro csa token get": "prints the exchange's scopes, expiry and tenant; the spec has no token field", + "pro local-admin-password history": "who viewed or rotated the password and when; the spec has no password field", + "pro local-admin-password settings update": "LAPS rotation settings, not a password", + "pro local-admin-password update": "LAPS rotation settings, not a password", + "pro enrollment-languages update": "the password field is the prompt label shown at enrollment", + "pro sso-settings update": "names a SAML token-expiry switch", + "pro patch-software-title-configurations create": "names the subscription the title comes from; prints no token", + "pro account-driven-user-enrollment-session-token-settings get": "the session token's lifetime settings, not a token", + "pro account-driven-user-enrollment-session-token-settings update": "the session token's lifetime settings, not a token", + "pro enrollment adue-session-token-settings get": "the session token's lifetime settings, not a token", + "pro enrollment adue-session-token-settings update": "the session token's lifetime settings, not a token", + "pro blueprints import-profile": "names the passcode payload type it converts", + "school blueprints import-profile": "names the passcode payload type it converts", + "platform sso-connections create": "names the tokenEndpointAuthMethod enum; the client secret is writeOnly", + "platform sso-connections update": "names the tokenEndpointAuthMethod enum; the client secret is writeOnly", + "security uem-connectors create": "names the USERNAME_PASSWORD auth strategy; the password is writeOnly", + "protect restore": "names the resources a restore skips because their secret cannot be restored", + + "pro classic-jwt-configs get": exemptClassicRead, + "pro classic-jwt-configs list": exemptClassicRead, + "pro classic-jwt-configs delete": "deletes by ID and prints nothing", + + "pro open": exemptOpensURL, + "protect open": exemptOpensURL, + "school open": exemptOpensURL, + "security open": exemptOpensURL, +} + +// isClassicWrite reports whether c is a generated Classic create, update or +// apply, every one of which prints the record ID the server answers with. +func isClassicWrite(c *cobra.Command) bool { + if c.Annotations["jamf:api"] != "pro-classic" { + return false + } + switch c.Name() { + case "create", "update", "apply": + return true + } + return false +} + +// TestMCPSecretNamingLeaves_AreClassified fails on any leaf whose name, Short +// or Long names a token, secret, credential, password or private key unless it +// is refused over MCP or carries a reason above. A new generated leaf that +// matches fails here until someone decides which. +func TestMCPSecretNamingLeaves_AreClassified(t *testing.T) { + root := NewRootCmd("test", "t", "t", "t") + usedProse := map[string]bool{} + usedExempt := map[string]bool{} + classicWrites := 0 + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, s := range c.Commands() { + walk(s) + } + if c.HasSubCommands() { + return + } + long := c.Long + for p := range proseNamingACredential { + if strings.Contains(long, p) { + usedProse[p] = true + long = strings.ReplaceAll(long, p, "") + } + } + if !namesASecret.MatchString(c.Name() + "\n" + c.Short + "\n" + long) { + return + } + path := strings.TrimPrefix(c.CommandPath(), root.Name()+" ") + refused := refuseOverMCP(childInvocation{path: c.CommandPath()}, "prod") != nil + _, exempt := notACredentialPrinter[path] + switch { + case refused && exempt: + t.Errorf("%q is refused over MCP and also exempted in notACredentialPrinter; drop the exemption", path) + case refused: + case exempt: + usedExempt[path] = true + case isClassicWrite(c): + classicWrites++ + default: + t.Errorf("%q names a token, secret, credential or password and is neither refused over MCP nor classified: add it to mcpRefusedCommands if it prints a credential that works outside the server, or to notACredentialPrinter with the reason it does not", path) + } + } + walk(root) + for path, why := range notACredentialPrinter { + if !usedExempt[path] { + t.Errorf("notACredentialPrinter names %q (%s), which is not a leaf the walk flags; remove the entry", path, why) + } + } + for p, why := range proseNamingACredential { + if !usedProse[p] { + t.Errorf("proseNamingACredential names %q (%s), which no Long contains any more; remove it", p, why) + } + } + if classicWrites == 0 { + t.Error("no Classic write matched; isClassicWrite has gone stale") + } +} + +func TestMCP_RefusesCommandsThatPrintOrSetALoginCredential(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"pro", "sso-oauth-session-tokens", "list"}, "access token"}, + {[]string{"pro", "cloud-distribution-point", "list"}, "private key"}, + {[]string{"pro", "cloud-distribution-point", "create", "--from-file", "x"}, "private key"}, + {[]string{"pro", "cloud-distribution-point", "patch"}, "private key"}, + {[]string{"pro", "jamf-pro-user-account-settings", "change-password"}, "password"}, + {[]string{"pro", "accounts", "create"}, "password"}, + {[]string{"pro", "accounts", "update", "1"}, "password"}, + {[]string{"pro", "accounts", "apply"}, "password"}, + } { + _, err := buildChildArgs("prod", tc.args) + if !isMCPRefusal(err) { + t.Errorf("run_command accepts %q (err %v); it hands the model a credential that works outside the server", tc.args, err) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("refusal of %q should name the %s: %v", tc.args, tc.want, err) + } + } +} + +func TestMCP_RefusesProtectDownloadsThatWriteKeyMaterial(t *testing.T) { + for _, args := range [][]string{ + {"protect", "downloads", "csr"}, + {"protect", "downloads", "websocket-auth"}, + } { + _, err := buildChildArgs("prod", args) + if !isMCPRefusal(err) { + t.Errorf("run_command accepts %q (err %v); it writes a .p12 into the server's working directory", args, err) + continue + } + if !strings.Contains(err.Error(), ".p12") { + t.Errorf("refusal of %q should say it writes a .p12: %v", args, err) + } + } +} + +func TestMCP_KeepsTheOperatorAllowedSecretsAvailable(t *testing.T) { + for _, args := range [][]string{ + {"pro", "jamf-cloud-distribution-service", "renew-credentials"}, + {"pro", "jamf-cloud-distribution-service-files", "create"}, + {"pro", "local-admin-password", "password", "mgmt-1", "admin"}, + {"pro", "computer-inventory", "view-recovery-lock-password", "1"}, + {"protect", "downloads", "installer"}, + } { + if _, err := buildChildArgs("prod", args); isMCPRefusal(err) { + t.Errorf("run_command refuses %q: %v; the operator chose to leave it available", args, err) + } + } +} diff --git a/internal/commands/pro/generated/classic_read_redaction_test.go b/internal/commands/pro/generated/classic_read_redaction_test.go new file mode 100644 index 00000000..75fe41de --- /dev/null +++ b/internal/commands/pro/generated/classic_read_redaction_test.go @@ -0,0 +1,177 @@ +// Copyright 2026, Jamf Software LLC + +package generated + +import ( + "bytes" + "cmp" + "context" + "fmt" + "io" + "net/http" + "regexp" + "slices" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/generator/classic" + "github.com/Jamf-Concepts/jamf-cli/generator/classicschema" + "github.com/Jamf-Concepts/jamf-cli/internal/output" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" + "github.com/spf13/cobra" +) + +// mcpChildEnv is the variable `mcp serve` sets on every child it spawns. +const mcpChildEnv = "JAMF_CLI_MCP" + +const classicSecretPrefix = "S3CRET-" + +// classicResourcesWithSchemas is the same walk TestEverySecretBearingFieldIsRefusedForSet +// makes: the manifest with the committed schema artifact attached. +func classicResourcesWithSchemas(t *testing.T) []classic.ClassicResource { + t.Helper() + res, err := classic.ParseManifest("../../../../specs/classic/resources.yaml") + if err != nil { + t.Fatalf("loading the Classic manifest: %v", err) + } + art, err := classicschema.Load("../../../../specs/classic/schemas.json") + if err != nil || art == nil { + t.Fatalf("loading the Classic schema artifact: %v", err) + } + if err := classic.AttachSchemas(res, art); err != nil { + t.Fatalf("attaching schemas: %v", err) + } + return res +} + +// credentialLeaf is the element name a dotted credential path ends in. +func credentialLeaf(path string) string { + return strings.TrimSuffix(path[strings.LastIndex(path, ".")+1:], "[]") +} + +// withSecrets returns xml with every element named leaf holding a sentinel. +func withSecrets(xml string, leaves []string) string { + for _, leaf := range leaves { + re := regexp.MustCompile(`<` + leaf + `>[^<]*|<` + leaf + `/>`) + xml = re.ReplaceAllString(xml, "<"+leaf+">"+classicSecretPrefix+leaf+"") + } + return xml +} + +type classicReadClient struct{ body string } + +func (c classicReadClient) Do(_ context.Context, method, _ string, _ io.Reader) (*http.Response, error) { + if method != http.MethodGet { + return nil, fmt.Errorf("unexpected %s", method) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(c.body))}, nil +} + +// runClassicRead runs ` ` against a client answering body, with -o +// format when format is not empty, and returns what it printed. +func runClassicRead(t *testing.T, body, format string, args ...string) string { + t.Helper() + var buf bytes.Buffer + f := output.New(cmp.Or(format, "json"), true, false) + f.SetWriter(&buf) + ctx := ®istry.CLIContext{Client: classicReadClient{body: body}, Output: &ndjsonOutput{f: f, buf: &buf}} + root := &cobra.Command{Use: "jamf-cli", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().StringP("output", "o", "json", "") + RegisterClassicCommands(root, ctx) + if format != "" { + args = append(args, "-o", format) + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("%q: %v", args, err) + } + return buf.String() +} + +// TestClassicRead_RedactsEveryCredentialFieldInAnMCPChild walks every +// string-typed field the generator refuses for --set and shows a Classic get in +// an MCP child prints the marker in its place, in every format a model can ask +// for. XML keeps the marker escaped, since it is element text. +func TestClassicRead_RedactsEveryCredentialFieldInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnv, "1") + checked := 0 + for _, r := range classicResourcesWithSchemas(t) { + paths := r.CredentialFields() + if len(paths) == 0 || !slices.Contains(r.Operations, "get") { + continue + } + scaffold, err := r.ScaffoldXML() + if err != nil { + t.Fatalf("%s: scaffold: %v", r.CLIName, err) + } + var leaves []string + for _, p := range paths { + leaves = append(leaves, credentialLeaf(p)) + } + doc := withSecrets(scaffold, leaves) + for _, p := range paths { + if !strings.Contains(doc, classicSecretPrefix+credentialLeaf(p)) { + t.Errorf("%s: the scaffold carries no <%s> for %s, so the sweep cannot show it redacted", r.CLIName, credentialLeaf(p), p) + } + checked++ + } + for _, format := range []string{"", "json", "yaml", "table", "plain", "xml", "raw"} { + got := runClassicRead(t, doc, format, r.CLIName, "get", "1") + if strings.Contains(got, classicSecretPrefix) { + t.Errorf("%s get -o %q prints a credential field over MCP:\n%s", r.CLIName, format, got) + } + marker := "" + if format == "" || format == "xml" || format == "raw" { + marker = "<redacted>" + } + if !strings.Contains(got, marker) { + t.Errorf("%s get -o %q should print %s for each credential field (%v):\n%s", r.CLIName, format, marker, paths, got) + } + } + } + if checked < 25 { + t.Errorf("the sweep checked only %d credential fields, too few to be walking the shipped artifact", checked) + } +} + +func TestClassicList_RedactsCredentialFieldsInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnv, "1") + body := `11VPP` + classicSecretPrefix + `stoken` + for _, format := range []string{"", "json", "table"} { + got := runClassicRead(t, body, format, "classic-vpp-accounts", "list") + if strings.Contains(got, classicSecretPrefix) { + t.Errorf("classic-vpp-accounts list -o %q prints the sToken over MCP:\n%s", format, got) + } + } +} + +func TestClassicRead_OutsideMCPPrintsCredentialFieldsUnchanged(t *testing.T) { + t.Setenv(mcpChildEnv, "") + for _, r := range classicResourcesWithSchemas(t) { + paths := r.CredentialFields() + if len(paths) == 0 || !slices.Contains(r.Operations, "get") { + continue + } + scaffold, err := r.ScaffoldXML() + if err != nil { + t.Fatalf("%s: scaffold: %v", r.CLIName, err) + } + var leaves []string + for _, p := range paths { + leaves = append(leaves, credentialLeaf(p)) + } + doc := withSecrets(scaffold, leaves) + if got := runClassicRead(t, doc, "raw", r.CLIName, "get", "1"); strings.TrimSpace(got) != strings.TrimSpace(doc) { + t.Errorf("%s get -o raw outside MCP must print the wire bytes unchanged:\n got %s\nwant %s", r.CLIName, got, doc) + } + got := runClassicRead(t, doc, "json", r.CLIName, "get", "1") + for _, leaf := range leaves { + if !strings.Contains(got, classicSecretPrefix+leaf) { + t.Errorf("%s get -o json outside MCP must print %s unchanged:\n%s", r.CLIName, leaf, got) + } + } + if strings.Contains(got, "redacted") { + t.Errorf("%s get outside MCP redacted something:\n%s", r.CLIName, got) + } + } +} From 9aef814a2f28592016948926a53fb10cb956b258 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 08:34:51 -0500 Subject: [PATCH 10/25] fix(mcp): refuse the remaining credential printers and redact Classic secrets run_command now refuses the SSO session token reader, the cloud distribution point reads and writes whose response carries the CloudFront signing key, and the commands that set a Jamf Pro login password to a value the model chose. It also refuses the two Protect downloads that write .p12 key material into the server's directory. The device secrets of the pinned tenant and the JCDS upload credentials stay available by the operator's decision, and the mcpRefusedCommands comment records that line. Every generated Classic get and list passes its body through one helper before choosing a format. In an MCP child it replaces the text of each element the resource's --set refuses as a credential with the redaction marker, so JSON, YAML, table, plain, XML and raw all print it. A body it cannot parse is refused rather than printed. Outside MCP nothing changes. The child flag moves into registry so generated code can read it. Report-client URLs lose their userinfo and query over MCP, an empty --input-dir is an error, the --output refusal names -o , a relative path outside the input directory asks for an absolute one, and the plan-name refusal no longer offers -O over MCP. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 ++++- generator/classic/generator.go | 85 +++++++++++++++-- internal/commands/agent_context.md | 27 ++++-- internal/commands/mcp.go | 93 ++++++++++++++++--- .../pro/generated/classic_account_groups.go | 8 +- .../pro/generated/classic_account_users.go | 8 +- .../pro/generated/classic_accounts.go | 5 +- .../classic_advanced_computer_searches.go | 10 +- ...classic_advanced_mobile_device_searches.go | 10 +- .../classic_allowed_file_extensions.go | 10 +- .../commands/pro/generated/classic_classes.go | 10 +- .../generated/classic_computer_commands.go | 5 +- .../pro/generated/classic_computer_configs.go | 10 +- .../generated/classic_computer_ext_attrs.go | 10 +- .../pro/generated/classic_computer_groups.go | 10 +- .../pro/generated/classic_computer_history.go | 5 +- .../generated/classic_computer_invitations.go | 10 +- .../generated/classic_directory_bindings.go | 10 +- .../classic_disk_encryption_configs.go | 10 +- .../generated/classic_distribution_points.go | 10 +- .../pro/generated/classic_dock_items.go | 10 +- .../commands/pro/generated/classic_ebooks.go | 10 +- .../pro/generated/classic_gsx_connection.go | 5 +- .../pro/generated/classic_ibeacons.go | 10 +- .../pro/generated/classic_jwt_configs.go | 10 +- .../pro/generated/classic_ldap_servers.go | 10 +- .../generated/classic_licensed_software.go | 10 +- .../pro/generated/classic_mac_apps.go | 10 +- .../classic_macos_config_profiles.go | 10 +- .../pro/generated/classic_mobile_apps.go | 10 +- .../pro/generated/classic_mobile_commands.go | 5 +- .../classic_mobile_config_profiles.go | 10 +- .../generated/classic_mobile_device_groups.go | 10 +- .../pro/generated/classic_mobile_devices.go | 10 +- .../pro/generated/classic_mobile_history.go | 5 +- .../generated/classic_mobile_invitations.go | 10 +- .../classic_mobile_provisioning_profiles.go | 10 +- .../pro/generated/classic_network_segments.go | 10 +- .../pro/generated/classic_packages.go | 10 +- .../classic_patch_available_titles.go | 5 +- .../classic_patch_external_sources.go | 10 +- .../classic_patch_internal_sources.go | 10 +- .../pro/generated/classic_patch_policies.go | 10 +- .../pro/generated/classic_patch_reports.go | 5 +- .../pro/generated/classic_patch_titles.go | 10 +- .../pro/generated/classic_policies.go | 10 +- .../pro/generated/classic_printers.go | 10 +- .../generated/classic_read_redaction_test.go | 6 +- .../pro/generated/classic_registry.go | 67 +++++++++++++ .../classic_removable_mac_addresses.go | 10 +- .../generated/classic_restricted_software.go | 10 +- .../pro/generated/classic_smtp_server.go | 5 +- .../classic_software_update_servers.go | 10 +- .../pro/generated/classic_user_ext_attrs.go | 10 +- .../pro/generated/classic_user_groups.go | 10 +- .../pro/generated/classic_vpp_accounts.go | 10 +- .../pro/generated/classic_vpp_assignments.go | 10 +- .../pro/generated/classic_vpp_invitations.go | 10 +- .../pro/generated/classic_webhooks.go | 10 +- internal/commands/protect_action_configs.go | 39 +++++++- internal/commands/protect_plans.go | 3 + internal/registry/registry.go | 10 ++ 62 files changed, 702 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b75138a..e007132b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,19 +38,37 @@ child process checks again before it runs. These now fail over MCP: - The commands that print an access token: `auth token` under `platform`, `pro` and `protect`, and `pro api-authentication token`, `oauth-token` and `keep-alive`. +- `pro sso-oauth-session-tokens`, which prints the session's access and ID + tokens. - The commands that mint a credential and print it: `pro api-integrations client-credentials` (a new client secret) and `protect api-clients apply` (a new API client's password). +- `pro cloud-distribution-point list`, `create` and `patch`, whose response + carries the CloudFront private key that signs download URLs. +- The commands that set a Jamf Pro login password to a value the model + chose: `pro jamf-pro-user-account-settings change-password` and + `pro accounts create`, `update` and `apply`. +- `protect downloads csr` and `websocket-auth`, which write the tenant's + `.p12` key material into the server's working directory. - `protect action-configs export`, whose document carries each report client's header values, such as a SIEM or webhook bearer token. A redacted copy would overwrite the real credential when applied. +- `mcp serve --input-dir ""`, which used to start with no input directory. `config show` still runs over MCP, with each token, client ID and client secret shown as ``. `config list --status` checks only the server's profile. These Protect commands also run over MCP with the credential shown as ``: `action-configs get` and `apply` (each report client's header -values), `data-forwarding get` and `update` (the Sentinel shared key) and -`api-clients get` (the password). Outside MCP their output is unchanged. +values, and the userinfo and query of each report-client URL), +`data-forwarding get` and `update` (the Sentinel shared key) and +`api-clients get` (the password). Every Classic `get` and `list` prints each +field that Classic `--set` refuses as a credential as ``, in every +output format, so `-o raw` is not the wire bytes over MCP. Outside MCP their +output is unchanged. Secrets of the pinned tenant's devices (the LAPS +password, the recovery lock password, the FileVault personal recovery key) +and the JCDS upload credentials of `pro jamf-cloud-distribution-service +renew-credentials` and `pro jamf-cloud-distribution-service-files create` are +still shown. **Migration:** if an agent sends file bodies through `run_command` (for example `pro scripts create --script-file …`), start the server with diff --git a/generator/classic/generator.go b/generator/classic/generator.go index c6eb383e..578cd15f 100644 --- a/generator/classic/generator.go +++ b/generator/classic/generator.go @@ -587,6 +587,9 @@ func new{{ .GoName }}ListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, {{ bodySpecVar . }}); err != nil { + return err + } {{- if .ListSubset }} // /JSSResource/{{ .Path }} returns users + groups combined; narrow to // the "{{ .ListSubset }}" subset so this command behaves like a @@ -608,7 +611,7 @@ func new{{ .GoName }}ListCmd(ctx *registry.CLIContext) *cobra.Command { return ctx.Output.PrintRaw(subsetXML) {{- else }} // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -695,8 +698,11 @@ func new{{ .GoName }}GetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, {{ bodySpecVar . }}); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -1573,12 +1579,13 @@ const classicRegistryTemplate = `// Copyright 2026, Jamf Software LLC package generated import ( + "bytes" + "encoding/xml" "io" "os" {{- if or (anyNeedsClassicNameResolve .) (anyClassicFileFields .) (anyHasGroupPath .) }} "context" "encoding/json" - "encoding/xml" "path/filepath" {{- end }} "slices" @@ -1586,9 +1593,6 @@ import ( "strings" "strconv" "fmt" -{{- if or (anyIsConfigProfile .) (anyClassicFileFields .) (anyListSubset .) (anyClassicExtraLookups .) (anyHasGroupPath .) }} - "bytes" -{{- end }} {{- if or (anyIsConfigProfile .) (anyClassicFileFields .) (anyClassicExtraLookups .) }} "net/url" {{- end }} @@ -1602,9 +1606,7 @@ import ( {{- if anyClassicExtraLookups . }} "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" {{- end }} -{{- if or (anyNeedsClassicNameResolve .) (anyClassicFileFields .) (anyListSubset .) }} "github.com/Jamf-Concepts/jamf-cli/internal/xmlconv" -{{- end }} {{- if or (anyIsConfigProfile .) (anyClassicFileFields .) }} "github.com/Jamf-Concepts/jamf-cli/internal/profileconvert" {{- end }} @@ -1645,6 +1647,73 @@ func readClassicBody(fromFile string) ([]byte, error) { return nil, nil } +// classicRedactedText is "" escaped as XML element text, so every +// output format decodes it back to the marker. +const classicRedactedText = "<redacted>" + +// redactClassicReadInMCPChild returns body with the text of every element +// named after one of spec's credential fields replaced by the redaction +// marker, when this process is a child of ` + "`mcp serve`" + `. Every get and list +// prints through it, before choosing a format, so -o raw is not the wire +// bytes there. A body it cannot parse as XML is refused rather than printed. +func redactClassicReadInMCPChild(body []byte, spec classicBodySpec) ([]byte, error) { + if len(spec.Credentials) == 0 || !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { + return body, nil + } + if !xmlconv.IsXML(body) { + return nil, fmt.Errorf("the Classic API answered with a body that is not XML, so its credential fields cannot be redacted and it is not printed over MCP") + } + leaves := map[string]bool{} + for path := range spec.Credentials { + leaves[strings.TrimSuffix(path[strings.LastIndex(path, ".")+1:], "[]")] = true + } + type span struct{ start, end int64 } + var spans []span + var names []string + var starts []int64 + dec := xml.NewDecoder(bytes.NewReader(body)) + for { + before := dec.InputOffset() + tok, err := dec.RawToken() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("parsing the Classic API response to redact its credential fields, so it is not printed over MCP: %w", err) + } + switch t := tok.(type) { + case xml.StartElement: + names = append(names, t.Name.Local) + starts = append(starts, dec.InputOffset()) + case xml.EndElement: + n := len(names) + if n == 0 { + continue + } + if leaves[names[n-1]] && before > starts[n-1] { + spans = append(spans, span{starts[n-1], before}) + } + names, starts = names[:n-1], starts[:n-1] + } + } + if len(spans) == 0 { + return body, nil + } + sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) + var out bytes.Buffer + var cursor int64 + for _, sp := range spans { + if sp.start < cursor { + continue + } + out.Write(body[cursor:sp.start]) + out.WriteString(classicRedactedText) + cursor = sp.end + } + out.Write(body[cursor:]) + return out.Bytes(), nil +} + // ── Schema-derived request bodies (--scaffold and --set) ────────────────── // // The Classic API takes XML and its manifest (specs/classic/resources.yaml) diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 1c60b232..c8506d6d 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -110,15 +110,24 @@ file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, `--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, `completion`, the config write subcommands, `config validate`, `doctor`, the commands that print an access token (`auth token` under `platform`, `pro` and -`protect`; `pro api-authentication token`, `oauth-token` and `keep-alive`), the -commands that mint and print a credential (`pro api-integrations -client-credentials`, `protect api-clients apply`), `protect action-configs -export`, every `setup`, both `backup` commands and jcds `sync`; and `pro diff` -against anything but the pinned profile. `config show` runs with every -credential field shown as ``, and so do the report-client header -values of `protect action-configs get` and `apply`, the Sentinel shared key of -`protect data-forwarding get` and `update`, and the password of `protect -api-clients get`. An administrator who +`protect`; `pro api-authentication token`, `oauth-token` and `keep-alive`; `pro +sso-oauth-session-tokens`), the commands that mint and print a credential (`pro +api-integrations client-credentials`, `protect api-clients apply`), `pro +cloud-distribution-point list`, `create` and `patch` (a CloudFront private key), +the commands that set a Jamf Pro login password (`pro +jamf-pro-user-account-settings change-password`, `pro accounts create`, +`update`, `apply`), `protect downloads csr` and `websocket-auth` (they write a +.p12 into the server's directory), `protect action-configs export`, every +`setup`, both `backup` commands and jcds `sync`; and `pro diff` against anything +but the pinned profile. `config show` runs with every credential field shown as +``, and so do the report-client header values and URL userinfo and +query of `protect action-configs get` and `apply`, the Sentinel shared key of +`protect data-forwarding get` and `update`, the password of `protect api-clients +get`, and every Classic `get` and `list` field that Classic `--set` refuses as a +credential. Secrets of the pinned tenant's devices (the LAPS password, the +recovery lock password, the FileVault personal recovery key) and the JCDS upload +credentials are shown. A relative read path resolves against the server's start +directory, so pass an absolute one. An administrator who starts the server with `--input-dir ` allows the read-side flags, and a `pro diff` side that is a directory, for existing paths inside that directory; `run_command`'s description names it. diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index e4de5fc5..6de7b444 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -22,6 +22,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/config" "github.com/Jamf-Concepts/jamf-cli/internal/exitcode" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" ) // newMCPCmd exposes the entire jamf-cli command tree to MCP-capable AI clients @@ -103,10 +104,19 @@ spelling. It refuses: - 'auth token' under 'platform', 'pro' and 'protect', and 'pro api-authentication token', 'oauth-token' and 'keep-alive', which print a live access token + - 'pro sso-oauth-session-tokens', which prints the session's access and ID + tokens - 'pro api-integrations client-credentials' and 'protect api-clients apply', which mint a new client secret or password and print it + - 'pro cloud-distribution-point list', 'create' and 'patch', whose response + carries the CloudFront private key that signs download URLs + - 'pro jamf-pro-user-account-settings change-password' and 'pro accounts + create', 'update' and 'apply', which set a Jamf Pro login password to a + value the model chose - 'protect action-configs export', whose document carries each report client's header values, the SIEM or webhook credential, verbatim + - 'protect downloads csr' and 'websocket-auth', which write the tenant's + .p12 key material into the directory this server was started in - any flag naming a profile, URL, token, tenant, environment or output file - any flag whose value is a local path: --from-file, --file, --script-file, --mobileconfig-file, --appconfig-file, --custom-payload-file, --body-file, @@ -116,13 +126,24 @@ spelling. It refuses: - 'pro diff' with a --source or --target that is neither this server's profile nor a directory inside --input-dir Some allowed commands print a third-party credential, shown as -here: the report-client header values of 'protect action-configs get' and -'apply', the Sentinel shared key of 'protect data-forwarding get' and 'update', -and the password of 'protect api-clients get'. +here: the report-client header values and the userinfo and query of each +report-client URL in 'protect action-configs get' and 'apply' (a secret in a +URL path, as a Slack webhook carries, is still shown), the Sentinel shared key +of 'protect data-forwarding get' and 'update', the password of 'protect +api-clients get', and every Classic 'get' and 'list' field the Classic --set +refuses as a credential (an SMTP, LDAP, webhook, directory binding or +distribution point password, the VPP sToken, the JWT signing key, the +institutional FileVault keystore). So a Classic '-o raw' is not the wire bytes +here. +Secrets of the pinned tenant's own devices are shown: the LAPS password, the +recovery lock password, the FileVault personal recovery key, and the bootstrap +token, unlock token and AirPlay password in device inventory. So are the JCDS +upload credentials of 'pro jamf-cloud-distribution-service renew-credentials' +and 'pro jamf-cloud-distribution-service-files create'. Some allowed commands write a file into the directory this server was started -in, named by Jamf or by the command's own argument: 'protect downloads' and -'pro jcds download' without -O, and 'protect plans config-profile'. Start the -server from a directory where that is acceptable. +in, named by Jamf or by the command's own argument: the other 'protect +downloads' and 'pro jcds download' without -O, and 'protect plans +config-profile'. Start the server from a directory where that is acceptable. It also refuses 'dashboard', because it returns a command's stdout as text and the report is a 320-800 KB document — generate_report writes that to a file @@ -134,11 +155,14 @@ instead. directory, or the --dir of 'protect analytics import' and 'protect unified-logging-filters import' is accepted when it exists and resolves inside , symlinks followed. A relative path is taken from the directory this -server was started in. --password-file and every -write-side path flag stay refused. The directory must exist; there is no -config key for it.`, +server was started in, so pass an absolute path. --password-file and every +write-side path flag stay refused. The directory must exist, and an empty +value is an error; there is no config key for it.`, Args: refuseStrayPositionals, RunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("input-dir") && inputDirFlag == "" { + return errors.New("--input-dir is empty: name the directory the model may read from, or drop the flag to allow no reads") + } executable, err := os.Executable() if err != nil { return fmt.Errorf("determining executable path: %w", err) @@ -204,16 +228,23 @@ config key for it.`, "the config write subcommands, 'config validate', 'doctor', " + "every command that prints an access token ('auth token', " + "'pro api-authentication token', 'oauth-token', 'keep-alive'), " + + "'pro sso-oauth-session-tokens', " + "the commands that mint and print a credential ('pro api-integrations " + "client-credentials', 'protect api-clients apply'), " + + "'pro cloud-distribution-point list', 'create' and 'patch' (they print a " + + "CloudFront private key), the commands that set a Jamf Pro login password " + + "('pro jamf-pro-user-account-settings change-password', 'pro accounts " + + "create', 'update', 'apply'), 'protect downloads csr' and 'websocket-auth', " + "'protect action-configs export', " + "every 'setup', the backup commands and jcds sync; and " + "'pro diff' against anything but this server's profile or a directory the " + "input directory allows. " + inputDirToolNote(inputDir) + " Use generate_report rather than 'dashboard': this tool returns " + "stdout as text and the dashboard writes a 320-800 KB HTML document there. " + - "Report-client header values, the Sentinel shared key and Protect API " + - "client passwords print as . " + + "Report-client header values and URL userinfo and query, the Sentinel " + + "shared key, Protect API client passwords and Classic credential fields " + + "(passwords, the VPP sToken, the JWT signing key) print as ; " + + "device secrets such as the LAPS password are shown. " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + "require an explicit --yes in args or they will refuse to run.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in runCommandInput) (*mcp.CallToolResult, any, error) { @@ -337,7 +368,7 @@ func childEnv() []string { } const ( - mcpChildEnvVar = "JAMF_CLI_MCP" + mcpChildEnvVar = registry.MCPChildEnvVar mcpPinnedProfileEnvVar = "JAMF_CLI_MCP_PROFILE" mcpInputDirEnvVar = "JAMF_CLI_MCP_INPUT_DIR" ) @@ -533,6 +564,20 @@ var blockedChildFlagPrefixes = []string{ // beneath it, and why. `dashboard` is not here: generate_report shares // buildChildArgs and must still spawn it, so the run_command handler refuses it // instead. +// +// A command that prints a credential working outside this server is refused. +// A secret of the pinned tenant's own devices is not: the operator decided the +// model may read them. So the LAPS password (`pro local-admin-password +// password`, `password-by-guid`, `audit`, `audit-by-guid`), the recovery lock +// password (`pro computer-inventory view-recovery-lock-password`), the +// FileVault personal recovery key (`filevault`, `filevault-by-id`) and the +// bootstrap token, unlock token and AirPlay password in device inventory all +// run, as do the commands that set or clear such a secret. So do `pro +// jamf-cloud-distribution-service renew-credentials` and +// `pro jamf-cloud-distribution-service-files create`, whose upload credentials +// reach only the pinned tenant's JCDS bucket. +// TestMCPSecretNamingLeaves_AreClassified holds every leaf whose help names a +// credential to one side of this line. var mcpRefusedCommands = []refusedCommand{ {"jamf-cli multi", refusedPicksTarget}, {"jamf-cli mcp", refusedPicksTarget}, @@ -549,7 +594,17 @@ var mcpRefusedCommands = []refusedCommand{ {"jamf-cli pro api-authentication token", refusedPrintsToken}, {"jamf-cli pro api-authentication oauth-token", refusedPrintsToken}, {"jamf-cli pro api-authentication keep-alive", refusedPrintsToken}, + {"jamf-cli pro sso-oauth-session-tokens", refusedPrintsToken}, {"jamf-cli pro api-integrations client-credentials", refusedMintsClientSecret}, + {"jamf-cli pro cloud-distribution-point list", refusedPrintsCDNKey}, + {"jamf-cli pro cloud-distribution-point create", refusedPrintsCDNKey}, + {"jamf-cli pro cloud-distribution-point patch", refusedPrintsCDNKey}, + {"jamf-cli pro jamf-pro-user-account-settings change-password", refusedSetsLoginPassword}, + {"jamf-cli pro accounts create", refusedSetsLoginPassword}, + {"jamf-cli pro accounts update", refusedSetsLoginPassword}, + {"jamf-cli pro accounts apply", refusedSetsLoginPassword}, + {"jamf-cli protect downloads csr", refusedWritesKeyMaterial}, + {"jamf-cli protect downloads websocket-auth", refusedWritesKeyMaterial}, {"jamf-cli protect api-clients apply", refusedMintsProtectPassword}, {"jamf-cli protect action-configs export", refusedExportsHeaders}, {"jamf-cli pro setup", refusedPicksTarget}, @@ -573,6 +628,9 @@ const ( refusedPrintsToken = "it prints a live access token, which works outside this server and every refusal it applies until it expires" refusedMintsClientSecret = "it mints a new client secret for the API integration and prints it, a credential that works outside this server until it is rotated" refusedMintsProtectPassword = "creating an API client mints a new password and prints it, a credential that works outside this server until the client is deleted" + refusedPrintsCDNKey = "its response carries the CloudFront private key that signs download URLs, a credential that works outside this server" + refusedSetsLoginPassword = "it sets a Jamf Pro login password to a value the model chose, a credential that works outside this server" + refusedWritesKeyMaterial = "it writes the tenant's .p12 key material into the directory this server was started in, under a fixed name that replaces any file already there" refusedExportsHeaders = "its document carries each report client's header values verbatim (the SIEM or webhook bearer token), and a redacted copy would overwrite the real credential when applied; 'protect action-configs get' shows the configuration with them redacted" ) @@ -799,8 +857,11 @@ func refuseDiffSide(s flagSetting, pinnedProfile, inputDir string) error { func refuseLocalPath(use localPathUse, s flagSetting, inputDir string) error { if use != pathRead || inputDir == "" { err := fmt.Errorf("flag --%s is not available over MCP: it %s a path on the machine running this server, which the connecting model must not choose", s.name, use) - if use == pathRead { + switch { + case use == pathRead: err = fmt.Errorf("%w; the administrator can allow reads from one directory with 'mcp serve --input-dir '", err) + case s.name == "output": + err = fmt.Errorf("%w; the global -o flag (json, yaml, table, csv) is still accepted", err) } return err } @@ -815,7 +876,11 @@ func refuseLocalPath(use localPathUse, s flagSetting, inputDir string) error { return fmt.Errorf("flag --%s %q cannot be used over MCP: %v; it must name an existing path inside the input directory %s", s.name, s.value, err, inputDir) } if !insideDir(inputDir, resolved) { - return fmt.Errorf("flag --%s %q is not available over MCP: it resolves to %s, outside the input directory %s", s.name, s.value, resolved, inputDir) + err := fmt.Errorf("flag --%s %q is not available over MCP: it resolves to %s, outside the input directory %s", s.name, s.value, resolved, inputDir) + if !filepath.IsAbs(s.value) { + err = fmt.Errorf("%w; a relative path resolves against the directory this server was started in, not the input directory, so pass an absolute path inside it", err) + } + return err } return nil } diff --git a/internal/commands/pro/generated/classic_account_groups.go b/internal/commands/pro/generated/classic_account_groups.go index 024a4ba9..64d26f96 100644 --- a/internal/commands/pro/generated/classic_account_groups.go +++ b/internal/commands/pro/generated/classic_account_groups.go @@ -147,6 +147,9 @@ func newClassicAccountGroupsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAccountGroups); err != nil { + return err + } // /JSSResource/accounts returns users + groups combined; narrow to // the "groups" subset so this command behaves like a // standalone list. Default to pretty-printed XML (matching other @@ -208,8 +211,11 @@ func newClassicAccountGroupsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAccountGroups); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_account_users.go b/internal/commands/pro/generated/classic_account_users.go index a84c8791..477c6702 100644 --- a/internal/commands/pro/generated/classic_account_users.go +++ b/internal/commands/pro/generated/classic_account_users.go @@ -196,6 +196,9 @@ func newClassicAccountUsersListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAccountUsers); err != nil { + return err + } // /JSSResource/accounts returns users + groups combined; narrow to // the "users" subset so this command behaves like a // standalone list. Default to pretty-printed XML (matching other @@ -257,8 +260,11 @@ func newClassicAccountUsersGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAccountUsers); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_accounts.go b/internal/commands/pro/generated/classic_accounts.go index a5824422..16feb286 100644 --- a/internal/commands/pro/generated/classic_accounts.go +++ b/internal/commands/pro/generated/classic_accounts.go @@ -54,8 +54,11 @@ func newClassicAccountsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAccounts); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_advanced_computer_searches.go b/internal/commands/pro/generated/classic_advanced_computer_searches.go index effbdb66..8477d520 100644 --- a/internal/commands/pro/generated/classic_advanced_computer_searches.go +++ b/internal/commands/pro/generated/classic_advanced_computer_searches.go @@ -124,8 +124,11 @@ func newClassicAdvancedComputerSearchesListCmd(ctx *registry.CLIContext) *cobra. if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAdvancedComputerSearches); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -193,8 +196,11 @@ func newClassicAdvancedComputerSearchesGetCmd(ctx *registry.CLIContext) *cobra.C if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAdvancedComputerSearches); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go b/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go index 6d5985e8..ffcf5fa2 100644 --- a/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go +++ b/internal/commands/pro/generated/classic_advanced_mobile_device_searches.go @@ -124,8 +124,11 @@ func newClassicAdvancedMobileDeviceSearchesListCmd(ctx *registry.CLIContext) *co if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAdvancedMobileDeviceSearches); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -193,8 +196,11 @@ func newClassicAdvancedMobileDeviceSearchesGetCmd(ctx *registry.CLIContext) *cob if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAdvancedMobileDeviceSearches); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_allowed_file_extensions.go b/internal/commands/pro/generated/classic_allowed_file_extensions.go index d40144c6..ede342e0 100644 --- a/internal/commands/pro/generated/classic_allowed_file_extensions.go +++ b/internal/commands/pro/generated/classic_allowed_file_extensions.go @@ -78,8 +78,11 @@ func newClassicAllowedFileExtensionsListCmd(ctx *registry.CLIContext) *cobra.Com if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAllowedFileExtensions); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -144,8 +147,11 @@ func newClassicAllowedFileExtensionsGetCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicAllowedFileExtensions); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_classes.go b/internal/commands/pro/generated/classic_classes.go index 2183abf2..4bff748e 100644 --- a/internal/commands/pro/generated/classic_classes.go +++ b/internal/commands/pro/generated/classic_classes.go @@ -171,8 +171,11 @@ func newClassicClassesListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicClasses); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -240,8 +243,11 @@ func newClassicClassesGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicClasses); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_computer_commands.go b/internal/commands/pro/generated/classic_computer_commands.go index 6f0827aa..7bc1e072 100644 --- a/internal/commands/pro/generated/classic_computer_commands.go +++ b/internal/commands/pro/generated/classic_computer_commands.go @@ -54,8 +54,11 @@ func newClassicComputerCommandsListCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerCommands); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_computer_configs.go b/internal/commands/pro/generated/classic_computer_configs.go index e6fc29ea..fefae856 100644 --- a/internal/commands/pro/generated/classic_computer_configs.go +++ b/internal/commands/pro/generated/classic_computer_configs.go @@ -70,8 +70,11 @@ func newClassicComputerConfigsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerConfigs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -139,8 +142,11 @@ func newClassicComputerConfigsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerConfigs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_computer_ext_attrs.go b/internal/commands/pro/generated/classic_computer_ext_attrs.go index 59f8219a..38d6a66a 100644 --- a/internal/commands/pro/generated/classic_computer_ext_attrs.go +++ b/internal/commands/pro/generated/classic_computer_ext_attrs.go @@ -109,8 +109,11 @@ func newClassicComputerExtAttrsListCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerExtAttrs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -178,8 +181,11 @@ func newClassicComputerExtAttrsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerExtAttrs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_computer_groups.go b/internal/commands/pro/generated/classic_computer_groups.go index aef9c0cb..f3cfae49 100644 --- a/internal/commands/pro/generated/classic_computer_groups.go +++ b/internal/commands/pro/generated/classic_computer_groups.go @@ -113,8 +113,11 @@ func newClassicComputerGroupsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerGroups); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -182,8 +185,11 @@ func newClassicComputerGroupsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerGroups); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_computer_history.go b/internal/commands/pro/generated/classic_computer_history.go index 8365bdf0..db22b9dd 100644 --- a/internal/commands/pro/generated/classic_computer_history.go +++ b/internal/commands/pro/generated/classic_computer_history.go @@ -104,8 +104,11 @@ func newClassicComputerHistoryGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerHistory); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_computer_invitations.go b/internal/commands/pro/generated/classic_computer_invitations.go index 32e6bfc2..4274091c 100644 --- a/internal/commands/pro/generated/classic_computer_invitations.go +++ b/internal/commands/pro/generated/classic_computer_invitations.go @@ -111,8 +111,11 @@ func newClassicComputerInvitationsListCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerInvitations); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -183,8 +186,11 @@ func newClassicComputerInvitationsGetCmd(ctx *registry.CLIContext) *cobra.Comman if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicComputerInvitations); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_directory_bindings.go b/internal/commands/pro/generated/classic_directory_bindings.go index a5413870..2920704a 100644 --- a/internal/commands/pro/generated/classic_directory_bindings.go +++ b/internal/commands/pro/generated/classic_directory_bindings.go @@ -190,8 +190,11 @@ func newClassicDirectoryBindingsListCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDirectoryBindings); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -259,8 +262,11 @@ func newClassicDirectoryBindingsGetCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDirectoryBindings); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_disk_encryption_configs.go b/internal/commands/pro/generated/classic_disk_encryption_configs.go index 6baf1cee..e554d84c 100644 --- a/internal/commands/pro/generated/classic_disk_encryption_configs.go +++ b/internal/commands/pro/generated/classic_disk_encryption_configs.go @@ -109,8 +109,11 @@ func newClassicDiskEncryptionConfigsListCmd(ctx *registry.CLIContext) *cobra.Com if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDiskEncryptionConfigs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -178,8 +181,11 @@ func newClassicDiskEncryptionConfigsGetCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDiskEncryptionConfigs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_distribution_points.go b/internal/commands/pro/generated/classic_distribution_points.go index 4eacfe7c..a35514d1 100644 --- a/internal/commands/pro/generated/classic_distribution_points.go +++ b/internal/commands/pro/generated/classic_distribution_points.go @@ -154,8 +154,11 @@ func newClassicDistributionPointsListCmd(ctx *registry.CLIContext) *cobra.Comman if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDistributionPoints); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -223,8 +226,11 @@ func newClassicDistributionPointsGetCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDistributionPoints); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_dock_items.go b/internal/commands/pro/generated/classic_dock_items.go index 436f2c98..c700a890 100644 --- a/internal/commands/pro/generated/classic_dock_items.go +++ b/internal/commands/pro/generated/classic_dock_items.go @@ -91,8 +91,11 @@ func newClassicDockItemsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDockItems); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -160,8 +163,11 @@ func newClassicDockItemsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicDockItems); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_ebooks.go b/internal/commands/pro/generated/classic_ebooks.go index e786633e..714e58dd 100644 --- a/internal/commands/pro/generated/classic_ebooks.go +++ b/internal/commands/pro/generated/classic_ebooks.go @@ -353,8 +353,11 @@ func newClassicEbooksListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicEbooks); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -422,8 +425,11 @@ func newClassicEbooksGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicEbooks); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_gsx_connection.go b/internal/commands/pro/generated/classic_gsx_connection.go index a1075cad..1402039c 100644 --- a/internal/commands/pro/generated/classic_gsx_connection.go +++ b/internal/commands/pro/generated/classic_gsx_connection.go @@ -84,8 +84,11 @@ func newClassicGsxConnectionGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicGsxConnection); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_ibeacons.go b/internal/commands/pro/generated/classic_ibeacons.go index 927e5189..abd587e4 100644 --- a/internal/commands/pro/generated/classic_ibeacons.go +++ b/internal/commands/pro/generated/classic_ibeacons.go @@ -88,8 +88,11 @@ func newClassicIbeaconsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicIbeacons); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -157,8 +160,11 @@ func newClassicIbeaconsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicIbeacons); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_jwt_configs.go b/internal/commands/pro/generated/classic_jwt_configs.go index 4aaba855..e9737171 100644 --- a/internal/commands/pro/generated/classic_jwt_configs.go +++ b/internal/commands/pro/generated/classic_jwt_configs.go @@ -89,8 +89,11 @@ func newClassicJwtConfigsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicJwtConfigs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -143,8 +146,11 @@ func newClassicJwtConfigsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicJwtConfigs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_ldap_servers.go b/internal/commands/pro/generated/classic_ldap_servers.go index b3a0ecd4..6522d676 100644 --- a/internal/commands/pro/generated/classic_ldap_servers.go +++ b/internal/commands/pro/generated/classic_ldap_servers.go @@ -222,8 +222,11 @@ func newClassicLdapServersListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicLdapServers); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -291,8 +294,11 @@ func newClassicLdapServersGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicLdapServers); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_licensed_software.go b/internal/commands/pro/generated/classic_licensed_software.go index 66ded343..dc7abaa5 100644 --- a/internal/commands/pro/generated/classic_licensed_software.go +++ b/internal/commands/pro/generated/classic_licensed_software.go @@ -171,8 +171,11 @@ func newClassicLicensedSoftwareListCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicLicensedSoftware); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -240,8 +243,11 @@ func newClassicLicensedSoftwareGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicLicensedSoftware); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mac_apps.go b/internal/commands/pro/generated/classic_mac_apps.go index 6537e7d3..1380b34e 100644 --- a/internal/commands/pro/generated/classic_mac_apps.go +++ b/internal/commands/pro/generated/classic_mac_apps.go @@ -305,8 +305,11 @@ func newClassicMacAppsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMacApps); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -374,8 +377,11 @@ func newClassicMacAppsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMacApps); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_macos_config_profiles.go b/internal/commands/pro/generated/classic_macos_config_profiles.go index 1060d773..1a2b7211 100644 --- a/internal/commands/pro/generated/classic_macos_config_profiles.go +++ b/internal/commands/pro/generated/classic_macos_config_profiles.go @@ -328,8 +328,11 @@ func newClassicMacosConfigProfilesListCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMacosConfigProfiles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -397,8 +400,11 @@ func newClassicMacosConfigProfilesGetCmd(ctx *registry.CLIContext) *cobra.Comman if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMacosConfigProfiles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_apps.go b/internal/commands/pro/generated/classic_mobile_apps.go index c538f38e..1fe144f1 100644 --- a/internal/commands/pro/generated/classic_mobile_apps.go +++ b/internal/commands/pro/generated/classic_mobile_apps.go @@ -370,8 +370,11 @@ func newClassicMobileAppsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileApps); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -439,8 +442,11 @@ func newClassicMobileAppsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileApps); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_commands.go b/internal/commands/pro/generated/classic_mobile_commands.go index 498042d4..bbcef7fc 100644 --- a/internal/commands/pro/generated/classic_mobile_commands.go +++ b/internal/commands/pro/generated/classic_mobile_commands.go @@ -54,8 +54,11 @@ func newClassicMobileCommandsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileCommands); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_config_profiles.go b/internal/commands/pro/generated/classic_mobile_config_profiles.go index e62720b9..c6585d18 100644 --- a/internal/commands/pro/generated/classic_mobile_config_profiles.go +++ b/internal/commands/pro/generated/classic_mobile_config_profiles.go @@ -315,8 +315,11 @@ func newClassicMobileConfigProfilesListCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileConfigProfiles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -384,8 +387,11 @@ func newClassicMobileConfigProfilesGetCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileConfigProfiles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_device_groups.go b/internal/commands/pro/generated/classic_mobile_device_groups.go index 96510f70..e7d9781d 100644 --- a/internal/commands/pro/generated/classic_mobile_device_groups.go +++ b/internal/commands/pro/generated/classic_mobile_device_groups.go @@ -114,8 +114,11 @@ func newClassicMobileDeviceGroupsListCmd(ctx *registry.CLIContext) *cobra.Comman if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileDeviceGroups); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -183,8 +186,11 @@ func newClassicMobileDeviceGroupsGetCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileDeviceGroups); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_devices.go b/internal/commands/pro/generated/classic_mobile_devices.go index 94458dea..f5417486 100644 --- a/internal/commands/pro/generated/classic_mobile_devices.go +++ b/internal/commands/pro/generated/classic_mobile_devices.go @@ -371,8 +371,11 @@ func newClassicMobileDevicesListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileDevices); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -449,8 +452,11 @@ func newClassicMobileDevicesGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileDevices); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_history.go b/internal/commands/pro/generated/classic_mobile_history.go index df291c1a..5758af3e 100644 --- a/internal/commands/pro/generated/classic_mobile_history.go +++ b/internal/commands/pro/generated/classic_mobile_history.go @@ -104,8 +104,11 @@ func newClassicMobileHistoryGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileHistory); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_invitations.go b/internal/commands/pro/generated/classic_mobile_invitations.go index 1336dd44..fad67dcc 100644 --- a/internal/commands/pro/generated/classic_mobile_invitations.go +++ b/internal/commands/pro/generated/classic_mobile_invitations.go @@ -110,8 +110,11 @@ func newClassicMobileInvitationsListCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileInvitations); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -176,8 +179,11 @@ func newClassicMobileInvitationsGetCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileInvitations); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go b/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go index 73ebac31..3ff6b9af 100644 --- a/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go +++ b/internal/commands/pro/generated/classic_mobile_provisioning_profiles.go @@ -108,8 +108,11 @@ func newClassicMobileProvisioningProfilesListCmd(ctx *registry.CLIContext) *cobr if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileProvisioningProfiles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -177,8 +180,11 @@ func newClassicMobileProvisioningProfilesGetCmd(ctx *registry.CLIContext) *cobra if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileProvisioningProfiles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_network_segments.go b/internal/commands/pro/generated/classic_network_segments.go index 6a01a65e..4a4eabaf 100644 --- a/internal/commands/pro/generated/classic_network_segments.go +++ b/internal/commands/pro/generated/classic_network_segments.go @@ -102,8 +102,11 @@ func newClassicNetworkSegmentsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicNetworkSegments); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -171,8 +174,11 @@ func newClassicNetworkSegmentsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicNetworkSegments); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_packages.go b/internal/commands/pro/generated/classic_packages.go index 054eb6ab..c8addf19 100644 --- a/internal/commands/pro/generated/classic_packages.go +++ b/internal/commands/pro/generated/classic_packages.go @@ -121,8 +121,11 @@ func newClassicPackagesListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPackages); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -190,8 +193,11 @@ func newClassicPackagesGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPackages); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_patch_available_titles.go b/internal/commands/pro/generated/classic_patch_available_titles.go index 9ed677fa..e361e28a 100644 --- a/internal/commands/pro/generated/classic_patch_available_titles.go +++ b/internal/commands/pro/generated/classic_patch_available_titles.go @@ -60,8 +60,11 @@ func newClassicPatchAvailableTitlesGetCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchAvailableTitles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_patch_external_sources.go b/internal/commands/pro/generated/classic_patch_external_sources.go index 27858ad2..f70c574e 100644 --- a/internal/commands/pro/generated/classic_patch_external_sources.go +++ b/internal/commands/pro/generated/classic_patch_external_sources.go @@ -92,8 +92,11 @@ func newClassicPatchExternalSourcesListCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchExternalSources); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -161,8 +164,11 @@ func newClassicPatchExternalSourcesGetCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchExternalSources); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_patch_internal_sources.go b/internal/commands/pro/generated/classic_patch_internal_sources.go index f22584f0..7f3c9575 100644 --- a/internal/commands/pro/generated/classic_patch_internal_sources.go +++ b/internal/commands/pro/generated/classic_patch_internal_sources.go @@ -58,8 +58,11 @@ func newClassicPatchInternalSourcesListCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchInternalSources); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -127,8 +130,11 @@ func newClassicPatchInternalSourcesGetCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchInternalSources); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_patch_policies.go b/internal/commands/pro/generated/classic_patch_policies.go index f790ae8c..98a34fce 100644 --- a/internal/commands/pro/generated/classic_patch_policies.go +++ b/internal/commands/pro/generated/classic_patch_policies.go @@ -261,8 +261,11 @@ func newClassicPatchPoliciesListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchPolicies); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -315,8 +318,11 @@ func newClassicPatchPoliciesGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchPolicies); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_patch_reports.go b/internal/commands/pro/generated/classic_patch_reports.go index 7485c767..4620207b 100644 --- a/internal/commands/pro/generated/classic_patch_reports.go +++ b/internal/commands/pro/generated/classic_patch_reports.go @@ -60,8 +60,11 @@ func newClassicPatchReportsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchReports); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_patch_titles.go b/internal/commands/pro/generated/classic_patch_titles.go index d1ba6d83..f253b98e 100644 --- a/internal/commands/pro/generated/classic_patch_titles.go +++ b/internal/commands/pro/generated/classic_patch_titles.go @@ -117,8 +117,11 @@ func newClassicPatchTitlesListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchTitles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -186,8 +189,11 @@ func newClassicPatchTitlesGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPatchTitles); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_policies.go b/internal/commands/pro/generated/classic_policies.go index 2d67a4ae..105d6ad2 100644 --- a/internal/commands/pro/generated/classic_policies.go +++ b/internal/commands/pro/generated/classic_policies.go @@ -599,8 +599,11 @@ func newClassicPoliciesListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPolicies); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -668,8 +671,11 @@ func newClassicPoliciesGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPolicies); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_printers.go b/internal/commands/pro/generated/classic_printers.go index 4636b6d9..4220c3fc 100644 --- a/internal/commands/pro/generated/classic_printers.go +++ b/internal/commands/pro/generated/classic_printers.go @@ -110,8 +110,11 @@ func newClassicPrintersListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPrinters); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -179,8 +182,11 @@ func newClassicPrintersGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicPrinters); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_read_redaction_test.go b/internal/commands/pro/generated/classic_read_redaction_test.go index 75fe41de..c93dbebd 100644 --- a/internal/commands/pro/generated/classic_read_redaction_test.go +++ b/internal/commands/pro/generated/classic_read_redaction_test.go @@ -91,7 +91,8 @@ func runClassicRead(t *testing.T, body, format string, args ...string) string { // TestClassicRead_RedactsEveryCredentialFieldInAnMCPChild walks every // string-typed field the generator refuses for --set and shows a Classic get in // an MCP child prints the marker in its place, in every format a model can ask -// for. XML keeps the marker escaped, since it is element text. +// for. XML keeps the marker escaped, since it is element text, and JSON prints +// it with the formatter's usual \u003c and \u003e escapes. func TestClassicRead_RedactsEveryCredentialFieldInAnMCPChild(t *testing.T) { t.Setenv(mcpChildEnv, "1") checked := 0 @@ -124,6 +125,9 @@ func TestClassicRead_RedactsEveryCredentialFieldInAnMCPChild(t *testing.T) { if format == "" || format == "xml" || format == "raw" { marker = "<redacted>" } + if format == "json" { + got = strings.NewReplacer(`\u003c`, "<", `\u003e`, ">").Replace(got) + } if !strings.Contains(got, marker) { t.Errorf("%s get -o %q should print %s for each credential field (%v):\n%s", r.CLIName, format, marker, paths, got) } diff --git a/internal/commands/pro/generated/classic_registry.go b/internal/commands/pro/generated/classic_registry.go index f78b9d4f..79aaefbc 100644 --- a/internal/commands/pro/generated/classic_registry.go +++ b/internal/commands/pro/generated/classic_registry.go @@ -110,6 +110,73 @@ func readClassicBody(fromFile string) ([]byte, error) { return nil, nil } +// classicRedactedText is "" escaped as XML element text, so every +// output format decodes it back to the marker. +const classicRedactedText = "<redacted>" + +// redactClassicReadInMCPChild returns body with the text of every element +// named after one of spec's credential fields replaced by the redaction +// marker, when this process is a child of `mcp serve`. Every get and list +// prints through it, before choosing a format, so -o raw is not the wire +// bytes there. A body it cannot parse as XML is refused rather than printed. +func redactClassicReadInMCPChild(body []byte, spec classicBodySpec) ([]byte, error) { + if len(spec.Credentials) == 0 || !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { + return body, nil + } + if !xmlconv.IsXML(body) { + return nil, fmt.Errorf("the Classic API answered with a body that is not XML, so its credential fields cannot be redacted and it is not printed over MCP") + } + leaves := map[string]bool{} + for path := range spec.Credentials { + leaves[strings.TrimSuffix(path[strings.LastIndex(path, ".")+1:], "[]")] = true + } + type span struct{ start, end int64 } + var spans []span + var names []string + var starts []int64 + dec := xml.NewDecoder(bytes.NewReader(body)) + for { + before := dec.InputOffset() + tok, err := dec.RawToken() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("parsing the Classic API response to redact its credential fields, so it is not printed over MCP: %w", err) + } + switch t := tok.(type) { + case xml.StartElement: + names = append(names, t.Name.Local) + starts = append(starts, dec.InputOffset()) + case xml.EndElement: + n := len(names) + if n == 0 { + continue + } + if leaves[names[n-1]] && before > starts[n-1] { + spans = append(spans, span{starts[n-1], before}) + } + names, starts = names[:n-1], starts[:n-1] + } + } + if len(spans) == 0 { + return body, nil + } + sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) + var out bytes.Buffer + var cursor int64 + for _, sp := range spans { + if sp.start < cursor { + continue + } + out.Write(body[cursor:sp.start]) + out.WriteString(classicRedactedText) + cursor = sp.end + } + out.Write(body[cursor:]) + return out.Bytes(), nil +} + // ── Schema-derived request bodies (--scaffold and --set) ────────────────── // // The Classic API takes XML and its manifest (specs/classic/resources.yaml) diff --git a/internal/commands/pro/generated/classic_removable_mac_addresses.go b/internal/commands/pro/generated/classic_removable_mac_addresses.go index 65824e87..d9f8f9b3 100644 --- a/internal/commands/pro/generated/classic_removable_mac_addresses.go +++ b/internal/commands/pro/generated/classic_removable_mac_addresses.go @@ -82,8 +82,11 @@ func newClassicRemovableMacAddressesListCmd(ctx *registry.CLIContext) *cobra.Com if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicRemovableMacAddresses); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -151,8 +154,11 @@ func newClassicRemovableMacAddressesGetCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicRemovableMacAddresses); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_restricted_software.go b/internal/commands/pro/generated/classic_restricted_software.go index 24e3c970..f112b437 100644 --- a/internal/commands/pro/generated/classic_restricted_software.go +++ b/internal/commands/pro/generated/classic_restricted_software.go @@ -182,8 +182,11 @@ func newClassicRestrictedSoftwareListCmd(ctx *registry.CLIContext) *cobra.Comman if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicRestrictedSoftware); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -251,8 +254,11 @@ func newClassicRestrictedSoftwareGetCmd(ctx *registry.CLIContext) *cobra.Command if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicRestrictedSoftware); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_smtp_server.go b/internal/commands/pro/generated/classic_smtp_server.go index c3abe27c..d85e52bb 100644 --- a/internal/commands/pro/generated/classic_smtp_server.go +++ b/internal/commands/pro/generated/classic_smtp_server.go @@ -96,8 +96,11 @@ func newClassicSmtpServerGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicSmtpServer); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_software_update_servers.go b/internal/commands/pro/generated/classic_software_update_servers.go index d81519c0..6d7425e9 100644 --- a/internal/commands/pro/generated/classic_software_update_servers.go +++ b/internal/commands/pro/generated/classic_software_update_servers.go @@ -88,8 +88,11 @@ func newClassicSoftwareUpdateServersListCmd(ctx *registry.CLIContext) *cobra.Com if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicSoftwareUpdateServers); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -157,8 +160,11 @@ func newClassicSoftwareUpdateServersGetCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicSoftwareUpdateServers); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_user_ext_attrs.go b/internal/commands/pro/generated/classic_user_ext_attrs.go index de3ae2ca..9f6ea07d 100644 --- a/internal/commands/pro/generated/classic_user_ext_attrs.go +++ b/internal/commands/pro/generated/classic_user_ext_attrs.go @@ -100,8 +100,11 @@ func newClassicUserExtAttrsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicUserExtAttrs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -169,8 +172,11 @@ func newClassicUserExtAttrsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicUserExtAttrs); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_user_groups.go b/internal/commands/pro/generated/classic_user_groups.go index 80aa275f..9bf49d7c 100644 --- a/internal/commands/pro/generated/classic_user_groups.go +++ b/internal/commands/pro/generated/classic_user_groups.go @@ -115,8 +115,11 @@ func newClassicUserGroupsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicUserGroups); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -184,8 +187,11 @@ func newClassicUserGroupsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicUserGroups); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_vpp_accounts.go b/internal/commands/pro/generated/classic_vpp_accounts.go index e333490f..36a4e172 100644 --- a/internal/commands/pro/generated/classic_vpp_accounts.go +++ b/internal/commands/pro/generated/classic_vpp_accounts.go @@ -110,8 +110,11 @@ func newClassicVppAccountsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicVppAccounts); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -164,8 +167,11 @@ func newClassicVppAccountsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicVppAccounts); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_vpp_assignments.go b/internal/commands/pro/generated/classic_vpp_assignments.go index 93602198..92e5d423 100644 --- a/internal/commands/pro/generated/classic_vpp_assignments.go +++ b/internal/commands/pro/generated/classic_vpp_assignments.go @@ -168,8 +168,11 @@ func newClassicVppAssignmentsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicVppAssignments); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -222,8 +225,11 @@ func newClassicVppAssignmentsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicVppAssignments); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_vpp_invitations.go b/internal/commands/pro/generated/classic_vpp_invitations.go index 023b32ca..a9c39241 100644 --- a/internal/commands/pro/generated/classic_vpp_invitations.go +++ b/internal/commands/pro/generated/classic_vpp_invitations.go @@ -175,8 +175,11 @@ func newClassicVppInvitationsListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicVppInvitations); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -229,8 +232,11 @@ func newClassicVppInvitationsGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicVppInvitations); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/pro/generated/classic_webhooks.go b/internal/commands/pro/generated/classic_webhooks.go index 82108839..7d1b7141 100644 --- a/internal/commands/pro/generated/classic_webhooks.go +++ b/internal/commands/pro/generated/classic_webhooks.go @@ -122,8 +122,11 @@ func newClassicWebhooksListCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicWebhooks); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } @@ -191,8 +194,11 @@ func newClassicWebhooksGetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + if body, err = redactClassicReadInMCPChild(body, bodySpecClassicWebhooks); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. - // -o xml = pretty-printed XML, -o raw = exact wire bytes. + // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { return ctx.Output.PrintBytes(body) } diff --git a/internal/commands/protect_action_configs.go b/internal/commands/protect_action_configs.go index 58e33070..069f4311 100644 --- a/internal/commands/protect_action_configs.go +++ b/internal/commands/protect_action_configs.go @@ -5,7 +5,9 @@ package commands import ( "encoding/json" "fmt" + "net/url" "os" + "strings" "github.com/spf13/cobra" @@ -195,8 +197,10 @@ func newProtectActionConfigsExportCmd(cliCtx *registry.CLIContext) *cobra.Comman } // printActionConfig prints an action configuration. In a child of `mcp serve` -// each report client's header values are shown as "": they hold the -// bearer token or API key the tenant forwards alerts to a SIEM or webhook with. +// each report client's header values, and the userinfo and query of its URL, +// are shown as "": they hold the bearer token, API key or signature +// the tenant forwards alerts to a SIEM or webhook with. A secret carried in the +// URL path, as a Slack webhook's is, is still printed. func printActionConfig(out registry.OutputFormatter, a *jamfprotect.ActionConfig) error { if a != nil && os.Getenv(mcpChildEnvVar) == "1" { a = redactReportClientHeaders(*a) @@ -218,6 +222,7 @@ func redactReportClientHeaders(a jamfprotect.ActionConfig) *jamfprotect.ActionCo } c.Params.Headers = headers } + c.Params.URL = redactURLSecrets(c.Params.URL) clients[i] = c } a.Clients = clients @@ -225,6 +230,36 @@ func redactReportClientHeaders(a jamfprotect.ActionConfig) *jamfprotect.ActionCo return &a } +// redactURLSecrets replaces a URL's userinfo and query with the redaction +// marker. A value that does not parse is replaced whole when it could carry +// either, so a malformed URL fails closed. +func redactURLSecrets(raw string) string { + u, err := url.Parse(raw) + if err != nil { + if strings.ContainsAny(raw, "@?") { + return protectRedacted + } + return raw + } + if u.User == nil && u.RawQuery == "" && !u.ForceQuery { + return raw + } + hasUser, hasQuery := u.User != nil, u.RawQuery != "" || u.ForceQuery + u.User, u.RawQuery, u.ForceQuery = nil, "", false + out := u.String() + if hasUser { + out = strings.Replace(out, "//", "//"+protectRedacted+"@", 1) + } + if hasQuery { + if i := strings.IndexByte(out, '#'); i >= 0 { + out = out[:i] + "?" + protectRedacted + out[i:] + } else { + out += "?" + protectRedacted + } + } + return out +} + // actionConfigToInput converts an ActionConfig response to an ActionConfigInput, stripping server-only fields. // AlertConfig and Clients use map[string]any in the input type, so we marshal/unmarshal to convert. func actionConfigToInput(a *jamfprotect.ActionConfig) (jamfprotect.ActionConfigInput, error) { diff --git a/internal/commands/protect_plans.go b/internal/commands/protect_plans.go index 1803c163..7cae903e 100644 --- a/internal/commands/protect_plans.go +++ b/internal/commands/protect_plans.go @@ -231,6 +231,9 @@ func newProtectPlansDeleteCmd(cliCtx *registry.CLIContext) *cobra.Command { // the default can never leave the working directory. func planConfigProfileFileName(plan string) (string, error) { if plan == "" || plan == "." || plan == ".." || strings.ContainsAny(plan, `/\`) { + if registry.InMCPChild() { + return "", fmt.Errorf("plan name %q cannot be used as a file name in the working directory, and run_command cannot choose another, so this plan's profile cannot be saved through run_command", plan) + } return "", fmt.Errorf("plan name %q cannot be used as a file name in the working directory; pass -O/--output to choose where the profile is saved", plan) } return plan + ".mobileconfig", nil diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 39e64b14..432e1787 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -8,6 +8,7 @@ import ( "context" "io" "net/http" + "os" "slices" "time" @@ -338,6 +339,15 @@ type SchoolClient interface { BaseURL() string } +// MCPChildEnvVar is set to "1" on every process `mcp serve` spawns. Commands +// that print a credential the connecting model must not receive read it. +const MCPChildEnvVar = "JAMF_CLI_MCP" + +// InMCPChild reports whether this process was spawned by `mcp serve`. +func InMCPChild() bool { + return os.Getenv(MCPChildEnvVar) == "1" +} + // CLIContext holds the shared client and output formatter for all commands. // It is populated in PersistentPreRunE after token/URL resolution. type CLIContext struct { From 87f58e01b13742baa4678b9a56c9cee886b10be6 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 08:39:52 -0500 Subject: [PATCH 11/25] test(mcp): pro diff prints Classic credential fields to the model pro diff runs over MCP against the pinned profile or a backup directory inside --input-dir, and it reports each changed field's old and new value. For a policy's account password, a disk encryption configuration's institutional keystore and an account's password hash, those values are the credential, so an MCP child prints them verbatim. Outside MCP the diff must keep printing them. Co-Authored-By: Claude Opus 5.5 --- internal/commands/mcp_diff_redaction_test.go | 80 ++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 internal/commands/mcp_diff_redaction_test.go diff --git a/internal/commands/mcp_diff_redaction_test.go b/internal/commands/mcp_diff_redaction_test.go new file mode 100644 index 00000000..6d0e904a --- /dev/null +++ b/internal/commands/mcp_diff_redaction_test.go @@ -0,0 +1,80 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/output" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" +) + +const diffSecretPrefix = "S3CRET-" + +// writeDiffSides writes two backup directories whose Classic credential fields +// differ, and nothing else does. +func writeDiffSides(t *testing.T) (src, tgt string) { + t.Helper() + src, tgt = t.TempDir(), t.TempDir() + for _, side := range []struct{ dir, v string }{{src, "old"}, {tgt, "new"}} { + writeBackupFileForTest(t, filepath.Join(side.dir, "policies", "p.yaml"), map[string]any{ + "general": map[string]any{"name": "P"}, + "account_maintenance": map[string]any{"accounts": []any{ + map[string]any{"account": map[string]any{"username": "admin", "password": diffSecretPrefix + "policy-" + side.v}}, + }}, + }, "yaml") + writeBackupFileForTest(t, filepath.Join(side.dir, "disk-encryption", "d.yaml"), map[string]any{ + "name": "D", + "institutional_recovery_key": map[string]any{"key": diffSecretPrefix + "key-" + side.v, "data": diffSecretPrefix + "data-" + side.v}, + }, "yaml") + writeBackupFileForTest(t, filepath.Join(side.dir, "accounts", "users", "u.yaml"), map[string]any{ + "name": "U", + "password_sha256": diffSecretPrefix + "hash-" + side.v, + }, "yaml") + } + return src, tgt +} + +func runDiffToString(t *testing.T, src, tgt string) string { + t.Helper() + var buf bytes.Buffer + f := output.New("json", true, false) + f.SetWriter(&buf) + cliCtx := ®istry.CLIContext{Output: &cliOutput{f}} + if err := runDiff(context.Background(), cliCtx, diffOptions{Source: src, Target: tgt}); err != nil { + t.Fatalf("runDiff: %v", err) + } + return buf.String() +} + +func TestRunDiff_RedactsClassicCredentialFieldsInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnvVar, "1") + src, tgt := writeDiffSides(t) + got := runDiffToString(t, src, tgt) + if strings.Contains(got, diffSecretPrefix) { + t.Errorf("pro diff prints a Classic credential field over MCP:\n%s", got) + } + for _, field := range []string{"account_maintenance", "institutional_recovery_key", "password_sha256"} { + if !strings.Contains(got, field) { + t.Errorf("a changed credential must still be reported as a modified %s:\n%s", field, got) + } + } + if !strings.Contains(got, `redacted`) { + t.Errorf("the masked values should print the redaction marker:\n%s", got) + } +} + +func TestRunDiff_PrintsClassicCredentialFieldsOutsideMCP(t *testing.T) { + t.Setenv(mcpChildEnvVar, "") + src, tgt := writeDiffSides(t) + got := runDiffToString(t, src, tgt) + for _, v := range []string{"policy-old", "policy-new", "key-new", "data-old", "hash-new"} { + if !strings.Contains(got, diffSecretPrefix+v) { + t.Errorf("outside MCP pro diff must print %s unchanged:\n%s", v, got) + } + } +} From c5337dfec5346ec6f6dbd595a7cc1affc74a1524 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 08:42:50 -0500 Subject: [PATCH 12/25] fix(mcp): mask Classic credential fields in pro diff over MCP The generated Classic registry now exports each command group's credential element names, from the same body spec the get and list redaction uses. In an MCP child, pro diff masks every old and new value that is, or holds, one of those fields for its resource, after comparing, so a changed credential is still reported as modified. Outside MCP nothing changes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +- generator/classic/generator.go | 34 ++++++-- internal/commands/agent_context.md | 4 +- internal/commands/mcp.go | 6 +- .../pro/generated/classic_registry.go | 85 +++++++++++++++++-- internal/commands/pro_diff.go | 80 +++++++++++++++++ 6 files changed, 201 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e007132b..8fc07b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,6 @@ child process checks again before it runs. These now fail over MCP: - `protect action-configs export`, whose document carries each report client's header values, such as a SIEM or webhook bearer token. A redacted copy would overwrite the real credential when applied. -- `mcp serve --input-dir ""`, which used to start with no input directory. `config show` still runs over MCP, with each token, client ID and client secret shown as ``. `config list --status` checks only the server's @@ -63,13 +62,17 @@ values, and the userinfo and query of each report-client URL), `data-forwarding get` and `update` (the Sentinel shared key) and `api-clients get` (the password). Every Classic `get` and `list` prints each field that Classic `--set` refuses as a credential as ``, in every -output format, so `-o raw` is not the wire bytes over MCP. Outside MCP their -output is unchanged. Secrets of the pinned tenant's devices (the LAPS +output format, so `-o raw` is not the wire bytes over MCP, and `pro diff` +shows those fields' old and new values as `` while still reporting +the change. Outside MCP their output is unchanged. Secrets of the pinned tenant's devices (the LAPS password, the recovery lock password, the FileVault personal recovery key) and the JCDS upload credentials of `pro jamf-cloud-distribution-service renew-credentials` and `pro jamf-cloud-distribution-service-files create` are still shown. +`mcp serve --input-dir ""` (for example `--input-dir "$DIR"` with `DIR` +unset) is now an error instead of starting with no input directory. + **Migration:** if an agent sends file bodies through `run_command` (for example `pro scripts create --script-file …`), start the server with `mcp serve --input-dir ` and keep those files in that directory. diff --git a/generator/classic/generator.go b/generator/classic/generator.go index 578cd15f..e712ef0a 100644 --- a/generator/classic/generator.go +++ b/generator/classic/generator.go @@ -1622,6 +1622,33 @@ func RegisterClassicCommands(root *cobra.Command, ctx *registry.CLIContext) { {{- end }} } +// classicBodySpecsByCommand maps each Classic command group to its body spec. +var classicBodySpecsByCommand = map[string]classicBodySpec{ +{{- range . }} + "{{ .CLIName }}": {{ bodySpecVar . }}, +{{- end }} +} + +// ClassicCredentialLeaves returns the element names that carry a credential in +// the Classic resource whose command group is cliName, or nil for none. +func ClassicCredentialLeaves(cliName string) map[string]bool { + return classicCredentialLeaves(classicBodySpecsByCommand[cliName]) +} + +// classicCredentialLeaves is the last segment of each credential path in spec. +// Within one resource no such name is also worn by a field that is not a +// credential, so an element is matched by name alone at any depth. +func classicCredentialLeaves(spec classicBodySpec) map[string]bool { + if len(spec.Credentials) == 0 { + return nil + } + leaves := make(map[string]bool, len(spec.Credentials)) + for path := range spec.Credentials { + leaves[strings.TrimSuffix(path[strings.LastIndex(path, ".")+1:], "[]")] = true + } + return leaves +} + // readClassicBody reads an XML request body from --from-file, or from stdin when // the flag is absent. Unlike readApplyInput it tolerates an absent body and // returns nil, leaving the caller to decide whether that is an error — classic @@ -1657,16 +1684,13 @@ const classicRedactedText = "<redacted>" // prints through it, before choosing a format, so -o raw is not the wire // bytes there. A body it cannot parse as XML is refused rather than printed. func redactClassicReadInMCPChild(body []byte, spec classicBodySpec) ([]byte, error) { - if len(spec.Credentials) == 0 || !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { + leaves := classicCredentialLeaves(spec) + if len(leaves) == 0 || !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { return body, nil } if !xmlconv.IsXML(body) { return nil, fmt.Errorf("the Classic API answered with a body that is not XML, so its credential fields cannot be redacted and it is not printed over MCP") } - leaves := map[string]bool{} - for path := range spec.Credentials { - leaves[strings.TrimSuffix(path[strings.LastIndex(path, ".")+1:], "[]")] = true - } type span struct{ start, end int64 } var spans []span var names []string diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index c8506d6d..e90c6731 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -123,8 +123,8 @@ but the pinned profile. `config show` runs with every credential field shown as ``, and so do the report-client header values and URL userinfo and query of `protect action-configs get` and `apply`, the Sentinel shared key of `protect data-forwarding get` and `update`, the password of `protect api-clients -get`, and every Classic `get` and `list` field that Classic `--set` refuses as a -credential. Secrets of the pinned tenant's devices (the LAPS password, the +get`, and every Classic field that Classic `--set` refuses as a credential, in +`get`, `list` and the old and new values `pro diff` reports. Secrets of the pinned tenant's devices (the LAPS password, the recovery lock password, the FileVault personal recovery key) and the JCDS upload credentials are shown. A relative read path resolves against the server's start directory, so pass an absolute one. An administrator who diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 6de7b444..929a7c73 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -134,7 +134,8 @@ api-clients get', and every Classic 'get' and 'list' field the Classic --set refuses as a credential (an SMTP, LDAP, webhook, directory binding or distribution point password, the VPP sToken, the JWT signing key, the institutional FileVault keystore). So a Classic '-o raw' is not the wire bytes -here. +here, and 'pro diff' shows those fields' old and new values as while +still reporting the change. Secrets of the pinned tenant's own devices are shown: the LAPS password, the recovery lock password, the FileVault personal recovery key, and the bootstrap token, unlock token and AirPlay password in device inventory. So are the JCDS @@ -243,7 +244,8 @@ value is an error; there is no config key for it.`, "stdout as text and the dashboard writes a 320-800 KB HTML document there. " + "Report-client header values and URL userinfo and query, the Sentinel " + "shared key, Protect API client passwords and Classic credential fields " + - "(passwords, the VPP sToken, the JWT signing key) print as ; " + + "(passwords, the VPP sToken, the JWT signing key), in 'get', 'list' and " + + "'pro diff', print as ; " + "device secrets such as the LAPS password are shown. " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + "require an explicit --yes in args or they will refuse to run.", diff --git a/internal/commands/pro/generated/classic_registry.go b/internal/commands/pro/generated/classic_registry.go index 79aaefbc..4fc07e2e 100644 --- a/internal/commands/pro/generated/classic_registry.go +++ b/internal/commands/pro/generated/classic_registry.go @@ -85,6 +85,84 @@ func RegisterClassicCommands(root *cobra.Command, ctx *registry.CLIContext) { root.AddCommand(NewClassicWebhooksCmd(ctx)) } +// classicBodySpecsByCommand maps each Classic command group to its body spec. +var classicBodySpecsByCommand = map[string]classicBodySpec{ + "classic-account-groups": bodySpecClassicAccountGroups, + "classic-account-users": bodySpecClassicAccountUsers, + "classic-accounts": bodySpecClassicAccounts, + "classic-advanced-computer-searches": bodySpecClassicAdvancedComputerSearches, + "classic-advanced-mobile-device-searches": bodySpecClassicAdvancedMobileDeviceSearches, + "classic-allowed-file-extensions": bodySpecClassicAllowedFileExtensions, + "classic-classes": bodySpecClassicClasses, + "classic-computer-apps": bodySpecClassicComputerApps, + "classic-computer-commands": bodySpecClassicComputerCommands, + "classic-computer-configs": bodySpecClassicComputerConfigs, + "classic-computer-ext-attrs": bodySpecClassicComputerExtAttrs, + "classic-computer-groups": bodySpecClassicComputerGroups, + "classic-computer-history": bodySpecClassicComputerHistory, + "classic-computer-invitations": bodySpecClassicComputerInvitations, + "classic-directory-bindings": bodySpecClassicDirectoryBindings, + "classic-disk-encryption-configs": bodySpecClassicDiskEncryptionConfigs, + "classic-distribution-points": bodySpecClassicDistributionPoints, + "classic-dock-items": bodySpecClassicDockItems, + "classic-ebooks": bodySpecClassicEbooks, + "classic-gsx-connection": bodySpecClassicGsxConnection, + "classic-ibeacons": bodySpecClassicIbeacons, + "classic-jwt-configs": bodySpecClassicJwtConfigs, + "classic-ldap-servers": bodySpecClassicLdapServers, + "classic-licensed-software": bodySpecClassicLicensedSoftware, + "classic-mac-apps": bodySpecClassicMacApps, + "classic-macos-config-profiles": bodySpecClassicMacosConfigProfiles, + "classic-mobile-apps": bodySpecClassicMobileApps, + "classic-mobile-commands": bodySpecClassicMobileCommands, + "classic-mobile-config-profiles": bodySpecClassicMobileConfigProfiles, + "classic-mobile-device-groups": bodySpecClassicMobileDeviceGroups, + "classic-mobile-devices": bodySpecClassicMobileDevices, + "classic-mobile-history": bodySpecClassicMobileHistory, + "classic-mobile-invitations": bodySpecClassicMobileInvitations, + "classic-mobile-provisioning-profiles": bodySpecClassicMobileProvisioningProfiles, + "classic-network-segments": bodySpecClassicNetworkSegments, + "classic-packages": bodySpecClassicPackages, + "classic-patch-available-titles": bodySpecClassicPatchAvailableTitles, + "classic-patch-external-sources": bodySpecClassicPatchExternalSources, + "classic-patch-internal-sources": bodySpecClassicPatchInternalSources, + "classic-patch-policies": bodySpecClassicPatchPolicies, + "classic-patch-reports": bodySpecClassicPatchReports, + "classic-patch-titles": bodySpecClassicPatchTitles, + "classic-policies": bodySpecClassicPolicies, + "classic-printers": bodySpecClassicPrinters, + "classic-removable-mac-addresses": bodySpecClassicRemovableMacAddresses, + "classic-restricted-software": bodySpecClassicRestrictedSoftware, + "classic-smtp-server": bodySpecClassicSmtpServer, + "classic-software-update-servers": bodySpecClassicSoftwareUpdateServers, + "classic-user-ext-attrs": bodySpecClassicUserExtAttrs, + "classic-user-groups": bodySpecClassicUserGroups, + "classic-vpp-accounts": bodySpecClassicVppAccounts, + "classic-vpp-assignments": bodySpecClassicVppAssignments, + "classic-vpp-invitations": bodySpecClassicVppInvitations, + "classic-webhooks": bodySpecClassicWebhooks, +} + +// ClassicCredentialLeaves returns the element names that carry a credential in +// the Classic resource whose command group is cliName, or nil for none. +func ClassicCredentialLeaves(cliName string) map[string]bool { + return classicCredentialLeaves(classicBodySpecsByCommand[cliName]) +} + +// classicCredentialLeaves is the last segment of each credential path in spec. +// Within one resource no such name is also worn by a field that is not a +// credential, so an element is matched by name alone at any depth. +func classicCredentialLeaves(spec classicBodySpec) map[string]bool { + if len(spec.Credentials) == 0 { + return nil + } + leaves := make(map[string]bool, len(spec.Credentials)) + for path := range spec.Credentials { + leaves[strings.TrimSuffix(path[strings.LastIndex(path, ".")+1:], "[]")] = true + } + return leaves +} + // readClassicBody reads an XML request body from --from-file, or from stdin when // the flag is absent. Unlike readApplyInput it tolerates an absent body and // returns nil, leaving the caller to decide whether that is an error — classic @@ -120,16 +198,13 @@ const classicRedactedText = "<redacted>" // prints through it, before choosing a format, so -o raw is not the wire // bytes there. A body it cannot parse as XML is refused rather than printed. func redactClassicReadInMCPChild(body []byte, spec classicBodySpec) ([]byte, error) { - if len(spec.Credentials) == 0 || !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { + leaves := classicCredentialLeaves(spec) + if len(leaves) == 0 || !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { return body, nil } if !xmlconv.IsXML(body) { return nil, fmt.Errorf("the Classic API answered with a body that is not XML, so its credential fields cannot be redacted and it is not printed over MCP") } - leaves := map[string]bool{} - for path := range spec.Credentials { - leaves[strings.TrimSuffix(path[strings.LastIndex(path, ".")+1:], "[]")] = true - } type span struct{ start, end int64 } var spans []span var names []string diff --git a/internal/commands/pro_diff.go b/internal/commands/pro_diff.go index d23b8cc2..6508af0c 100644 --- a/internal/commands/pro_diff.go +++ b/internal/commands/pro_diff.go @@ -19,6 +19,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/auth" "github.com/Jamf-Concepts/jamf-cli/internal/client" + "github.com/Jamf-Concepts/jamf-cli/internal/commands/pro/generated" "github.com/Jamf-Concepts/jamf-cli/internal/config" "github.com/Jamf-Concepts/jamf-cli/internal/registry" "github.com/jamf/jamfplatform-go-sdk/jamfplatform/blueprints" @@ -675,6 +676,9 @@ func runDiff(ctx context.Context, cliCtx *registry.CLIContext, opts diffOptions) } results := compareSnapshots(srcSnapshot, tgtSnapshot) + if registry.InMCPChild() { + redactDiffCredentials(results, classicCredentialLeavesByFilter()) + } if len(results) == 0 { fmt.Fprintln(os.Stderr, "No differences found.") @@ -699,3 +703,79 @@ func runDiff(ctx context.Context, cliCtx *registry.CLIContext, opts diffOptions) return printRows(cliCtx, rows) } + +// classicCredentialLeavesByFilter maps each diff resource filter to the element +// names its Classic resources carry a credential in. +func classicCredentialLeavesByFilter() map[string]map[string]bool { + out := map[string]map[string]bool{} + for _, r := range BackupResources { + if !generated.BackupEndpoints[r.Key].IsClassic { + continue + } + for leaf := range generated.ClassicCredentialLeaves(r.Key) { + if out[r.FilterName] == nil { + out[r.FilterName] = map[string]bool{} + } + out[r.FilterName][leaf] = true + } + } + return out +} + +// redactDiffCredentials masks, in place, every old and new value that is or +// holds a credential field of its resource. It runs after the comparison, so a +// changed credential is still reported as modified. +func redactDiffCredentials(results []diffResult, leavesByFilter map[string]map[string]bool) { + for i := range results { + r := &results[i] + leaves := leavesByFilter[r.Resource] + if len(leaves) == 0 || r.Field == "" { + continue + } + r.OldValue = redactDiffValue(r.Field, r.OldValue, leaves) + r.NewValue = redactDiffValue(r.Field, r.NewValue, leaves) + } +} + +// redactDiffValue masks v when field is itself a credential, or masks each +// credential inside v when v is the JSON formatFieldValue renders a nested +// field as. +func redactDiffValue(field, v string, leaves map[string]bool) string { + if v == "" || v == "" { + return v + } + if leaves[field] { + return protectRedacted + } + var decoded any + if json.Unmarshal([]byte(v), &decoded) != nil || !redactCredentialKeys(decoded, leaves) { + return v + } + b, err := json.Marshal(decoded) + if err != nil { + return protectRedacted + } + return string(b) +} + +// redactCredentialKeys replaces each non-empty string under a key in leaves, +// at any depth, and reports whether it replaced one. +func redactCredentialKeys(v any, leaves map[string]bool) bool { + changed := false + switch t := v.(type) { + case map[string]any: + for k, child := range t { + if s, ok := child.(string); ok && s != "" && leaves[k] { + t[k] = protectRedacted + changed = true + continue + } + changed = redactCredentialKeys(child, leaves) || changed + } + case []any: + for _, child := range t { + changed = redactCredentialKeys(child, leaves) || changed + } + } + return changed +} From 9f23d678c1295b27399d59673fdeaae2d4c5a178 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 10:24:00 -0500 Subject: [PATCH 13/25] fix(mcp): run cloud-distribution-point list over MCP with the key redacted In an MCP child, a client decorator replaces privateKey and password in the GET /v1/cloud-distribution-point response with before any formatter sees it, so every output format, --select and --field print the redacted record. Outside MCP the response is unchanged. create and patch stay refused. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- internal/commands/agent_context.md | 5 +- internal/commands/mcp.go | 19 ++--- internal/commands/mcp_cdn_key_redaction.go | 73 +++++++++++++++++++ .../commands/mcp_cdn_key_redaction_test.go | 72 ++++++++++++++++++ internal/commands/mcp_secret_leaves_test.go | 1 - internal/commands/root.go | 3 + 7 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 internal/commands/mcp_cdn_key_redaction.go create mode 100644 internal/commands/mcp_cdn_key_redaction_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c1dca359..033033ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ child process checks again before it runs. These now fail over MCP: - The commands that mint a credential and print it: `pro api-integrations client-credentials` (a new client secret) and `protect api-clients apply` (a new API client's password). -- `pro cloud-distribution-point list`, `create` and `patch`, whose response +- `pro cloud-distribution-point create` and `patch`, whose response carries the CloudFront private key that signs download URLs. - The commands that set a Jamf Pro login password to a value the model chose: `pro jamf-pro-user-account-settings change-password` and @@ -60,7 +60,9 @@ profile. These Protect commands also run over MCP with the credential shown as ``: `action-configs get` and `apply` (each report client's header values, and the userinfo and query of each report-client URL), `data-forwarding get` and `update` (the Sentinel shared key) and -`api-clients get` (the password). Every Classic `get` and `list` prints each +`api-clients get` (the password). `pro cloud-distribution-point list` runs +over MCP with the CloudFront private key and the CDN password shown as +`` in every output format. Every Classic `get` and `list` prints each field that Classic `--set` refuses as a credential as ``, in every output format, so `-o raw` is not the wire bytes over MCP, and `pro diff` shows those fields' old and new values as `` while still reporting diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index e90c6731..0d436463 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -113,7 +113,7 @@ commands that print an access token (`auth token` under `platform`, `pro` and `protect`; `pro api-authentication token`, `oauth-token` and `keep-alive`; `pro sso-oauth-session-tokens`), the commands that mint and print a credential (`pro api-integrations client-credentials`, `protect api-clients apply`), `pro -cloud-distribution-point list`, `create` and `patch` (a CloudFront private key), +cloud-distribution-point create` and `patch` (a CloudFront private key), the commands that set a Jamf Pro login password (`pro jamf-pro-user-account-settings change-password`, `pro accounts create`, `update`, `apply`), `protect downloads csr` and `websocket-auth` (they write a @@ -123,7 +123,8 @@ but the pinned profile. `config show` runs with every credential field shown as ``, and so do the report-client header values and URL userinfo and query of `protect action-configs get` and `apply`, the Sentinel shared key of `protect data-forwarding get` and `update`, the password of `protect api-clients -get`, and every Classic field that Classic `--set` refuses as a credential, in +get`, the CloudFront private key and CDN password of `pro +cloud-distribution-point list`, and every Classic field that Classic `--set` refuses as a credential, in `get`, `list` and the old and new values `pro diff` reports. Secrets of the pinned tenant's devices (the LAPS password, the recovery lock password, the FileVault personal recovery key) and the JCDS upload credentials are shown. A relative read path resolves against the server's start diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 929a7c73..972b1fb2 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -108,8 +108,8 @@ spelling. It refuses: tokens - 'pro api-integrations client-credentials' and 'protect api-clients apply', which mint a new client secret or password and print it - - 'pro cloud-distribution-point list', 'create' and 'patch', whose response - carries the CloudFront private key that signs download URLs + - 'pro cloud-distribution-point create' and 'patch', whose response carries + the CloudFront private key that signs download URLs - 'pro jamf-pro-user-account-settings change-password' and 'pro accounts create', 'update' and 'apply', which set a Jamf Pro login password to a value the model chose @@ -130,10 +130,11 @@ here: the report-client header values and the userinfo and query of each report-client URL in 'protect action-configs get' and 'apply' (a secret in a URL path, as a Slack webhook carries, is still shown), the Sentinel shared key of 'protect data-forwarding get' and 'update', the password of 'protect -api-clients get', and every Classic 'get' and 'list' field the Classic --set -refuses as a credential (an SMTP, LDAP, webhook, directory binding or -distribution point password, the VPP sToken, the JWT signing key, the -institutional FileVault keystore). So a Classic '-o raw' is not the wire bytes +api-clients get', the CloudFront private key and CDN password of 'pro +cloud-distribution-point list', and every Classic 'get' and 'list' field the +Classic --set refuses as a credential (an SMTP, LDAP, webhook, directory +binding or distribution point password, the VPP sToken, the JWT signing key, +the institutional FileVault keystore). So a Classic '-o raw' is not the wire bytes here, and 'pro diff' shows those fields' old and new values as while still reporting the change. Secrets of the pinned tenant's own devices are shown: the LAPS password, the @@ -232,7 +233,7 @@ value is an error; there is no config key for it.`, "'pro sso-oauth-session-tokens', " + "the commands that mint and print a credential ('pro api-integrations " + "client-credentials', 'protect api-clients apply'), " + - "'pro cloud-distribution-point list', 'create' and 'patch' (they print a " + + "'pro cloud-distribution-point create' and 'patch' (they print a " + "CloudFront private key), the commands that set a Jamf Pro login password " + "('pro jamf-pro-user-account-settings change-password', 'pro accounts " + "create', 'update', 'apply'), 'protect downloads csr' and 'websocket-auth', " + @@ -243,7 +244,8 @@ value is an error; there is no config key for it.`, " Use generate_report rather than 'dashboard': this tool returns " + "stdout as text and the dashboard writes a 320-800 KB HTML document there. " + "Report-client header values and URL userinfo and query, the Sentinel " + - "shared key, Protect API client passwords and Classic credential fields " + + "shared key, Protect API client passwords, the CloudFront private key of " + + "'pro cloud-distribution-point list' and Classic credential fields " + "(passwords, the VPP sToken, the JWT signing key), in 'get', 'list' and " + "'pro diff', print as ; " + "device secrets such as the LAPS password are shown. " + @@ -598,7 +600,6 @@ var mcpRefusedCommands = []refusedCommand{ {"jamf-cli pro api-authentication keep-alive", refusedPrintsToken}, {"jamf-cli pro sso-oauth-session-tokens", refusedPrintsToken}, {"jamf-cli pro api-integrations client-credentials", refusedMintsClientSecret}, - {"jamf-cli pro cloud-distribution-point list", refusedPrintsCDNKey}, {"jamf-cli pro cloud-distribution-point create", refusedPrintsCDNKey}, {"jamf-cli pro cloud-distribution-point patch", refusedPrintsCDNKey}, {"jamf-cli pro jamf-pro-user-account-settings change-password", refusedSetsLoginPassword}, diff --git a/internal/commands/mcp_cdn_key_redaction.go b/internal/commands/mcp_cdn_key_redaction.go new file mode 100644 index 00000000..35e56d1f --- /dev/null +++ b/internal/commands/mcp_cdn_key_redaction.go @@ -0,0 +1,73 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + "github.com/Jamf-Concepts/jamf-cli/internal/registry" +) + +// cloudDistributionPointSecrets are the fields of the cloud distribution point +// that work outside an MCP server: the CloudFront signing key and the CDN +// password. keyPairId is the public half's ID and stays. +var cloudDistributionPointSecrets = []string{"privateKey", "password"} + +// cdnKeyRedactingClient replaces the cloud distribution point's credential +// fields with in every response to its GET, so each output format +// renders the redacted body. Installed only in an MCP child. +type cdnKeyRedactingClient struct { + inner registry.HTTPClient +} + +func (c *cdnKeyRedactingClient) Do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + resp, err := c.inner.Do(ctx, method, path, body) + if err != nil { + return resp, err + } + if p, _, _ := strings.Cut(path, "?"); method != "GET" || p != "/v1/cloud-distribution-point" { + return resp, nil + } + raw, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + raw = redactCloudDistributionPoint(raw) + resp.Body = io.NopCloser(bytes.NewReader(raw)) + resp.ContentLength = int64(len(raw)) + if resp.Header != nil { + resp.Header.Set("Content-Length", strconv.Itoa(len(raw))) + } + return resp, nil +} + +func redactCloudDistributionPoint(raw []byte) []byte { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var obj map[string]any + if dec.Decode(&obj) != nil { + return raw + } + changed := false + for _, k := range cloudDistributionPointSecrets { + if v, ok := obj[k]; ok && v != nil && v != "" { + obj[k] = protectRedacted + changed = true + } + } + if !changed { + return raw + } + var out bytes.Buffer + enc := json.NewEncoder(&out) + enc.SetEscapeHTML(false) + _ = enc.Encode(obj) + return bytes.TrimSuffix(out.Bytes(), []byte("\n")) +} diff --git a/internal/commands/mcp_cdn_key_redaction_test.go b/internal/commands/mcp_cdn_key_redaction_test.go new file mode 100644 index 00000000..82e21ed3 --- /dev/null +++ b/internal/commands/mcp_cdn_key_redaction_test.go @@ -0,0 +1,72 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestCloudDistributionPointList_RedactsTheKeyOnlyInAnMCPChild(t *testing.T) { + const privateKey = "-----BEGIN RSA PRIVATE KEY-----MIIEcanary" + const password = "cdn-password-canary" + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/cloud-distribution-point", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"cdnType":"AMAZON_S3","keyPairId":"APKAEXAMPLE","privateKey":"` + privateKey + `","password":"` + password + `","expirationSeconds":3600}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + run := func(t *testing.T, mcpChild, format string) string { + t.Helper() + resetGlobals() + t.Cleanup(resetGlobals) + isolated := t.TempDir() + t.Setenv("HOME", isolated) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, "config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(isolated, "cache")) + t.Setenv("JAMF_PROFILE", "") + t.Setenv("JAMF_URL", srv.URL) + t.Setenv("JAMF_TOKEN", "fake-token") + t.Setenv("JAMF_CLIENT_ID", "") + t.Setenv("JAMF_CLIENT_SECRET", "") + t.Setenv("JAMF_CLI_ARGS", "") + t.Setenv(mcpChildEnvVar, mcpChild) + + args := []string{"pro", "cloud-distribution-point", "list", "-o", format} + if mcpChild == "1" { + childArgs, err := buildChildArgs("", args) + if err != nil { + t.Fatalf("run_command refuses %v: %v", args, err) + } + args = childArgs + } + stdout, stderr, err := runRoot(t, args...) + if err != nil { + t.Fatalf("%v: %v\nstderr: %s", args, err, stderr) + } + return stdout + } + + for _, format := range []string{"json", "table"} { + t.Run("mcp/"+format, func(t *testing.T) { + out := run(t, "1", format) + if strings.Contains(out, privateKey) || strings.Contains(out, password) { + t.Errorf("-o %s in an MCP child printed a CDN credential:\n%s", format, out) + } + if !strings.Contains(out, protectRedacted) || !strings.Contains(out, "APKAEXAMPLE") { + t.Errorf("-o %s in an MCP child should print the record with the marker:\n%s", format, out) + } + }) + t.Run("cli/"+format, func(t *testing.T) { + out := run(t, "", format) + if !strings.Contains(out, privateKey) || strings.Contains(out, protectRedacted) { + t.Errorf("-o %s outside MCP should print the key unchanged:\n%s", format, out) + } + }) + } +} diff --git a/internal/commands/mcp_secret_leaves_test.go b/internal/commands/mcp_secret_leaves_test.go index e2a3da1a..9f8f588b 100644 --- a/internal/commands/mcp_secret_leaves_test.go +++ b/internal/commands/mcp_secret_leaves_test.go @@ -186,7 +186,6 @@ func TestMCP_RefusesCommandsThatPrintOrSetALoginCredential(t *testing.T) { want string }{ {[]string{"pro", "sso-oauth-session-tokens", "list"}, "access token"}, - {[]string{"pro", "cloud-distribution-point", "list"}, "private key"}, {[]string{"pro", "cloud-distribution-point", "create", "--from-file", "x"}, "private key"}, {[]string{"pro", "cloud-distribution-point", "patch"}, "private key"}, {[]string{"pro", "jamf-pro-user-account-settings", "change-password"}, "password"}, diff --git a/internal/commands/root.go b/internal/commands/root.go index 1135054b..80d8d852 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -931,6 +931,9 @@ in the config file. It never runs in CI, when output is piped, or under proClient := &cliClient{client.New(resolvedURL, authProvider, clientOpts...)} cliCtx.Uploader = proClient // set before wrapping with decorators var httpClient registry.HTTPClient = proClient + if registry.InMCPChild() { + httpClient = &cdnKeyRedactingClient{inner: httpClient} + } if dryRun { httpClient = &dryRunClient{inner: httpClient} } From 8b7ab942470128058a87b1b26b2fb9231639e92f Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 10:42:21 -0500 Subject: [PATCH 14/25] test(mcp): classify cloud-ldap update in the secret-naming guard #407 added the --set credential-refusal sentence to cloud-ldap update's help, so the guard now sees it. It sends the keystore and its password and its response keystore carries only the name, type and expiry. Co-Authored-By: Claude Opus 5.5 --- internal/commands/mcp_secret_leaves_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/commands/mcp_secret_leaves_test.go b/internal/commands/mcp_secret_leaves_test.go index 9f8f588b..4bbe25ef 100644 --- a/internal/commands/mcp_secret_leaves_test.go +++ b/internal/commands/mcp_secret_leaves_test.go @@ -52,6 +52,7 @@ var notACredentialPrinter = map[string]string{ "pro jamf-cloud-distribution-service renew-credentials": exemptJCDS, "pro jamf-cloud-distribution-service-files create": exemptJCDS, + "pro cloud-ldap update": "sends the keystore and its password in the request body; the response's keystore is CloudLdapKeystore, which carries only its name, type and expiry", "pro adcs-settings patch": exemptWriteOnly, "pro adcs-settings validate-client-certificate": exemptWriteOnly, "pro computer-prestages update": exemptWriteOnly, From a7612bfee112a7132738db13158241f61bcf8fd9 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 13:58:06 -0500 Subject: [PATCH 15/25] test(mcp): configuration profile payload secrets reach the model An MCP-child get of a Classic macOS or mobile profile prints a Wi-Fi password, an EAP password, a VPN shared secret and XAuth password, a SCEP challenge and a PKCS#12 blob with its password in every -o format, because they sit inside the escaped plist in . pro diff on the profiles filter prints them too. Co-Authored-By: Claude Opus 5.5 --- .../mcp_diff_payload_redaction_test.go | 72 +++++++++ .../classic_profile_payload_redaction_test.go | 148 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 internal/commands/mcp_diff_payload_redaction_test.go create mode 100644 internal/commands/pro/generated/classic_profile_payload_redaction_test.go diff --git a/internal/commands/mcp_diff_payload_redaction_test.go b/internal/commands/mcp_diff_payload_redaction_test.go new file mode 100644 index 00000000..7e43ce9c --- /dev/null +++ b/internal/commands/mcp_diff_payload_redaction_test.go @@ -0,0 +1,72 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "path/filepath" + "strings" + "testing" +) + +func wifiProfilePlist(password string) string { + return ` + + + PayloadContent + + + PayloadTypecom.apple.wifi.managed + PayloadIdentifiercom.example.wifi + SSID_STRCorpWiFi + Password` + password + ` + + + PayloadIdentifiercom.example.profile + + +` +} + +// writeProfileDiffSides writes two backups whose macOS and iOS profiles differ +// only in a Wi-Fi password, plus one iOS profile whose payloads do not decode. +func writeProfileDiffSides(t *testing.T) (src, tgt string) { + t.Helper() + src, tgt = t.TempDir(), t.TempDir() + for _, side := range []struct{ dir, v string }{{src, "old"}, {tgt, "new"}} { + writeBackupFileForTest(t, filepath.Join(side.dir, "profiles", "macos", "w.yaml"), map[string]any{ + "general": map[string]any{"name": "Wi-Fi mac", "payloads": wifiProfilePlist(diffSecretPrefix + "mac-" + side.v)}, + }, "yaml") + writeBackupFileForTest(t, filepath.Join(side.dir, "profiles", "ios", "w.yaml"), map[string]any{ + "general": map[string]any{"name": "Wi-Fi ios", "payloads": wifiProfilePlist(diffSecretPrefix + "ios-" + side.v)}, + }, "yaml") + writeBackupFileForTest(t, filepath.Join(side.dir, "profiles", "ios", "b.yaml"), map[string]any{ + "general": map[string]any{"name": "Broken", "payloads": "not a plist " + diffSecretPrefix + "broken-" + side.v}, + }, "yaml") + } + return src, tgt +} + +func TestRunDiff_RedactsProfilePayloadSecretsInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnvVar, "1") + src, tgt := writeProfileDiffSides(t) + got := runDiffToString(t, src, tgt) + if strings.Contains(got, diffSecretPrefix) { + t.Errorf("pro diff prints a profile payload secret over MCP:\n%s", got) + } + for _, want := range []string{"Wi-Fi mac", "Wi-Fi ios", "Broken", "SSID_STR", "CorpWiFi", "redacted"} { + if !strings.Contains(got, want) { + t.Errorf("pro diff over MCP should still report %q:\n%s", want, got) + } + } +} + +func TestRunDiff_PrintsProfilePayloadsOutsideMCP(t *testing.T) { + t.Setenv(mcpChildEnvVar, "") + src, tgt := writeProfileDiffSides(t) + got := runDiffToString(t, src, tgt) + for _, v := range []string{"mac-old", "ios-new", "broken-old"} { + if !strings.Contains(got, diffSecretPrefix+v) { + t.Errorf("outside MCP pro diff must print %s unchanged:\n%s", v, got) + } + } +} diff --git a/internal/commands/pro/generated/classic_profile_payload_redaction_test.go b/internal/commands/pro/generated/classic_profile_payload_redaction_test.go new file mode 100644 index 00000000..c730eb19 --- /dev/null +++ b/internal/commands/pro/generated/classic_profile_payload_redaction_test.go @@ -0,0 +1,148 @@ +// Copyright 2026, Jamf Software LLC + +package generated + +import ( + "encoding/base64" + "encoding/xml" + "strings" + "testing" +) + +// payloadSecretPlist is a configuration profile carrying one secret of each +// kind a Wi-Fi, VPN, SCEP or identity payload holds. Every secret begins with +// classicSecretPrefix; a data secret is base64 on the wire, so it is checked +// in that form too. +var payloadSecretPlist = ` + + + + PayloadContent + + + PayloadTypecom.apple.wifi.managed + PayloadIdentifiercom.example.wifi + SSID_STRCorpWiFi + Password` + classicSecretPrefix + `wifi + PayloadCertificatePassword` + classicSecretPrefix + `certpass + EAPClientConfiguration + + UserNamewifi-user + UserPassword` + classicSecretPrefix + `eap + + + + PayloadTypecom.apple.vpn.managed + PayloadIdentifiercom.example.vpn + IPSec + + SharedSecret` + payloadDataSecret("shared") + ` + XAuthPassword` + classicSecretPrefix + `xauth + + + + PayloadTypecom.apple.security.scep + PayloadIdentifiercom.example.scep + PayloadContent + + URLhttps://scep.example.com + Challenge` + classicSecretPrefix + `challenge + + + + PayloadTypecom.apple.security.pkcs12 + PayloadIdentifiercom.example.identity + PayloadCertificateFileNameidentity.p12 + Password` + classicSecretPrefix + `p12pass + PayloadContent` + payloadDataSecret("p12") + ` + + + PayloadTypecom.example.custom + PayloadIdentifiercom.example.custom + adminpassword` + classicSecretPrefix + `lowercase + + + PayloadIdentifiercom.example.profile + PayloadTypeConfiguration + + +` + +func payloadDataSecret(name string) string { + return base64.StdEncoding.EncodeToString([]byte(classicSecretPrefix + name)) +} + +var payloadSecretSpellings = []string{classicSecretPrefix, payloadDataSecret("shared"), payloadDataSecret("p12")} + +var payloadVisibleKeys = []string{"SSID_STR", "CorpWiFi", "com.example.wifi", "wifi-user", "https://scep.example.com", "identity.p12", "com.example.profile"} + +// classicProfileBody is a Classic GET answer for one profile, with payloads as +// the escaped text the server sends. +func classicProfileBody(root, payloads string) string { + var esc strings.Builder + _ = xml.EscapeText(&esc, []byte(payloads)) + return `<` + root + `>1Corp` + esc.String() + `false` +} + +var classicProfileResources = []struct{ cli, root string }{ + {"classic-macos-config-profiles", "os_x_configuration_profile"}, + {"classic-mobile-config-profiles", "configuration_profile"}, +} + +var everyReadFormat = []string{"", "json", "yaml", "raw", "xml", "table", "plain", "ndjson"} + +func TestClassicProfileGet_RedactsPayloadSecretsInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnv, "1") + for _, r := range classicProfileResources { + body := classicProfileBody(r.root, payloadSecretPlist) + for _, format := range everyReadFormat { + got := runClassicRead(t, body, format, r.cli, "get", "1") + for _, s := range payloadSecretSpellings { + if strings.Contains(got, s) { + t.Errorf("%s get -o %q prints a payload secret (%s) over MCP:\n%s", r.cli, format, s, got) + } + } + if !strings.Contains(got, "redacted") { + t.Errorf("%s get -o %q should print the redaction marker in place of each payload secret:\n%s", r.cli, format, got) + } + if format == "table" || format == "plain" { + continue + } + for _, k := range payloadVisibleKeys { + if !strings.Contains(got, k) { + t.Errorf("%s get -o %q dropped %q, which is not a secret:\n%s", r.cli, format, k, got) + } + } + } + } +} + +func TestClassicProfileGet_UndecodablePayloadsFailClosedInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnv, "1") + for _, r := range classicProfileResources { + body := classicProfileBody(r.root, "not a plist Password"+classicSecretPrefix+"broken") + for _, format := range []string{"json", "raw"} { + got := runClassicRead(t, body, format, r.cli, "get", "1") + if strings.Contains(got, classicSecretPrefix) { + t.Errorf("%s get -o %s prints an undecodable payload over MCP:\n%s", r.cli, format, got) + } + if !strings.Contains(got, "redacted") || !strings.Contains(got, "Corp") { + t.Errorf("%s get -o %s should keep the record and print the marker for its payloads:\n%s", r.cli, format, got) + } + } + } +} + +func TestClassicProfileGet_OutsideMCPPrintsPayloadsUnchanged(t *testing.T) { + t.Setenv(mcpChildEnv, "") + for _, r := range classicProfileResources { + body := classicProfileBody(r.root, payloadSecretPlist) + if got := runClassicRead(t, body, "raw", r.cli, "get", "1"); strings.TrimSpace(got) != strings.TrimSpace(body) { + t.Errorf("%s get -o raw outside MCP must print the wire bytes unchanged:\n got %s\nwant %s", r.cli, got, body) + } + got := runClassicRead(t, body, "json", r.cli, "get", "1") + if !strings.Contains(got, classicSecretPrefix+"wifi") || strings.Contains(got, "redacted") { + t.Errorf("%s get -o json outside MCP must print the payload unchanged:\n%s", r.cli, got) + } + } +} From 2b4bd96ea987cf2597e5c6856fb1909d92a54087 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 14:00:58 -0500 Subject: [PATCH 16/25] fix(mcp): redact secrets inside configuration profile payloads A Classic profile's Wi-Fi, EAP, VPN, SCEP and identity secrets sit in the escaped plist in , which the element-level Classic redaction never sees. In an MCP child, get and list on both profile resources now decode that plist with profileconvert and replace each string or data value under a key ending in password or secret, a Challenge, and a com.apple.security.pkcs12 PayloadContent, matched case-insensitively at any depth. The result is re-escaped into , so the body stays a profile document. A payload that does not decode is replaced whole, and a body that is not XML is refused. pro diff applies the same redaction to the profiles filter. Co-Authored-By: Claude Opus 5.5 --- generator/classic/generator.go | 42 +++++ .../classic_macos_config_profiles.go | 6 + .../classic_mobile_config_profiles.go | 6 + .../pro/generated/classic_registry.go | 31 ++++ internal/commands/pro_diff.go | 82 ++++++++ internal/profileconvert/payload_secrets.go | 175 ++++++++++++++++++ .../profileconvert/payload_secrets_test.go | 86 +++++++++ 7 files changed, 428 insertions(+) create mode 100644 internal/profileconvert/payload_secrets.go create mode 100644 internal/profileconvert/payload_secrets_test.go diff --git a/generator/classic/generator.go b/generator/classic/generator.go index f5631e6a..44b89f00 100644 --- a/generator/classic/generator.go +++ b/generator/classic/generator.go @@ -620,6 +620,11 @@ func new{{ .GoName }}ListCmd(ctx *registry.CLIContext) *cobra.Command { if body, err = redactClassicReadInMCPChild(body, {{ bodySpecVar . }}); err != nil { return err } +{{- if .IsConfigProfile }} + if body, err = redactClassicProfilePayloadsInMCPChild(body); err != nil { + return err + } +{{- end }} {{- if .ListSubset }} // /JSSResource/{{ .Path }} returns users + groups combined; narrow to // the "{{ .ListSubset }}" subset so this command behaves like a @@ -731,6 +736,11 @@ func new{{ .GoName }}GetCmd(ctx *registry.CLIContext) *cobra.Command { if body, err = redactClassicReadInMCPChild(body, {{ bodySpecVar . }}); err != nil { return err } +{{- if .IsConfigProfile }} + if body, err = redactClassicProfilePayloadsInMCPChild(body); err != nil { + return err + } +{{- end }} // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { @@ -1761,6 +1771,38 @@ func redactClassicReadInMCPChild(body []byte, spec classicBodySpec) ([]byte, err return out.Bytes(), nil } +// classicProfilePayloadCommands are the Classic command groups whose records +// carry a configuration profile's plist in . +var classicProfilePayloadCommands = map[string]bool{ +{{- range . }}{{ if .IsConfigProfile }} + "{{ .CLIName }}": true, +{{- end }}{{ end }} +} + +// ClassicCarriesProfilePayloads reports whether the Classic resource whose +// command group is cliName carries a configuration profile in . +func ClassicCarriesProfilePayloads(cliName string) bool { + return classicProfilePayloadCommands[cliName] +} +{{ if anyIsConfigProfile . }} +// redactClassicProfilePayloadsInMCPChild returns body with each secret inside +// a configuration profile's plist replaced by the redaction marker, +// when this process is a child of mcp serve. A payload that does not decode is +// replaced whole, and a body that is not XML is refused rather than printed. +func redactClassicProfilePayloadsInMCPChild(body []byte) ([]byte, error) { + if !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { + return body, nil + } + if !xmlconv.IsXML(body) { + return nil, fmt.Errorf("the Classic API answered with a body that is not XML, so its profile payloads cannot be redacted and it is not printed over MCP") + } + out, err := profileconvert.RedactClassicProfilePayloads(body) + if err != nil { + return nil, fmt.Errorf("parsing the Classic API response to redact its profile payloads, so it is not printed over MCP: %w", err) + } + return out, nil +} +{{ end }} // ── Schema-derived request bodies (--scaffold and --set) ────────────────── // // The Classic API takes XML and its manifest (specs/classic/resources.yaml) diff --git a/internal/commands/pro/generated/classic_macos_config_profiles.go b/internal/commands/pro/generated/classic_macos_config_profiles.go index 1a2b7211..1c4a4a87 100644 --- a/internal/commands/pro/generated/classic_macos_config_profiles.go +++ b/internal/commands/pro/generated/classic_macos_config_profiles.go @@ -331,6 +331,9 @@ func newClassicMacosConfigProfilesListCmd(ctx *registry.CLIContext) *cobra.Comma if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMacosConfigProfiles); err != nil { return err } + if body, err = redactClassicProfilePayloadsInMCPChild(body); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { @@ -403,6 +406,9 @@ func newClassicMacosConfigProfilesGetCmd(ctx *registry.CLIContext) *cobra.Comman if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMacosConfigProfiles); err != nil { return err } + if body, err = redactClassicProfilePayloadsInMCPChild(body); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { diff --git a/internal/commands/pro/generated/classic_mobile_config_profiles.go b/internal/commands/pro/generated/classic_mobile_config_profiles.go index c6585d18..81500d7a 100644 --- a/internal/commands/pro/generated/classic_mobile_config_profiles.go +++ b/internal/commands/pro/generated/classic_mobile_config_profiles.go @@ -318,6 +318,9 @@ func newClassicMobileConfigProfilesListCmd(ctx *registry.CLIContext) *cobra.Comm if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileConfigProfiles); err != nil { return err } + if body, err = redactClassicProfilePayloadsInMCPChild(body); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { @@ -390,6 +393,9 @@ func newClassicMobileConfigProfilesGetCmd(ctx *registry.CLIContext) *cobra.Comma if body, err = redactClassicReadInMCPChild(body, bodySpecClassicMobileConfigProfiles); err != nil { return err } + if body, err = redactClassicProfilePayloadsInMCPChild(body); err != nil { + return err + } // Default to pretty-printed XML; use -o json/yaml/table/csv for structured output. // -o xml = pretty-printed XML, -o raw = the wire bytes outside an MCP child. if (!cmd.Flags().Changed("output") && !cmd.Flags().Changed("field") && ctx.Output.Format() == "json") || ctx.Output.Format() == "xml" || ctx.Output.Format() == "raw" { diff --git a/internal/commands/pro/generated/classic_registry.go b/internal/commands/pro/generated/classic_registry.go index 4fc07e2e..d613905d 100644 --- a/internal/commands/pro/generated/classic_registry.go +++ b/internal/commands/pro/generated/classic_registry.go @@ -252,6 +252,37 @@ func redactClassicReadInMCPChild(body []byte, spec classicBodySpec) ([]byte, err return out.Bytes(), nil } +// classicProfilePayloadCommands are the Classic command groups whose records +// carry a configuration profile's plist in . +var classicProfilePayloadCommands = map[string]bool{ + "classic-macos-config-profiles": true, + "classic-mobile-config-profiles": true, +} + +// ClassicCarriesProfilePayloads reports whether the Classic resource whose +// command group is cliName carries a configuration profile in . +func ClassicCarriesProfilePayloads(cliName string) bool { + return classicProfilePayloadCommands[cliName] +} + +// redactClassicProfilePayloadsInMCPChild returns body with each secret inside +// a configuration profile's plist replaced by the redaction marker, +// when this process is a child of mcp serve. A payload that does not decode is +// replaced whole, and a body that is not XML is refused rather than printed. +func redactClassicProfilePayloadsInMCPChild(body []byte) ([]byte, error) { + if !registry.InMCPChild() || len(bytes.TrimSpace(body)) == 0 { + return body, nil + } + if !xmlconv.IsXML(body) { + return nil, fmt.Errorf("the Classic API answered with a body that is not XML, so its profile payloads cannot be redacted and it is not printed over MCP") + } + out, err := profileconvert.RedactClassicProfilePayloads(body) + if err != nil { + return nil, fmt.Errorf("parsing the Classic API response to redact its profile payloads, so it is not printed over MCP: %w", err) + } + return out, nil +} + // ── Schema-derived request bodies (--scaffold and --set) ────────────────── // // The Classic API takes XML and its manifest (specs/classic/resources.yaml) diff --git a/internal/commands/pro_diff.go b/internal/commands/pro_diff.go index 6508af0c..3a025100 100644 --- a/internal/commands/pro_diff.go +++ b/internal/commands/pro_diff.go @@ -21,6 +21,7 @@ import ( "github.com/Jamf-Concepts/jamf-cli/internal/client" "github.com/Jamf-Concepts/jamf-cli/internal/commands/pro/generated" "github.com/Jamf-Concepts/jamf-cli/internal/config" + "github.com/Jamf-Concepts/jamf-cli/internal/profileconvert" "github.com/Jamf-Concepts/jamf-cli/internal/registry" "github.com/jamf/jamfplatform-go-sdk/jamfplatform/blueprints" "github.com/jamf/jamfplatform-go-sdk/jamfplatform/compliancebenchmarks" @@ -678,6 +679,7 @@ func runDiff(ctx context.Context, cliCtx *registry.CLIContext, opts diffOptions) results := compareSnapshots(srcSnapshot, tgtSnapshot) if registry.InMCPChild() { redactDiffCredentials(results, classicCredentialLeavesByFilter()) + redactDiffProfilePayloads(results, classicProfilePayloadFilters()) } if len(results) == 0 { @@ -722,6 +724,86 @@ func classicCredentialLeavesByFilter() map[string]map[string]bool { return out } +// classicProfilePayloadFilters are the diff resource filters whose Classic +// records carry a configuration profile in general.payloads. +func classicProfilePayloadFilters() map[string]bool { + out := map[string]bool{} + for _, r := range BackupResources { + if generated.ClassicCarriesProfilePayloads(r.Key) { + out[r.FilterName] = true + } + } + return out +} + +// redactDiffProfilePayloads masks, in place, each secret inside a profile +// payload that an old or new value carries. It runs after the comparison, so a +// changed Wi-Fi password is still reported as a modified field. +func redactDiffProfilePayloads(results []diffResult, filters map[string]bool) { + for i := range results { + r := &results[i] + if !filters[r.Resource] || r.Field == "" { + continue + } + r.OldValue = redactDiffPayloadValue(r.Field, r.OldValue) + r.NewValue = redactDiffPayloadValue(r.Field, r.NewValue) + } +} + +// redactDiffPayloadValue redacts v when field is the payloads plist itself, or +// each payloads plist inside v when v is the JSON of a nested field. +func redactDiffPayloadValue(field, v string) string { + if v == "" || v == "" { + return v + } + if field == "payloads" { + return redactPayloadPlist(v) + } + var decoded any + if json.Unmarshal([]byte(v), &decoded) != nil || !redactPayloadsKeys(decoded) { + return v + } + b, err := json.Marshal(decoded) + if err != nil { + return protectRedacted + } + return string(b) +} + +// redactPayloadPlist redacts the secrets in one payloads plist, or replaces it +// whole when it does not decode. +func redactPayloadPlist(s string) string { + out, err := profileconvert.RedactPayloadSecrets([]byte(s)) + if err != nil { + return protectRedacted + } + return string(out) +} + +// redactPayloadsKeys redacts each non-empty string under a payloads key, at +// any depth, and reports whether it changed one. +func redactPayloadsKeys(v any) bool { + changed := false + switch t := v.(type) { + case map[string]any: + for k, child := range t { + if s, ok := child.(string); ok && k == "payloads" && s != "" { + if r := redactPayloadPlist(s); r != s { + t[k] = r + changed = true + } + continue + } + changed = redactPayloadsKeys(child) || changed + } + case []any: + for _, child := range t { + changed = redactPayloadsKeys(child) || changed + } + } + return changed +} + // redactDiffCredentials masks, in place, every old and new value that is or // holds a credential field of its resource. It runs after the comparison, so a // changed credential is still reported as modified. diff --git a/internal/profileconvert/payload_secrets.go b/internal/profileconvert/payload_secrets.go new file mode 100644 index 00000000..e606efba --- /dev/null +++ b/internal/profileconvert/payload_secrets.go @@ -0,0 +1,175 @@ +// Copyright 2026, Jamf Software LLC + +package profileconvert + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "strings" + + "howett.net/plist" +) + +// RedactedPayloadValue is what a secret inside a profile payload is replaced by. +const RedactedPayloadValue = "" + +// isSecretPayloadKey reports whether a payload key holds a secret that works +// outside the profile: a Wi-Fi, EAP, VPN, account or identity password, a +// shared or client secret, or a SCEP challenge. Compared case-insensitively, +// because custom payloads do not follow Apple's casing. +func isSecretPayloadKey(key string) bool { + k := strings.ToLower(key) + return k == "challenge" || strings.HasSuffix(k, "password") || strings.HasSuffix(k, "secret") +} + +// RedactPayloadSecrets returns profile, a configuration profile plist, with the +// value of every secret key at any depth replaced by RedactedPayloadValue, and +// the PayloadContent of a com.apple.security.pkcs12 payload too. A profile with +// no secret is returned unchanged; one with a secret is re-serialised as XML. +// It fails when profile is not a plist whose top level is a dictionary. +func RedactPayloadSecrets(profile []byte) ([]byte, error) { + var v any + if _, err := plist.Unmarshal(profile, &v); err != nil { + return nil, fmt.Errorf("parsing profile plist: %w", err) + } + if _, ok := v.(map[string]any); !ok { + return nil, fmt.Errorf("profile plist is not a dictionary") + } + if !redactPayloadSecrets(v) { + return profile, nil + } + out, err := plist.MarshalIndent(v, plist.XMLFormat, "\t") + if err != nil { + return nil, fmt.Errorf("re-serialising profile plist: %w", err) + } + return out, nil +} + +func redactPayloadSecrets(v any) bool { + changed := false + switch t := v.(type) { + case map[string]any: + pkcs12 := isPKCS12Payload(t) + for k, child := range t { + if (isSecretPayloadKey(k) || pkcs12 && strings.EqualFold(k, "PayloadContent")) && isScalarSecret(child) { + t[k] = RedactedPayloadValue + changed = true + continue + } + changed = redactPayloadSecrets(child) || changed + } + case []any: + for _, child := range t { + changed = redactPayloadSecrets(child) || changed + } + } + return changed +} + +func isPKCS12Payload(d map[string]any) bool { + for k, v := range d { + if s, ok := v.(string); ok && strings.EqualFold(k, "PayloadType") && strings.EqualFold(s, "com.apple.security.pkcs12") { + return true + } + } + return false +} + +func isScalarSecret(v any) bool { + switch t := v.(type) { + case string: + return t != "" + case []byte: + return len(t) > 0 + } + return false +} + +// RedactClassicProfilePayloads returns a Classic configuration profile body +// with RedactPayloadSecrets applied to the plist inside each +// element, re-escaped as element text. A that does not decode is +// replaced whole by RedactedPayloadValue. It fails when body is not well-formed +// XML, so a caller can refuse it rather than print it. +func RedactClassicProfilePayloads(body []byte) ([]byte, error) { + type span struct { + start, end int64 + text string + } + var spans []span + dec := xml.NewDecoder(bytes.NewReader(body)) + var ( + in, nested bool + depth int + start int64 + content strings.Builder + ) + for { + before := dec.InputOffset() + tok, err := dec.RawToken() + if err == io.EOF { + if depth != 0 { + return nil, io.ErrUnexpectedEOF + } + break + } + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + depth++ + if in { + nested = true + } else if t.Name.Local == "payloads" { + in, nested, start = true, false, dec.InputOffset() + content.Reset() + } + case xml.CharData: + if in { + content.Write(t) + } + case xml.EndElement: + depth-- + if in && t.Name.Local == "payloads" { + in = false + if text, changed := redactedPayloadsText(content.String(), nested); changed { + spans = append(spans, span{start, before, text}) + } + } + } + } + var out bytes.Buffer + var cursor int64 + for _, sp := range spans { + out.Write(body[cursor:sp.start]) + out.WriteString(sp.text) + cursor = sp.end + } + out.Write(body[cursor:]) + return out.Bytes(), nil +} + +// redactedPayloadsText is the escaped element text that replaces a +// whose decoded text is profile, and whether it differs from what was there. A +// payloads element holding child elements, or a profile that does not decode, +// becomes the marker alone. +func redactedPayloadsText(profile string, nested bool) (string, bool) { + if !nested && strings.TrimSpace(profile) == "" { + return "", false + } + redacted := []byte(RedactedPayloadValue) + if !nested { + out, err := RedactPayloadSecrets([]byte(profile)) + if err == nil && string(out) == profile { + return "", false + } + if err == nil { + redacted = out + } + } + var esc strings.Builder + _ = xml.EscapeText(&esc, redacted) + return esc.String(), true +} diff --git a/internal/profileconvert/payload_secrets_test.go b/internal/profileconvert/payload_secrets_test.go new file mode 100644 index 00000000..9533694a --- /dev/null +++ b/internal/profileconvert/payload_secrets_test.go @@ -0,0 +1,86 @@ +// Copyright 2026, Jamf Software LLC + +package profileconvert + +import ( + "strings" + "testing" + + "howett.net/plist" +) + +func decodePlist(t *testing.T, b []byte) map[string]any { + t.Helper() + var v map[string]any + if _, err := plist.Unmarshal(b, &v); err != nil { + t.Fatalf("redacted output is not a plist: %v\n%s", err, b) + } + return v +} + +func TestRedactPayloadSecrets_KeepsPayloadContentOutsidePKCS12(t *testing.T) { + in := ` +PayloadContent +PayloadTypeCOM.APPLE.SECURITY.PKCS12PayloadContentc2VjcmV0 +PayloadTypecom.apple.security.rootPayloadContentcHVibGlj +PayloadTypecom.apple.wifi.managedPASSWORDsAutoJoin +` + out, err := RedactPayloadSecrets([]byte(in)) + if err != nil { + t.Fatal(err) + } + payloads := decodePlist(t, out)["PayloadContent"].([]any) + if got := payloads[0].(map[string]any)["PayloadContent"]; got != RedactedPayloadValue { + t.Errorf("a pkcs12 PayloadContent should be redacted, got %v", got) + } + if got, ok := payloads[1].(map[string]any)["PayloadContent"].([]byte); !ok || string(got) != "public" { + t.Errorf("a root certificate's PayloadContent is not a secret and must stay, got %v", payloads[1]) + } + wifi := payloads[2].(map[string]any) + if wifi["PASSWORD"] != RedactedPayloadValue || wifi["AutoJoin"] != true { + t.Errorf("an upper-case password key should be redacted and a boolean kept, got %v", wifi) + } +} + +func TestRedactPayloadSecrets_ReturnsAProfileWithoutSecretsUnchanged(t *testing.T) { + in := []byte(`PayloadIdentifierxRequirePassword`) + out, err := RedactPayloadSecrets(in) + if err != nil || string(out) != string(in) { + t.Errorf("a profile with no secret should come back byte-identical, got %q, %v", out, err) + } +} + +func TestRedactPayloadSecrets_RefusesWhatIsNotAProfile(t *testing.T) { + for _, in := range []string{"not a plist s", "hello", `Password`} { + if _, err := RedactPayloadSecrets([]byte(in)); err == nil { + t.Errorf("RedactPayloadSecrets(%q) should fail so a caller can redact the whole payload", in) + } + } +} + +func TestRedactClassicProfilePayloads_HandlesCDATAAndFailsClosed(t *testing.T) { + profile := `PasswordS3CRETSSID_STRCorp` + for name, body := range map[string]string{ + "cdata": `

`, + "nested": `

S3CRET

`, + "broken": `

S3CRET

`, + } { + out, err := RedactClassicProfilePayloads([]byte(body)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if strings.Contains(string(out), "S3CRET") || !strings.Contains(string(out), "redacted") { + t.Errorf("%s: the secret should be gone and the marker present:\n%s", name, out) + } + if !strings.HasPrefix(string(out), "

") || !strings.HasSuffix(string(out), "

") { + t.Errorf("%s: the document around the payload should be untouched:\n%s", name, out) + } + } + if _, err := RedactClassicProfilePayloads([]byte(`

`)); err == nil { + t.Error("a truncated body should fail so the caller refuses it") + } + keep := `

n

` + if out, _ := RedactClassicProfilePayloads([]byte(keep)); string(out) != keep { + t.Errorf("an empty payloads element should be left alone, got %s", out) + } +} From 2b9f2de622eabc20ae6e08e3957f253b198aeadd Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 14:02:17 -0500 Subject: [PATCH 17/25] docs(mcp): name profile payload redaction and blueprint configuration The mcpRefusedCommands policy comment, mcp serve --help, the run_command tool description, agent_context.md and the CHANGELOG now say that the secrets inside a Classic configuration profile's payloads print as , and that blueprint configuration is shown: the Platform SDK decodes each response inside its transport, so there is no per-response hook to redact it at. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++++---- internal/commands/agent_context.md | 12 +++++++---- internal/commands/mcp.go | 32 ++++++++++++++++++++++++------ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2abb8ce0..f65acb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,10 +66,17 @@ over MCP with the CloudFront private key and the CDN password shown as field that Classic `--set` refuses as a credential as ``, in every output format, so `-o raw` is not the wire bytes over MCP, and `pro diff` shows those fields' old and new values as `` while still reporting -the change. Outside MCP their output is unchanged. Secrets of the pinned tenant's devices (the LAPS -password, the recovery lock password, the FileVault personal recovery key) -and the JCDS upload credentials of `pro jamf-cloud-distribution-service -renew-credentials` and `pro jamf-cloud-distribution-service-files create` are +the change. `get` and `list` on `classic-macos-config-profiles` and +`classic-mobile-config-profiles`, and `pro diff` on `profiles`, also print +each secret inside a profile's payloads as ``: a Wi-Fi, EAP, VPN or +account password, a VPN shared secret, a SCEP challenge, and an identity +certificate with its password. The payload is re-encoded, so the record is +still a profile document, and a payload that does not decode is redacted +whole. Outside MCP their output is unchanged. Secrets of the pinned tenant's devices (the LAPS +password, the recovery lock password, the FileVault personal recovery key), +the JCDS upload credentials of `pro jamf-cloud-distribution-service +renew-credentials` and `pro jamf-cloud-distribution-service-files create`, +and blueprint configuration, a secret a component carries included, are still shown. `mcp serve --input-dir ""` (for example `--input-dir "$DIR"` with `DIR` diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 0d436463..574ee8cd 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -124,10 +124,14 @@ but the pinned profile. `config show` runs with every credential field shown as query of `protect action-configs get` and `apply`, the Sentinel shared key of `protect data-forwarding get` and `update`, the password of `protect api-clients get`, the CloudFront private key and CDN password of `pro -cloud-distribution-point list`, and every Classic field that Classic `--set` refuses as a credential, in -`get`, `list` and the old and new values `pro diff` reports. Secrets of the pinned tenant's devices (the LAPS password, the -recovery lock password, the FileVault personal recovery key) and the JCDS upload -credentials are shown. A relative read path resolves against the server's start +cloud-distribution-point list`, every Classic field that Classic `--set` refuses as a credential, and +each secret inside a Classic configuration profile's payloads (a Wi-Fi, EAP, +VPN or account password, a VPN shared secret, a SCEP challenge, an identity +certificate and its password), in `get`, `list` and the old and new values +`pro diff` reports. Secrets of the pinned tenant's devices (the LAPS password, the +recovery lock password, the FileVault personal recovery key), the JCDS upload +credentials and blueprint configuration, a secret a component carries +included, are shown. A relative read path resolves against the server's start directory, so pass an absolute one. An administrator who starts the server with `--input-dir ` allows the read-side flags, and a `pro diff` side that is a directory, for existing paths inside that directory; diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 972b1fb2..802f999a 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -134,14 +134,21 @@ api-clients get', the CloudFront private key and CDN password of 'pro cloud-distribution-point list', and every Classic 'get' and 'list' field the Classic --set refuses as a credential (an SMTP, LDAP, webhook, directory binding or distribution point password, the VPP sToken, the JWT signing key, -the institutional FileVault keystore). So a Classic '-o raw' is not the wire bytes -here, and 'pro diff' shows those fields' old and new values as while -still reporting the change. +the institutional FileVault keystore). Each secret inside a configuration +profile's payloads in 'classic-macos-config-profiles' and +'classic-mobile-config-profiles' is redacted too: a Wi-Fi, EAP, VPN or account +password, a VPN shared secret, a SCEP challenge, and an identity certificate +with its password. A payload that does not decode is redacted whole. So a +Classic '-o raw' is not the wire bytes here, and 'pro diff' shows those +fields' old and new values as while still reporting the change. Secrets of the pinned tenant's own devices are shown: the LAPS password, the recovery lock password, the FileVault personal recovery key, and the bootstrap token, unlock token and AirPlay password in device inventory. So are the JCDS upload credentials of 'pro jamf-cloud-distribution-service renew-credentials' -and 'pro jamf-cloud-distribution-service-files create'. +and 'pro jamf-cloud-distribution-service-files create'. Blueprint configuration +is shown as the Platform API answers it, a secret a component carries +included, in the 'pro blueprints' and 'school blueprints' reads and in 'pro +diff' on blueprints. Some allowed commands write a file into the directory this server was started in, named by Jamf or by the command's own argument: the other 'protect downloads' and 'pro jcds download' without -O, and 'protect plans @@ -246,9 +253,11 @@ value is an error; there is no config key for it.`, "Report-client header values and URL userinfo and query, the Sentinel " + "shared key, Protect API client passwords, the CloudFront private key of " + "'pro cloud-distribution-point list' and Classic credential fields " + - "(passwords, the VPP sToken, the JWT signing key), in 'get', 'list' and " + + "(passwords, the VPP sToken, the JWT signing key), and the secrets inside " + + "Classic configuration profile payloads (Wi-Fi, VPN and identity passwords, " + + "shared secrets, SCEP challenges, PKCS#12 certificates), in 'get', 'list' and " + "'pro diff', print as ; " + - "device secrets such as the LAPS password are shown. " + + "device secrets such as the LAPS password, and blueprint configuration, are shown. " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + "require an explicit --yes in args or they will refuse to run.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in runCommandInput) (*mcp.CallToolResult, any, error) { @@ -580,6 +589,17 @@ var blockedChildFlagPrefixes = []string{ // jamf-cloud-distribution-service renew-credentials` and // `pro jamf-cloud-distribution-service-files create`, whose upload credentials // reach only the pinned tenant's JCDS bucket. +// +// A command that prints such a credential inside a larger record runs with it +// redacted instead, in every output format: the Classic credential fields +// (redactClassicReadInMCPChild), the secrets in a Classic configuration +// profile's payloads plist (redactClassicProfilePayloadsInMCPChild, through +// profileconvert.RedactPayloadSecrets), the cloud distribution point's keys +// (cdnKeyRedactingClient), and the same fields in `pro diff`. Blueprint +// configuration is not redacted: the Platform SDK decodes each response +// inside its transport, so there is no per-response hook, and a component's +// secret keys vary by declaration type. It is named as shown in the help and +// the tool description instead. // TestMCPSecretNamingLeaves_AreClassified holds every leaf whose help names a // credential to one side of this line. var mcpRefusedCommands = []refusedCommand{ From 11e1092ba179472bcb37c2b34ad6447b5e64f9dc Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 14:06:42 -0500 Subject: [PATCH 18/25] test(mcp): classify every response that returns a secret-named field TestMCPSecretNamingLeaves_AreClassified judges a leaf on its help, so pro cloud-distribution-point list, whose response carries the CloudFront private key, passed it. The new guard walks the 2xx response schemas of the Pro, Platform and Security Cloud specs, items and nested objects included, for a string property named like a secret that is not writeOnly, and requires each operation to carry a verdict: its leaf refused over MCP, or a reason it may reach the model (redacted, a device secret, a timestamp, an enum or a label). Each verdict lists the exact properties it judged, so a new secret on an exempted response fails. A second test feeds it a fabricated response and requires the failure. Co-Authored-By: Claude Opus 5.5 --- .../commands/mcp_response_secrets_test.go | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 internal/commands/mcp_response_secrets_test.go diff --git a/internal/commands/mcp_response_secrets_test.go b/internal/commands/mcp_response_secrets_test.go new file mode 100644 index 00000000..7111a29f --- /dev/null +++ b/internal/commands/mcp_response_secrets_test.go @@ -0,0 +1,282 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "fmt" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/generator/parser" + platformgen "github.com/Jamf-Concepts/jamf-cli/generator/platform" + securitygen "github.com/Jamf-Concepts/jamf-cli/generator/security" + "github.com/spf13/cobra" +) + +// responseSecretVerdict classifies one operation whose 2xx response declares a +// readable string property named like a secret. TestMCPSecretNamingLeaves_AreClassified +// judges a leaf on its help, and help need not name what the response +// carries: `pro cloud-distribution-point list` returns the CloudFront private +// key and passed that guard. This one judges the response. +type responseSecretVerdict struct { + // props is every flagged property, space-separated in sorted order, so a + // secret added to an already-classified response fails until judged. + props string + // leaf is the command printing the response, for a verdict that depends on + // what that command does over MCP. + leaf string + // refused requires leaf to be refused over MCP. + refused bool + // reason says why the properties may reach the model; empty only when refused. + reason string +} + +const ( + exemptTimestamp = "a date or expiry, not a secret" + exemptLabel = "the label shown beside a password prompt, not a password" + exemptRedactedCDP = "printed with the key as by cdnKeyRedactingClient in an MCP child" + exemptDDMServerToken = "the declaration's ServerToken, a version hash a device echoes back, not a credential" +) + +// responseSecretVerdicts is keyed "METHOD path" as the spec declares the path. +var responseSecretVerdicts = map[string]responseSecretVerdict{ + "GET /devices/v1/devices/{id}": {props: ".security.bootstrapTokenEscrowedStatus", reason: "an escrow status enum, not the token"}, + "GET /partners/v1/distributor/configuration": {props: ".webhook.secretName", reason: "names the webhook's secret, not its value"}, + "GET /sso/v1/connections/{connectionId}": {props: ".oidcOptions.tokenEndpoint .oktaOptions.tokenEndpoint .tokenEndpointAuthMethod", reason: "the token endpoint's URL and its auth method enum"}, + "POST /sso/v1/connections": {props: ".oidcOptions.tokenEndpoint .oktaOptions.tokenEndpoint .tokenEndpointAuthMethod", reason: "the token endpoint's URL and its auth method enum"}, + "PUT /sso/v1/connections/{connectionId}": {props: ".oidcOptions.tokenEndpoint .oktaOptions.tokenEndpoint .tokenEndpointAuthMethod", reason: "the token endpoint's URL and its auth method enum"}, + + "GET /v1/accounts/{id}": {props: ".lastPasswordChange", reason: exemptTimestamp}, + "POST /v1/accounts": {props: ".lastPasswordChange", reason: exemptTimestamp}, + "PUT /v1/accounts/{id}": {props: ".lastPasswordChange", reason: exemptTimestamp}, + "GET /v1/device-enrollments/{id}": {props: ".tokenExpirationDate", reason: exemptTimestamp}, + "PUT /v1/device-enrollments/{id}": {props: ".tokenExpirationDate", reason: exemptTimestamp}, + "PUT /v1/device-enrollments/{id}/upload-token": {props: ".tokenExpirationDate", reason: exemptTimestamp}, + "GET /v1/volume-purchasing-locations/{id}": {props: ".tokenExpiration", reason: exemptTimestamp}, + "PATCH /v1/volume-purchasing-locations/{id}": {props: ".tokenExpiration", reason: exemptTimestamp}, + + "GET /v1/enrollment-customization/{id}/ldap/{panel-id}": {props: ".passwordLabel", reason: exemptLabel}, + "POST /v1/enrollment-customization/{id}/ldap": {props: ".passwordLabel", reason: exemptLabel}, + "PUT /v1/enrollment-customization/{id}/ldap/{panel-id}": {props: ".passwordLabel", reason: exemptLabel}, + "GET /v3/enrollment/languages/{languageId}": {props: ".password", reason: exemptLabel}, + "PUT /v3/enrollment/languages/{languageId}": {props: ".password", reason: exemptLabel}, + "GET /v3/computer-prestages/{id}": {props: ".recoveryLockPasswordType", reason: "an enum naming how the recovery lock password is set"}, + "PUT /v3/computer-prestages/{id}": {props: ".recoveryLockPasswordType", reason: "an enum naming how the recovery lock password is set"}, + + "GET /v2/local-admin-password/{clientManagementId}/account/{username}/password": {props: ".password", leaf: "pro local-admin-password password", reason: exemptDeviceSecret}, + "GET /v2/local-admin-password/{clientManagementId}/account/{username}/{guid}/password": {props: ".password", leaf: "pro local-admin-password password-by-guid", reason: exemptDeviceSecret}, + "GET /v2/mobile-devices/{id}/detail": {props: mobileDeviceSecretProps, leaf: "pro mobile-devices detail-by-id", reason: exemptDeviceSecret}, + "PATCH /v2/mobile-devices/{id}": {props: mobileDeviceSecretProps, leaf: "pro mobile-devices patch", reason: exemptSetsDeviceSecret}, + "POST /v1/jcds/files": {props: ".secretAccessKey .sessionToken", leaf: "pro jamf-cloud-distribution-service-files create", reason: exemptJCDS}, + "POST /v1/jcds/renew-credentials": {props: ".secretAccessKey .sessionToken", leaf: "pro jamf-cloud-distribution-service renew-credentials", reason: exemptJCDS}, + "GET /v1/cloud-distribution-point": {props: ".privateKey", leaf: "pro cloud-distribution-point list", reason: exemptRedactedCDP}, + + "GET /ddm/report/v1/declarations/{declarationIdentifier}/devices": {props: ".results.serverToken", reason: exemptDDMServerToken}, + "GET /ddm/report/v1/devices/{deviceId}/declarations": {props: ".results.serverToken", reason: exemptDDMServerToken}, + "GET /sso/v1/connections": {props: ".results.tokenEndpointAuthMethod", reason: "the token endpoint's auth method enum"}, + "GET /v1/accounts": {props: ".results.lastPasswordChange", reason: exemptTimestamp}, + "GET /v1/device-enrollments": {props: ".results.tokenExpirationDate", reason: exemptTimestamp}, + "GET /v1/volume-purchasing-locations": {props: ".results.tokenExpiration", reason: exemptTimestamp}, + "GET /v3/computer-prestages": {props: ".results.recoveryLockPasswordType", reason: "an enum naming how the recovery lock password is set"}, + "GET /v3/enrollment/languages": {props: ".results.password", reason: exemptLabel}, + "GET /v4/computers-inventory": {props: ".results.security.bootstrapTokenEscrowedStatus", reason: "an escrow status enum, not the token"}, + "GET /v4/computers-inventory/{id}": {props: ".security.bootstrapTokenEscrowedStatus", reason: "an escrow status enum, not the token"}, + "GET /v4/computers-inventory-detail/{id}": {props: ".security.bootstrapTokenEscrowedStatus", reason: "an escrow status enum, not the token"}, + "GET /v2/mobile-devices/detail": {props: ".results.security.bootstrapTokenEscrowed", reason: "an escrow flag, not the token"}, + "GET /v2/mobile-devices/{id}/paired-devices": {props: ".results.security.bootstrapTokenEscrowed", reason: "an escrow flag, not the token"}, + + "GET /v2/local-admin-password/{clientManagementId}/account/{username}/audit": {props: ".results.password", leaf: "pro local-admin-password audit", reason: exemptDeviceSecret}, + "GET /v2/local-admin-password/{clientManagementId}/account/{username}/{guid}/audit": {props: ".results.password", leaf: "pro local-admin-password audit-by-guid", reason: exemptDeviceSecret}, + "GET /v4/computers-inventory/{id}/view-recovery-lock-password": {props: ".recoveryLockPassword", leaf: "pro computer-inventory view-recovery-lock-password", reason: exemptDeviceSecret}, + "GET /v2/mobile-device-groups/smart-group-membership/{id}": {props: ".results.airPlayPassword", reason: exemptDeviceSecret}, + "GET /v2/mobile-device-groups/static-group-membership/{id}": {props: ".results.airPlayPassword", reason: exemptDeviceSecret}, + + "POST /auth/keepAlive": {props: ".token", leaf: "pro api-authentication keep-alive", refused: true}, + "POST /v1/cloud-distribution-point": {props: ".privateKey", leaf: "pro cloud-distribution-point create", refused: true}, + "PATCH /v1/cloud-distribution-point": {props: ".privateKey", leaf: "pro cloud-distribution-point patch", refused: true}, + "GET /v1/oauth2/session-tokens": {props: ".accessToken .idToken", leaf: "pro sso-oauth-session-tokens list", refused: true}, + "POST /v1/api-integrations/{id}/client-credentials": {props: ".clientSecret", leaf: "pro api-integrations client-credentials", refused: true}, + "POST /v1/auth/token": {props: ".token", leaf: "pro api-authentication token", refused: true}, + "POST /v1/auth/keep-alive": {props: ".token", leaf: "pro api-authentication keep-alive", refused: true}, + "POST /v1/oauth/token": {props: ".access_token .token_type", leaf: "pro api-authentication oauth-token", refused: true}, +} + +const mobileDeviceSecretProps = ".ios.security.bootstrapToken .ios.security.bootstrapTokenEscrowed .ios.unlockToken .tvos.airplayPassword .visionos.security.bootstrapToken .visionos.security.bootstrapTokenEscrowed .visionos.unlockToken .watchos.security.bootstrapToken .watchos.security.bootstrapTokenEscrowed .watchos.unlockToken" + +// loadSpecResources is every Pro, Platform and Security Cloud resource the +// generators derive from the committed specs. +var loadSpecResources = sync.OnceValues(func() ([]*parser.Resource, error) { + specs := filepath.Join("..", "..", "specs") + proSpecs, err := filepath.Glob(filepath.Join(specs, "*.yaml")) + if err != nil { + return nil, err + } + pro, _, err := parser.LoadDocuments(proSpecs) + if err != nil { + return nil, fmt.Errorf("loading the Pro specs: %w", err) + } + plat, _, err := platformgen.LoadResources(filepath.Join(specs, "platform")) + if err != nil { + return nil, fmt.Errorf("loading the Platform specs: %w", err) + } + sec, _, _, err := securitygen.LoadResources(filepath.Join(specs, "security")) + if err != nil { + return nil, fmt.Errorf("loading the Security Cloud specs: %w", err) + } + return slices.Concat(pro, plat, sec), nil +}) + +// responseSecretProps maps "METHOD path" to the sorted, space-separated +// readable string properties of any 2xx response whose name matches +// namesASecret. A writeOnly property is never in a response and is skipped. +func responseSecretProps(resources []*parser.Resource) map[string]string { + found := map[string]map[string]bool{} + for _, r := range parser.FlattenResources(resources) { + for _, op := range r.Operations { + for code, resp := range op.Responses { + if !strings.HasPrefix(code, "2") || resp == nil { + continue + } + key := op.Method + " " + op.Path + walkSecretProps(resp.Schema, "", map[*parser.Schema]bool{}, func(p string) { + if found[key] == nil { + found[key] = map[string]bool{} + } + found[key][p] = true + }) + } + } + } + out := make(map[string]string, len(found)) + for key, props := range found { + names := make([]string, 0, len(props)) + for p := range props { + names = append(names, p) + } + sort.Strings(names) + out[key] = strings.Join(names, " ") + } + return out +} + +func walkSecretProps(s *parser.Schema, prefix string, seen map[*parser.Schema]bool, hit func(string)) { + if s == nil || seen[s] { + return + } + seen[s] = true + walkSecretProps(s.Items, prefix, seen, hit) + for name, p := range s.Properties { + if p == nil { + continue + } + walkSecretProps(p.Nested, prefix+"."+name, seen, hit) + walkSecretProps(p.Items, prefix+"."+name, seen, hit) + if p.Type == "string" && !p.WriteOnly && namesASecret.MatchString(name) { + hit(prefix + "." + name) + } + } +} + +// checkResponseSecrets returns one problem per operation in found that verdicts +// does not classify correctly, and per verdict found no longer needs. +func checkResponseSecrets(found map[string]string, verdicts map[string]responseSecretVerdict, root *cobra.Command) []string { + leaves := map[string]*cobra.Command{} + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, s := range c.Commands() { + walk(s) + } + if !c.HasSubCommands() { + leaves[strings.TrimPrefix(c.CommandPath(), root.Name()+" ")] = c + } + } + walk(root) + + var problems []string + for key, props := range found { + v, ok := verdicts[key] + switch { + case !ok: + problems = append(problems, fmt.Sprintf("%s returns %s, named like a secret and not writeOnly: refuse the command that prints it, redact it over MCP, or add a verdict with the reason it may reach the model", key, props)) + continue + case v.props != props: + problems = append(problems, fmt.Sprintf("%s returns %s, but its verdict judged %s; judge the difference", key, props, v.props)) + } + if v.leaf == "" { + if v.refused || v.reason == "" { + problems = append(problems, fmt.Sprintf("%s: a verdict without a leaf needs a reason and cannot be refused", key)) + } + continue + } + c, ok := leaves[v.leaf] + if !ok { + problems = append(problems, fmt.Sprintf("%s: verdict names %q, which is not a leaf", key, v.leaf)) + continue + } + refused := refuseOverMCP(childInvocation{path: c.CommandPath()}, "prod") != nil + switch { + case v.refused && !refused: + problems = append(problems, fmt.Sprintf("%s: %q prints %s and must be refused over MCP", key, v.leaf, props)) + case !v.refused && refused: + problems = append(problems, fmt.Sprintf("%s: %q is refused over MCP; mark the verdict refused", key, v.leaf)) + case !v.refused && v.reason == "": + problems = append(problems, fmt.Sprintf("%s: %q runs over MCP and its verdict gives no reason", key, v.leaf)) + } + } + for key := range verdicts { + if _, ok := found[key]; !ok { + problems = append(problems, fmt.Sprintf("responseSecretVerdicts names %q, which no 2xx response flags any more; remove it", key)) + } + } + sort.Strings(problems) + return problems +} + +// TestMCPResponseSecrets_AreClassified walks the 2xx response schemas of the +// committed Pro, Platform and Security Cloud specs for a readable string +// property named like a secret, and requires each such operation to be +// refused, redacted or exempted with a reason in responseSecretVerdicts. +func TestMCPResponseSecrets_AreClassified(t *testing.T) { + resources, err := loadSpecResources() + if err != nil { + t.Fatal(err) + } + found := responseSecretProps(resources) + if len(found) < 20 { + t.Fatalf("only %d operations flagged; the walk is not reaching the specs", len(found)) + } + for _, p := range checkResponseSecrets(found, responseSecretVerdicts, NewRootCmd("test", "t", "t", "t")) { + t.Error(p) + } +} + +// TestMCPResponseSecrets_FailsOnAnUnclassifiedResponse feeds the guard one +// fabricated operation answering a readable clientSecret beside a writeOnly +// password, and requires it to name the first and not the second. +func TestMCPResponseSecrets_FailsOnAnUnclassifiedResponse(t *testing.T) { + op := &parser.Operation{Method: "GET", Path: "/v1/synthetic", Responses: map[string]*parser.Response{ + "200": {StatusCode: "200", Schema: &parser.Schema{Type: "object", Properties: map[string]*parser.Property{ + "results": {Name: "results", Type: "array", Items: &parser.Schema{Type: "object", Properties: map[string]*parser.Property{ + "clientSecret": {Name: "clientSecret", Type: "string"}, + "password": {Name: "password", Type: "string", WriteOnly: true}, + }}}, + }}}, + }} + found := responseSecretProps([]*parser.Resource{{Name: "synthetic", Operations: []*parser.Operation{op}}}) + if found["GET /v1/synthetic"] != ".results.clientSecret" { + t.Fatalf("the walk found %v; want only .results.clientSecret", found) + } + problems := checkResponseSecrets(found, map[string]responseSecretVerdict{}, NewRootCmd("test", "t", "t", "t")) + if len(problems) != 1 || !strings.Contains(problems[0], "GET /v1/synthetic returns .results.clientSecret") { + t.Errorf("an unclassified response secret should be the one problem reported, got %q", problems) + } + + refusedNoMore := map[string]responseSecretVerdict{"GET /v1/synthetic": {props: ".results.clientSecret", leaf: "pro cloud-distribution-point list", refused: true}} + if problems := checkResponseSecrets(found, refusedNoMore, NewRootCmd("test", "t", "t", "t")); len(problems) != 1 || !strings.Contains(problems[0], "must be refused") { + t.Errorf("a verdict claiming an allowed leaf is refused should fail, got %q", problems) + } +} From c17fad5769702589857dea6f8d1a473aeb447c41 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 15:21:15 -0500 Subject: [PATCH 19/25] test(mcp): blueprint profile conversion and token-named payload keys leak pro blueprints components configuration-profile --id/--name downloads a Classic profile and prints the converted component with its Wi-Fi password verbatim over MCP, for both --type values. import-profile is held to the opposite: it copies the profile into a new blueprint, so the create request must carry the real value. Payload keys ending in token, authkey, apikey, accesskey, privatekey, secretkey or passcode print verbatim: a Chrome enrollment token, a Tailscale auth key and an API key among them. Co-Authored-By: Claude Opus 5.5 --- .../mcp_blueprint_profile_redaction_test.go | 122 ++++++++++++++++++ .../mcp_diff_payload_redaction_test.go | 1 + .../profileconvert/payload_secrets_test.go | 38 ++++++ 3 files changed, 161 insertions(+) create mode 100644 internal/commands/mcp_blueprint_profile_redaction_test.go diff --git a/internal/commands/mcp_blueprint_profile_redaction_test.go b/internal/commands/mcp_blueprint_profile_redaction_test.go new file mode 100644 index 00000000..521a20b4 --- /dev/null +++ b/internal/commands/mcp_blueprint_profile_redaction_test.go @@ -0,0 +1,122 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" +) + +// classicProfileClient answers a Classic profile GET with a profile holding a +// Wi-Fi payload, and a group lookup with one platform group. +type classicProfileClient struct{ root, password string } + +func (c classicProfileClient) Do(_ context.Context, method, path string, _ io.Reader) (*http.Response, error) { + var body string + switch { + case method == http.MethodGet && strings.HasPrefix(path, "/JSSResource/"): + var esc strings.Builder + _ = xml.EscapeText(&esc, []byte(wifiProfilePlist(c.password))) + body = `<` + c.root + `>7Corp Wi-Fi` + esc.String() + `` + + `1G` + case method == http.MethodGet && strings.HasPrefix(path, "/v2/groups"): + body = `{"totalCount":1,"results":[{"groupPlatformId":"00000000-0000-0000-0000-000000000001"}]}` + default: + return nil, fmt.Errorf("unexpected %s %s", method, path) + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func runComponentsConfigProfile(t *testing.T, root string, args ...string) string { + t.Helper() + cliCtx, _, _ := newTestPlatformContext(t) + cliCtx.Client = classicProfileClient{root: root, password: diffSecretPrefix + "psk"} + cmd := newBlueprintsComponentsConfigProfileCmd(cliCtx) + cmd.SetArgs(args) + cmd.SetErr(io.Discard) + var err error + out := captureStdout(t, func() { err = cmd.Execute() }) + if err != nil { + t.Fatalf("components configuration-profile %v: %v", args, err) + } + return out +} + +var componentProfileCases = []struct { + root string + args []string +}{ + {"os_x_configuration_profile", []string{"--id", "7"}}, + {"configuration_profile", []string{"--id", "7", "--type", "mobile"}}, +} + +func TestComponentsConfigProfile_RedactsDownloadedPayloadSecretsInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnvVar, "1") + for _, tc := range componentProfileCases { + got := runComponentsConfigProfile(t, tc.root, tc.args...) + if strings.Contains(got, diffSecretPrefix) { + t.Errorf("components configuration-profile %v prints the Wi-Fi password over MCP:\n%s", tc.args, got) + } + for _, want := range []string{"redacted", "CorpWiFi", "SSID_STR"} { + if !strings.Contains(got, want) { + t.Errorf("components configuration-profile %v over MCP should still print %q:\n%s", tc.args, want, got) + } + } + } +} + +func TestComponentsConfigProfile_OutsideMCPPrintsTheDownloadedPayload(t *testing.T) { + t.Setenv(mcpChildEnvVar, "") + for _, tc := range componentProfileCases { + if got := runComponentsConfigProfile(t, tc.root, tc.args...); !strings.Contains(got, diffSecretPrefix+"psk") || strings.Contains(got, "redacted") { + t.Errorf("components configuration-profile %v outside MCP must print the payload unchanged:\n%s", tc.args, got) + } + } +} + +// TestImportProfile_SendsTheRealPayloadInAnMCPChild holds the write side to the +// real value: import-profile copies the profile into a new blueprint, so a +// redacted copy would store the marker as the Wi-Fi password. +func TestImportProfile_SendsTheRealPayloadInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnvVar, "1") + cliCtx, mux, _ := newTestPlatformContext(t) + cliCtx.Client = classicProfileClient{root: "os_x_configuration_profile", password: diffSecretPrefix + "psk"} + var mu sync.Mutex + var created []string + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + b, _ := io.ReadAll(r.Body) + mu.Lock() + created = append(created, string(b)) + mu.Unlock() + writeJSONStatus(w, http.StatusCreated, map[string]any{"id": "bp-1", "href": "/bp-1"}) + return + } + writeJSON(w, map[string]any{"id": "bp-1", "name": "Corp Wi-Fi"}) + }) + cmd := newBlueprintsImportProfileCmd(cliCtx) + cmd.SetArgs([]string{"7"}) + cmd.SetErr(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("import-profile: %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(created) != 1 { + t.Fatalf("import-profile sent %d create requests, want 1", len(created)) + } + var decoded any + if err := json.Unmarshal([]byte(created[0]), &decoded); err != nil { + t.Fatalf("create body is not JSON: %v\n%s", err, created[0]) + } + if !strings.Contains(created[0], diffSecretPrefix+"psk") || strings.Contains(created[0], "redacted") { + t.Errorf("import-profile must send the real Wi-Fi password to the new blueprint:\n%s", created[0]) + } +} diff --git a/internal/commands/mcp_diff_payload_redaction_test.go b/internal/commands/mcp_diff_payload_redaction_test.go index 7e43ce9c..c4e832a5 100644 --- a/internal/commands/mcp_diff_payload_redaction_test.go +++ b/internal/commands/mcp_diff_payload_redaction_test.go @@ -22,6 +22,7 @@ func wifiProfilePlist(password string) string { PayloadIdentifiercom.example.profile + PayloadDisplayNameCorp Wi-Fi ` diff --git a/internal/profileconvert/payload_secrets_test.go b/internal/profileconvert/payload_secrets_test.go index 9533694a..e7ca1ab5 100644 --- a/internal/profileconvert/payload_secrets_test.go +++ b/internal/profileconvert/payload_secrets_test.go @@ -84,3 +84,41 @@ func TestRedactClassicProfilePayloads_HandlesCDATAAndFailsClosed(t *testing.T) { t.Errorf("an empty payloads element should be left alone, got %s", out) } } + +func TestRedactPayloadSecrets_MatchesTokenAndKeySuffixes(t *testing.T) { + in := ` +PayloadContent +PayloadTypecom.apple.ManagedClient.preferences +PayloadIdentifiercom.example.custom +SSID_STRCorp +CloudManagementEnrollmentTokenS3CRET-cbcm +TailscaleAuthKeyS3CRET-tskey +APIKeyS3CRET-api +PrivateKeyUzNDUkVULXBr +AWSAccessKeyS3CRET-aws +SecretKeyS3CRET-sk +DevicePasscodeS3CRET-passcode +TokenURLhttps://idp.example.com/token-url +TokenEndpointhttps://idp.example.com/token-endpoint +PINvisible-pin +KeyIDvisible-key-id +` + out, err := RedactPayloadSecrets([]byte(in)) + if err != nil { + t.Fatal(err) + } + p := decodePlist(t, out)["PayloadContent"].([]any)[0].(map[string]any) + for _, k := range []string{"CloudManagementEnrollmentToken", "TailscaleAuthKey", "APIKey", "PrivateKey", "AWSAccessKey", "SecretKey", "DevicePasscode"} { + if p[k] != RedactedPayloadValue { + t.Errorf("%s should be redacted, got %v", k, p[k]) + } + } + for k, want := range map[string]string{ + "TokenURL": "https://idp.example.com/token-url", "TokenEndpoint": "https://idp.example.com/token-endpoint", + "PIN": "visible-pin", "KeyID": "visible-key-id", "SSID_STR": "Corp", "PayloadIdentifier": "com.example.custom", + } { + if p[k] != want { + t.Errorf("%s does not end in a secret suffix and must stay %q, got %v", k, want, p[k]) + } + } +} From 5070faa15becc45d6b1241e993255494523927d5 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 15:21:55 -0500 Subject: [PATCH 20/25] fix(mcp): redact the profile blueprints components configuration-profile downloads With --id or --name the command downloads a Classic profile and prints the converted component, so in an MCP child the downloaded mobileconfig now goes through profileconvert.RedactPayloadSecrets before conversion, and a payload that does not decode is refused rather than printed. The redaction sits at this print site and not in fetchClassicProfile, because import-profile copies the same payloads into a new blueprint. Co-Authored-By: Claude Opus 5.5 --- .../mcp_blueprint_profile_redaction_test.go | 23 +++++++++++++++++-- internal/commands/pro_blueprints.go | 7 ++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/commands/mcp_blueprint_profile_redaction_test.go b/internal/commands/mcp_blueprint_profile_redaction_test.go index 521a20b4..1e469c7f 100644 --- a/internal/commands/mcp_blueprint_profile_redaction_test.go +++ b/internal/commands/mcp_blueprint_profile_redaction_test.go @@ -16,14 +16,18 @@ import ( // classicProfileClient answers a Classic profile GET with a profile holding a // Wi-Fi payload, and a group lookup with one platform group. -type classicProfileClient struct{ root, password string } +type classicProfileClient struct{ root, password, payloads string } func (c classicProfileClient) Do(_ context.Context, method, path string, _ io.Reader) (*http.Response, error) { var body string switch { case method == http.MethodGet && strings.HasPrefix(path, "/JSSResource/"): var esc strings.Builder - _ = xml.EscapeText(&esc, []byte(wifiProfilePlist(c.password))) + payloads := c.payloads + if payloads == "" { + payloads = wifiProfilePlist(c.password) + } + _ = xml.EscapeText(&esc, []byte(payloads)) body = `<` + c.root + `>7Corp Wi-Fi` + esc.String() + `` + `1G` case method == http.MethodGet && strings.HasPrefix(path, "/v2/groups"): @@ -72,6 +76,21 @@ func TestComponentsConfigProfile_RedactsDownloadedPayloadSecretsInAnMCPChild(t * } } +func TestComponentsConfigProfile_UndecodablePayloadFailsClosedInAnMCPChild(t *testing.T) { + t.Setenv(mcpChildEnvVar, "1") + cliCtx, _, _ := newTestPlatformContext(t) + cliCtx.Client = classicProfileClient{root: "os_x_configuration_profile", payloads: "hello " + diffSecretPrefix + "psk"} + cmd := newBlueprintsComponentsConfigProfileCmd(cliCtx) + cmd.SetArgs([]string{"--id", "7"}) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + var err error + out := captureStdout(t, func() { err = cmd.Execute() }) + if err == nil || !strings.Contains(err.Error(), "not printed over MCP") || strings.Contains(out, diffSecretPrefix) { + t.Errorf("an undecodable payload should be refused over MCP, got err %v and output:\n%s", err, out) + } +} + func TestComponentsConfigProfile_OutsideMCPPrintsTheDownloadedPayload(t *testing.T) { t.Setenv(mcpChildEnvVar, "") for _, tc := range componentProfileCases { diff --git a/internal/commands/pro_blueprints.go b/internal/commands/pro_blueprints.go index 1dbe4448..380f56c0 100644 --- a/internal/commands/pro_blueprints.go +++ b/internal/commands/pro_blueprints.go @@ -875,6 +875,13 @@ Supported payloads: https://github.com/apple/device-management/tree/release/mdm/ if err != nil { return err } + // Here and not in fetchClassicProfile: import-profile writes the + // downloaded payloads into a new blueprint and needs the real values. + if registry.InMCPChild() { + if data, err = profileconvert.RedactPayloadSecrets(data); err != nil { + return fmt.Errorf("redacting the profile's payload secrets, so it is not printed over MCP: %w", err) + } + } } else { data, err = readInput(fromFile) if err != nil { From b70aed3673db9af4ab57782461d753c192d93215 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 15:24:00 -0500 Subject: [PATCH 21/25] fix(mcp): redact token- and key-named payload values, and say what is shown isSecretPayloadKey matched only keys ending in password or secret, so a Chrome enrollment token, a Tailscale auth key and API keys printed verbatim from a custom-settings payload while the help said each secret was redacted. It now also matches, case-insensitively and on the whole suffix, token, authkey, apikey, accesskey, privatekey, secretkey, passcode and credential, the last aligning with the words the MCP guards count as a secret name. A bare pin is not matched. mcp serve --help, the run_command description, agent_context.md and the CHANGELOG now name those suffixes, name the blueprint converter, and say every other payload value is shown. A test holds all four to profileconvert.SecretPayloadKeySuffixes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++--- internal/commands/agent_context.md | 13 +++--- internal/commands/mcp.go | 38 ++++++++++++------ .../commands/mcp_payload_policy_text_test.go | 40 +++++++++++++++++++ internal/profileconvert/payload_secrets.go | 29 ++++++++++---- .../profileconvert/payload_secrets_test.go | 3 +- 6 files changed, 106 insertions(+), 32 deletions(-) create mode 100644 internal/commands/mcp_payload_policy_text_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f65acb57..43b3a98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,12 +67,15 @@ field that Classic `--set` refuses as a credential as ``, in every output format, so `-o raw` is not the wire bytes over MCP, and `pro diff` shows those fields' old and new values as `` while still reporting the change. `get` and `list` on `classic-macos-config-profiles` and -`classic-mobile-config-profiles`, and `pro diff` on `profiles`, also print -each secret inside a profile's payloads as ``: a Wi-Fi, EAP, VPN or -account password, a VPN shared secret, a SCEP challenge, and an identity -certificate with its password. The payload is re-encoded, so the record is -still a profile document, and a payload that does not decode is redacted -whole. Outside MCP their output is unchanged. Secrets of the pinned tenant's devices (the LAPS +`classic-mobile-config-profiles`, `pro diff` on `profiles` and `pro blueprints +components configuration-profile --id/--name` also redact profile payloads by +key name: the value of a key named `Challenge`, or ending in any case in +`password`, `secret`, `token`, `authkey`, `apikey`, `accesskey`, `privatekey`, +`secretkey`, `passcode` or `credential`, prints as ``, and so does a +PKCS#12 certificate. Every other payload value is shown, a custom payload's +included. The payload is re-encoded, so the record is still a profile +document. A payload that does not decode is redacted whole, and the blueprint +converter refuses it. Outside MCP their output is unchanged. Secrets of the pinned tenant's devices (the LAPS password, the recovery lock password, the FileVault personal recovery key), the JCDS upload credentials of `pro jamf-cloud-distribution-service renew-credentials` and `pro jamf-cloud-distribution-service-files create`, diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 574ee8cd..34abe91e 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -124,11 +124,14 @@ but the pinned profile. `config show` runs with every credential field shown as query of `protect action-configs get` and `apply`, the Sentinel shared key of `protect data-forwarding get` and `update`, the password of `protect api-clients get`, the CloudFront private key and CDN password of `pro -cloud-distribution-point list`, every Classic field that Classic `--set` refuses as a credential, and -each secret inside a Classic configuration profile's payloads (a Wi-Fi, EAP, -VPN or account password, a VPN shared secret, a SCEP challenge, an identity -certificate and its password), in `get`, `list` and the old and new values -`pro diff` reports. Secrets of the pinned tenant's devices (the LAPS password, the +cloud-distribution-point list`, and every Classic field that Classic `--set` refuses as a credential, +in `get`, `list` and the old and new values `pro diff` reports. Configuration +profile payloads, in a Classic profile `get` or `list`, `pro diff` and `pro +blueprints components configuration-profile --id/--name`, are redacted by key +name: the value of a key named `Challenge`, or ending in any case in +`password`, `secret`, `token`, `authkey`, `apikey`, `accesskey`, `privatekey`, +`secretkey`, `passcode` or `credential`, and a PKCS#12 certificate. Every other +payload value is shown, a custom payload's included. Secrets of the pinned tenant's devices (the LAPS password, the recovery lock password, the FileVault personal recovery key), the JCDS upload credentials and blueprint configuration, a secret a component carries included, are shown. A relative read path resolves against the server's start diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 802f999a..c1305d1a 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -134,12 +134,15 @@ api-clients get', the CloudFront private key and CDN password of 'pro cloud-distribution-point list', and every Classic 'get' and 'list' field the Classic --set refuses as a credential (an SMTP, LDAP, webhook, directory binding or distribution point password, the VPP sToken, the JWT signing key, -the institutional FileVault keystore). Each secret inside a configuration -profile's payloads in 'classic-macos-config-profiles' and -'classic-mobile-config-profiles' is redacted too: a Wi-Fi, EAP, VPN or account -password, a VPN shared secret, a SCEP challenge, and an identity certificate -with its password. A payload that does not decode is redacted whole. So a -Classic '-o raw' is not the wire bytes here, and 'pro diff' shows those +the institutional FileVault keystore). Configuration profile payloads are +redacted by key name in 'classic-macos-config-profiles', in +'classic-mobile-config-profiles' and in 'pro blueprints components +configuration-profile --id/--name': the value of a key named Challenge, or +ending in any case in password, secret, token, authkey, apikey, accesskey, +privatekey, secretkey, passcode or credential, and a PKCS#12 identity +certificate. Every other payload value is shown, a custom payload's included. +A payload that does not decode is redacted whole, or refused by the blueprint +converter. So a Classic '-o raw' is not the wire bytes here, and 'pro diff' shows those fields' old and new values as while still reporting the change. Secrets of the pinned tenant's own devices are shown: the LAPS password, the recovery lock password, the FileVault personal recovery key, and the bootstrap @@ -253,11 +256,9 @@ value is an error; there is no config key for it.`, "Report-client header values and URL userinfo and query, the Sentinel " + "shared key, Protect API client passwords, the CloudFront private key of " + "'pro cloud-distribution-point list' and Classic credential fields " + - "(passwords, the VPP sToken, the JWT signing key), and the secrets inside " + - "Classic configuration profile payloads (Wi-Fi, VPN and identity passwords, " + - "shared secrets, SCEP challenges, PKCS#12 certificates), in 'get', 'list' and " + - "'pro diff', print as ; " + - "device secrets such as the LAPS password, and blueprint configuration, are shown. " + + "(passwords, the VPP sToken, the JWT signing key), in 'get', 'list' and " + + "'pro diff', print as . " + payloadRedactionToolNote + + " Device secrets such as the LAPS password, and blueprint configuration, are shown. " + "Output is truncated past 256 KB. Destructive commands (delete, etc.) " + "require an explicit --yes in args or they will refuse to run.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in runCommandInput) (*mcp.CallToolResult, any, error) { @@ -573,6 +574,15 @@ var blockedChildFlagPrefixes = []string{ "--out-file", } +// payloadRedactionToolNote is the run_command description's account of +// profileconvert.SecretPayloadKeySuffixes; TestMCPPolicyTexts_NameEveryPayloadSecretSuffix +// holds it and the other policy texts to that list. +const payloadRedactionToolNote = "In a configuration profile's payloads, from a Classic profile 'get' or " + + "'list', 'pro diff' or 'pro blueprints components configuration-profile', the value of a " + + "key named Challenge or ending in any case in password, secret, token, authkey, apikey, " + + "accesskey, privatekey, secretkey, passcode or credential, and a PKCS#12 certificate, print " + + "as ; every other payload value is shown." + // mcpRefusedCommands are resolved command paths, each refused with everything // beneath it, and why. `dashboard` is not here: generate_report shares // buildChildArgs and must still spawn it, so the run_command handler refuses it @@ -593,8 +603,10 @@ var blockedChildFlagPrefixes = []string{ // A command that prints such a credential inside a larger record runs with it // redacted instead, in every output format: the Classic credential fields // (redactClassicReadInMCPChild), the secrets in a Classic configuration -// profile's payloads plist (redactClassicProfilePayloadsInMCPChild, through -// profileconvert.RedactPayloadSecrets), the cloud distribution point's keys +// profile's payloads plist (redactClassicProfilePayloadsInMCPChild, and the +// download of `pro blueprints components configuration-profile`, through +// profileconvert.RedactPayloadSecrets, which matches by key name), the cloud +// distribution point's keys // (cdnKeyRedactingClient), and the same fields in `pro diff`. Blueprint // configuration is not redacted: the Platform SDK decodes each response // inside its transport, so there is no per-response hook, and a component's diff --git a/internal/commands/mcp_payload_policy_text_test.go b/internal/commands/mcp_payload_policy_text_test.go new file mode 100644 index 00000000..7d470d87 --- /dev/null +++ b/internal/commands/mcp_payload_policy_text_test.go @@ -0,0 +1,40 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamf-cli/internal/profileconvert" +) + +// TestMCPPolicyTexts_NameEveryPayloadSecretSuffix requires each text that +// tells an operator which payload values are redacted to name exactly the +// suffixes profileconvert matches, in its order, and to say the rest are shown. +func TestMCPPolicyTexts_NameEveryPayloadSecretSuffix(t *testing.T) { + s := profileconvert.SecretPayloadKeySuffixes + want := strings.Join(s[:len(s)-1], ", ") + " or " + s[len(s)-1] + texts := map[string]string{ + "mcp serve --help": newMCPServeCmd().Long, + "the run_command description": payloadRedactionToolNote, + } + for _, f := range []string{"agent_context.md", filepath.Join("..", "..", "CHANGELOG.md")} { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + texts[filepath.Base(f)] = string(b) + } + for name, text := range texts { + flat := strings.Join(strings.Fields(strings.ReplaceAll(text, "`", "")), " ") + if !strings.Contains(flat, want) { + t.Errorf("%s should name the redacted payload key suffixes as %q", name, want) + } + if !strings.Contains(flat, "Challenge") || !strings.Contains(strings.ToLower(flat), "every other payload value is shown") { + t.Errorf("%s should name Challenge and say every other payload value is shown", name) + } + } +} diff --git a/internal/profileconvert/payload_secrets.go b/internal/profileconvert/payload_secrets.go index e606efba..5b6a5ea6 100644 --- a/internal/profileconvert/payload_secrets.go +++ b/internal/profileconvert/payload_secrets.go @@ -15,18 +15,33 @@ import ( // RedactedPayloadValue is what a secret inside a profile payload is replaced by. const RedactedPayloadValue = "" -// isSecretPayloadKey reports whether a payload key holds a secret that works -// outside the profile: a Wi-Fi, EAP, VPN, account or identity password, a -// shared or client secret, or a SCEP challenge. Compared case-insensitively, -// because custom payloads do not follow Apple's casing. +// SecretPayloadKeySuffixes are the endings, compared case-insensitively, of a +// payload key whose value is a secret that works outside the profile. They +// cover the words namesASecret in the MCP guards counts as a secret name. A +// bare "pin" is not one: it ends far more keys than it protects. +var SecretPayloadKeySuffixes = []string{"password", "secret", "token", "authkey", "apikey", "accesskey", "privatekey", "secretkey", "passcode", "credential"} + +// isSecretPayloadKey reports whether a payload key holds a secret: it is +// Challenge (a SCEP challenge) or ends in one of SecretPayloadKeySuffixes. +// Compared case-insensitively, because custom payloads do not follow Apple's +// casing. func isSecretPayloadKey(key string) bool { k := strings.ToLower(key) - return k == "challenge" || strings.HasSuffix(k, "password") || strings.HasSuffix(k, "secret") + if k == "challenge" { + return true + } + for _, suffix := range SecretPayloadKeySuffixes { + if strings.HasSuffix(k, suffix) { + return true + } + } + return false } // RedactPayloadSecrets returns profile, a configuration profile plist, with the -// value of every secret key at any depth replaced by RedactedPayloadValue, and -// the PayloadContent of a com.apple.security.pkcs12 payload too. A profile with +// value of every secret key (isSecretPayloadKey) at any depth replaced by +// RedactedPayloadValue, and the PayloadContent of a com.apple.security.pkcs12 +// payload too. A profile with // no secret is returned unchanged; one with a secret is re-serialised as XML. // It fails when profile is not a plist whose top level is a dictionary. func RedactPayloadSecrets(profile []byte) ([]byte, error) { diff --git a/internal/profileconvert/payload_secrets_test.go b/internal/profileconvert/payload_secrets_test.go index e7ca1ab5..33ec6f41 100644 --- a/internal/profileconvert/payload_secrets_test.go +++ b/internal/profileconvert/payload_secrets_test.go @@ -98,6 +98,7 @@ func TestRedactPayloadSecrets_MatchesTokenAndKeySuffixes(t *testing.T) { AWSAccessKeyS3CRET-aws SecretKeyS3CRET-sk DevicePasscodeS3CRET-passcode +VPNCredentialS3CRET-cred TokenURLhttps://idp.example.com/token-url TokenEndpointhttps://idp.example.com/token-endpoint PINvisible-pin @@ -108,7 +109,7 @@ func TestRedactPayloadSecrets_MatchesTokenAndKeySuffixes(t *testing.T) { t.Fatal(err) } p := decodePlist(t, out)["PayloadContent"].([]any)[0].(map[string]any) - for _, k := range []string{"CloudManagementEnrollmentToken", "TailscaleAuthKey", "APIKey", "PrivateKey", "AWSAccessKey", "SecretKey", "DevicePasscode"} { + for _, k := range []string{"CloudManagementEnrollmentToken", "TailscaleAuthKey", "APIKey", "PrivateKey", "AWSAccessKey", "SecretKey", "DevicePasscode", "VPNCredential"} { if p[k] != RedactedPayloadValue { t.Errorf("%s should be redacted, got %v", k, p[k]) } From ad18fdb8a278de91c39e30ebae63cf2287e83fb1 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 15:38:02 -0500 Subject: [PATCH 22/25] test(mcp): -vvv body logs and cookie headers reach the model A --verbose count of 3 or more logs each response body to stderr inside the client, before any MCP redaction, and run_command returns stderr to the model. Neither buildChildArgs nor the child's own check refuses it, in any spelling: -vvv, -v -v -v, --verbose=3, -vv -v, or after the command path. -vv headers print Set-Cookie and Cookie values in full. Co-Authored-By: Claude Opus 5.5 --- internal/client/log_headers_cookie_test.go | 30 ++++++++++ internal/commands/mcp_verbose_bodies_test.go | 60 ++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 internal/client/log_headers_cookie_test.go create mode 100644 internal/commands/mcp_verbose_bodies_test.go diff --git a/internal/client/log_headers_cookie_test.go b/internal/client/log_headers_cookie_test.go new file mode 100644 index 00000000..2af4947a --- /dev/null +++ b/internal/client/log_headers_cookie_test.go @@ -0,0 +1,30 @@ +// Copyright 2026, Jamf Software LLC + +package client + +import ( + "bytes" + "net/http" + "strings" + "testing" +) + +func TestLogHeaders_RedactsCookiesInEveryMode(t *testing.T) { + h := http.Header{} + h.Add("Set-Cookie", "APBALANCEID=aws.S3CRET-affinity; Path=/; Secure") + h.Add("Cookie", "JSESSIONID=S3CRET-session") + h.Set("Content-Type", "application/xml") + for _, redactAuth := range []bool{true, false} { + var buf bytes.Buffer + logHeaders(&buf, h, redactAuth) + got := buf.String() + if strings.Contains(got, "S3CRET-") { + t.Errorf("logHeaders(redactAuth=%v) prints a cookie value:\n%s", redactAuth, got) + } + for _, want := range []string{"Set-Cookie: [redacted]", "Cookie: [redacted]", "Content-Type: application/xml"} { + if !strings.Contains(got, want) { + t.Errorf("logHeaders(redactAuth=%v) should print %q:\n%s", redactAuth, want, got) + } + } + } +} diff --git a/internal/commands/mcp_verbose_bodies_test.go b/internal/commands/mcp_verbose_bodies_test.go new file mode 100644 index 00000000..2e85ada1 --- /dev/null +++ b/internal/commands/mcp_verbose_bodies_test.go @@ -0,0 +1,60 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "strings" + "testing" +) + +// verboseBodyForms are the spellings of a --verbose count of 3 or more, which +// logs response bodies to stderr before any MCP redaction sees them. +var verboseBodyForms = [][]string{ + {"-vvv", "pro", "classic-macos-config-profiles", "get", "1"}, + {"-v", "-v", "-v", "pro", "classic-macos-config-profiles", "get", "1"}, + {"--verbose=3", "pro", "classic-macos-config-profiles", "get", "1"}, + {"--verbose=4", "pro", "classic-macos-config-profiles", "get", "1"}, + {"-vv", "-v", "pro", "classic-macos-config-profiles", "get", "1"}, + {"pro", "classic-macos-config-profiles", "get", "1", "-vvv"}, + {"pro", "blueprints", "components", "configuration-profile", "--id", "1", "-v", "-vv"}, +} + +var verboseHeaderForms = [][]string{ + {"-v", "pro", "computers", "list"}, + {"-vv", "pro", "computers", "list"}, + {"pro", "computers", "list", "--verbose=2"}, + {"-vvv", "--verbose=2", "pro", "computers", "list"}, +} + +func TestMCP_RefusesVerboseBodyLogging(t *testing.T) { + for _, args := range verboseBodyForms { + _, err := buildChildArgs("prod", args) + if !isMCPRefusal(err) { + t.Errorf("run_command accepts %q (err %v); -vvv logs response bodies to stderr, which the model reads", args, err) + continue + } + if !strings.Contains(err.Error(), "use -vv or less") { + t.Errorf("refusal of %q should say to use -vv or less: %v", args, err) + } + } + for _, args := range verboseHeaderForms { + if _, err := buildChildArgs("prod", args); isMCPRefusal(err) { + t.Errorf("run_command refuses %q: %v; -vv and less log no body", args, err) + } + } +} + +func TestMCPChild_RefusesVerboseBodyLogging(t *testing.T) { + for _, args := range verboseBodyForms { + argv := append([]string{"--profile", "prod", "--no-input"}, args...) + if err := executeAsMCPChild(t, "prod", argv...); !isMCPRefusal(err) { + t.Errorf("MCP child ran %q (err %v); it must refuse body logging on its own parse", argv, err) + } + } + for _, args := range verboseHeaderForms { + argv := append([]string{"--profile", "prod", "--no-input"}, args...) + if err := executeAsMCPChild(t, "prod", argv...); isMCPRefusal(err) { + t.Errorf("MCP child refuses %q: %v; -vv and less log no body", argv, err) + } + } +} From 7ddd0a04c102ff241af2c0a7547ccdb9cabde42f Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 15:40:13 -0500 Subject: [PATCH 23/25] fix(client): redact Cookie and Set-Cookie values in the header log The -vv header log printed session-affinity cookies in full on every response. A Cookie or Set-Cookie value now prints as [redacted] in every mode, the way a request's Authorization header already did. Co-Authored-By: Claude Opus 5.5 --- internal/client/client.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 5a5c98fd..4ec2e772 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -541,8 +541,10 @@ func RedactBodyForLog(data []byte) []byte { return redactBodyForLog(data) } -// logHeaders prints HTTP headers to w in sorted order. When redactAuth is true, -// the Authorization header value is replaced with "[redacted]". +// logHeaders prints HTTP headers to w in sorted order. A Cookie or Set-Cookie +// value is always replaced with "[redacted]", since a session cookie +// authenticates the same as the token, and so is Authorization when redactAuth +// is true. func logHeaders(w io.Writer, h http.Header, redactAuth bool) { keys := make([]string, 0, len(h)) for k := range h { @@ -551,7 +553,7 @@ func logHeaders(w io.Writer, h http.Header, redactAuth bool) { sort.Strings(keys) for _, k := range keys { v := strings.Join(h[k], ", ") - if redactAuth && strings.EqualFold(k, "Authorization") { + if redactAuth && strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "Cookie") || strings.EqualFold(k, "Set-Cookie") { v = "[redacted]" } _, _ = fmt.Fprintf(w, " %s: %s\n", k, v) From ca06b80db0888b16e521b1ea6620280ae1e287d0 Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 15:40:13 -0500 Subject: [PATCH 24/25] fix(mcp): refuse -vvv body logging over MCP At a --verbose level of 3 or more the client logs each response body to stderr before any MCP redaction sees it, and run_command returns stderr to the model, so a Classic profile's payload secrets printed there while stdout showed . refuseOverMCP, which serves both the parent check and the child's own re-check, now resolves the level the way pflag's count flag does and refuses 3 or more, in every spelling and position. The child also drops JAMF_CLI_ARGS, and anything prepended to its argv is parsed and judged the same way. -vv and less stay available. mcp serve --help, the run_command description, agent_context.md and the CHANGELOG say body logging is not available over MCP. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ internal/commands/agent_context.md | 3 ++- internal/commands/mcp.go | 32 +++++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b3a98f..93fd42ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ child process checks again before it runs. These now fail over MCP: flag is not affected. - `pro diff` with a side that is neither the server's profile nor a directory inside `--input-dir`. +- Body logging: `-vvv`, or any `--verbose` level of 3 or more however it is + spelled. It logs each response body to stderr before any redaction, and + `run_command` returns stderr. Use `-vv` or less. In every mode, not only over MCP, + the `-vv` header log now shows `Cookie` and `Set-Cookie` values as + `[redacted]`, as it already did for `Authorization`. - `multi`, `mcp`, `completion`, the `config` write subcommands, `config validate`, `doctor`, every `setup`, both `backup` commands and `jcds sync`. diff --git a/internal/commands/agent_context.md b/internal/commands/agent_context.md index 34abe91e..ea22acfc 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -107,7 +107,8 @@ The server is pinned to the profile it was launched with, and `run_command` is judged on the command and flags your arguments resolve to, aliases included. Rejected: credential- and target-selecting flags; flags whose value is a local file or directory (`--from-file`, `--file`, `--script-file`, `--save-to`, -`--dir` and the like; `-o/--output` as a format is fine); `multi`, `mcp`, +`--dir` and the like; `-o/--output` as a format is fine); body logging (`-vvv` +or a `--verbose` level of 3 or more; `-vv` and less are fine); `multi`, `mcp`, `completion`, the config write subcommands, `config validate`, `doctor`, the commands that print an access token (`auth token` under `platform`, `pro` and `protect`; `pro api-authentication token`, `oauth-token` and `keep-alive`; `pro diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index c1305d1a..2ec08548 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "slices" + "strconv" "strings" "sync" "time" @@ -123,6 +124,9 @@ spelling. It refuses: --input, --password-file, --dir, --save-to, --report-dir, and a command's own --output (the global -o/--output format flag stays available), except as --input-dir allows below + - a --verbose level of 3 or more (-vvv), which logs response bodies before + any redaction; -vv and less stay available, with Authorization, Cookie and + Set-Cookie values shown as [redacted] - 'pro diff' with a --source or --target that is neither this server's profile nor a directory inside --input-dir Some allowed commands print a third-party credential, shown as @@ -236,7 +240,8 @@ value is an error; there is no config key for it.`, "included. Rejected: any flag naming a profile, URL, token, tenant, " + "environment or output file; any flag whose value is a local file or " + "directory (--from-file, --file, --script-file, --save-to, --dir and the " + - "like; the -o/--output format flag is fine); 'multi', 'mcp', 'completion', " + + "like; the -o/--output format flag is fine); body logging (-vvv or a " + + "--verbose level of 3 or more; use -vv or less); 'multi', 'mcp', 'completion', " + "the config write subcommands, 'config validate', 'doctor', " + "every command that prints an access token ('auth token', " + "'pro api-authentication token', 'oauth-token', 'keep-alive'), " + @@ -821,6 +826,9 @@ func refuseOverMCP(inv childInvocation, pinnedProfile string) error { return fmt.Errorf("command %q is not available over MCP: %s", strings.TrimPrefix(inv.path, "jamf-cli "), refused.why) } } + if n := verboseCount(inv.settings); n >= verboseLogsBodies { + return fmt.Errorf("--verbose at level %d is not available over MCP: response bodies are logged at -vvv, which is not available over MCP; use -vv or less", n) + } for _, s := range inv.settings { if isBlockedChildFlag("--" + s.name) { return fmt.Errorf("flag %q is not allowed: the MCP server is pinned to the configuration it was started with; the target instance, credentials, and output destination cannot be overridden per command", "--"+s.name) @@ -839,6 +847,28 @@ func refuseOverMCP(inv childInvocation, pinnedProfile string) error { return nil } +// verboseLogsBodies is the --verbose level at which the client logs each +// request and response body to stderr, inside the client and so ahead of every +// MCP redaction, and run_command returns stderr to the model. +const verboseLogsBodies = 3 + +// verboseCount resolves --verbose the way pflag's count flag does: a bare -v +// arrives as "+1" and adds one, and --verbose=N sets N. A child-side setting is already the final count. +func verboseCount(settings []flagSetting) int { + n := 0 + for _, s := range settings { + if s.name != "verbose" { + continue + } + if s.value == "+1" { + n++ + } else if v, err := strconv.Atoi(s.value); err == nil { + n = v + } + } + return n +} + // refuseInMCPChild applies refuseOverMCP to the command this process parsed, // when it was spawned by `mcp serve`. The server's own --profile is not a // setting the model made, so it is skipped when it names the pinned profile. From 87b521c69de10e30f812ea45ed7bc6cfea4962ba Mon Sep 17 00:00:00 2001 From: Keaton Svoma Date: Thu, 1 Oct 2026 15:43:34 -0500 Subject: [PATCH 25/25] test(mcp): -vv, not -vvv, is the verbose level that stays available over MCP Co-Authored-By: Claude Opus 5.5 --- internal/commands/mcp_run_command_guard_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/mcp_run_command_guard_test.go b/internal/commands/mcp_run_command_guard_test.go index 0b2472e8..3a0528b6 100644 --- a/internal/commands/mcp_run_command_guard_test.go +++ b/internal/commands/mcp_run_command_guard_test.go @@ -204,7 +204,7 @@ func TestBuildChildArgs_KeepsDryRunVerboseHelpAndFormat(t *testing.T) { allowed := [][]string{ {"pro", "classic-policies", "create", "--set", "general.name=x", "--dry-run"}, {"pro", "classic-policies", "create", "--set", "general.name=x", "-n"}, - {"pro", "computers", "list", "-vvv"}, + {"pro", "computers", "list", "-vv"}, {"pro", "computers", "list", "-v"}, {"pro", "computers", "list", "--help"}, {"pro", "computers", "list", "-oplain"},