diff --git a/.claude/skills/where-to-make-changes/SKILL.md b/.claude/skills/where-to-make-changes/SKILL.md index 94e95825..61b6399d 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/CHANGELOG.md b/CHANGELOG.md index 16dbb217..93fd42ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,96 @@ 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`. +- 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`. +- 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 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. + +`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, 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). `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 +the change. `get` and `list` on `classic-macos-config-profiles` and +`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`, +and blueprint configuration, a secret a component carries included, 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. + +### 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. + ### Breaking — `--set` refuses credential fields in Pro, Platform and Security Cloud A `--set` value is on the command line, so it lands in shell history, in `ps` diff --git a/generator/classic/generator.go b/generator/classic/generator.go index a58f9281..44b89f00 100644 --- a/generator/classic/generator.go +++ b/generator/classic/generator.go @@ -617,6 +617,14 @@ 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 .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 @@ -638,7 +646,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) } @@ -725,8 +733,16 @@ func new{{ .GoName }}GetCmd(ctx *registry.CLIContext) *cobra.Command { if err != nil { return err } + 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 = 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) } @@ -1596,12 +1612,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" @@ -1609,9 +1626,6 @@ import ( "strings" "strconv" "fmt" -{{- if or (anyIsConfigProfile .) (anyClassicFileFields .) (anyListSubset .) (anyClassicExtraLookups .) (anyHasGroupPath .) }} - "bytes" -{{- end }} {{- if or (anyIsConfigProfile .) (anyClassicFileFields .) (anyClassicExtraLookups .) }} "net/url" {{- end }} @@ -1625,9 +1639,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 }} @@ -1643,6 +1655,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 @@ -1668,6 +1707,102 @@ 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) { + 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") + } + 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 +} + +// 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/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) 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/agent_context.md b/internal/commands/agent_context.md index eedaddbd..ea22acfc 100644 --- a/internal/commands/agent_context.md +++ b/internal/commands/agent_context.md @@ -103,10 +103,43 @@ 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); 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 +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 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`, 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. 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 +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. **`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/config.go b/internal/commands/config.go index 8e6b9e2d..39b6de00 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 66f8abf6..2ec08548 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -11,14 +11,19 @@ import ( "os" "os/exec" "path/filepath" + "slices" + "strconv" "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" + "github.com/Jamf-Concepts/jamf-cli/internal/registry" ) // newMCPCmd exposes the entire jamf-cli command tree to MCP-capable AI clients @@ -59,7 +64,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. @@ -85,14 +91,94 @@ 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 -instead.`, +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', '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) + - '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 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, + --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 +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', 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). 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 +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'. 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 +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. + +--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, 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, 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) @@ -108,9 +194,15 @@ instead.`, return err } + inputDir, err := resolveMCPInputDir(inputDirFlag) + if err != nil { + return err + } + if !noHints { printMCPStartupHints(cmd.ErrOrStderr(), cfg) } + installMCPResolver(cmd.Root(), inputDir) server := mcp.NewServer(&mcp.Implementation{ Name: "jamf-cli", @@ -144,11 +236,34 @@ 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); 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'), " + + "'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 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 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 " + + "'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) { @@ -200,6 +315,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 { @@ -222,17 +371,34 @@ 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 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" { + if name, _, ok := strings.Cut(kv, "="); ok && (name == "JAMF_CLI_ARGS" || name == mcpPinnedProfileEnvVar || name == mcpInputDirEnvVar) { continue } kept = append(kept, kv) } - return append(kept, "JAMF_CLI_MCP=1") + return append(kept, mcpChildEnvVar+"=1") +} + +const ( + mcpChildEnvVar = registry.MCPChildEnvVar + mcpPinnedProfileEnvVar = "JAMF_CLI_MCP_PROFILE" + mcpInputDirEnvVar = "JAMF_CLI_MCP_INPUT_DIR" +) + +// 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, + mcpInputDirEnvVar+"="+installedMCPInputDir()) } type listCommandsInput struct { @@ -274,7 +440,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 +498,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 +566,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,94 +579,440 @@ var blockedChildFlagPrefixes = []string{ "--out-file", } -// blockedChildCommandPaths are command paths a model must not run, as -// space-joined prefixes of the argument list. +// 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 +// instead. // -// 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"}, +// 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. +// +// 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, 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 +// 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{ + {"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 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 sso-oauth-session-tokens", refusedPrintsToken}, + {"jamf-cli pro api-integrations client-credentials", refusedMintsClientSecret}, + {"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}, + {"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}, } -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 +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" + 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" +) + +// 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 !strings.HasPrefix(arg, "--") { - return false + if s.name == "dir" { + if use, ok := mcpDirFlags[path]; ok { + return use, true + } + return pathWrite, true } - // Compare the flag NAME, so --profile=x is judged as --profile. - name, _, _ := strings.Cut(arg, "=") - for _, f := range blockedChildFlagPrefixes { - if strings.HasPrefix(name, f) { - return 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. 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 +// 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 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 + inputDir string +} + +// 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. +// +// 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(), inputDir: mcpResolver.inputDir} + 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 +} + +// 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.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) } } - return false + 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) + } + if use, ok := localPathFlagUse(inv.path, s); ok { + if err := refuseLocalPath(use, s, inv.inputDir); err != nil { + return err + } + } + if inv.path == proDiffPath && (s.name == "source" || s.name == "target") { + if err := refuseDiffSide(s, pinnedProfile, inv.inputDir); err != nil { + return err + } + } + } + return nil } -// 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) { +// 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 } - match := true - for i, seg := range path { - if args[i] != seg { - match = false - break - } + 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. +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(), 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 + } + 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}) } - if match { - return path + }) + 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. +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) + 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 + } + 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 !insideDir(inputDir, resolved) { + 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 +} + +// 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 { + if !strings.HasPrefix(arg, "--") { + return false + } + // Compare the flag NAME, so --profile=x is judged as --profile. + name, _, _ := strings.Cut(arg, "=") + for _, f := range blockedChildFlagPrefixes { + if strings.HasPrefix(name, f) { + return true + } + } + return false +} + // 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 +1024,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 +1182,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 +1271,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_blueprint_profile_redaction_test.go b/internal/commands/mcp_blueprint_profile_redaction_test.go new file mode 100644 index 00000000..1e469c7f --- /dev/null +++ b/internal/commands/mcp_blueprint_profile_redaction_test.go @@ -0,0 +1,141 @@ +// 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, 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 + 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"): + 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_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 { + 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_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_diff_payload_redaction_test.go b/internal/commands/mcp_diff_payload_redaction_test.go new file mode 100644 index 00000000..c4e832a5 --- /dev/null +++ b/internal/commands/mcp_diff_payload_redaction_test.go @@ -0,0 +1,73 @@ +// 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 + PayloadDisplayNameCorp Wi-Fi + + +` +} + +// 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/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) + } + } +} diff --git a/internal/commands/mcp_input_dir_test.go b/internal/commands/mcp_input_dir_test.go new file mode 100644 index 00000000..574756db --- /dev/null +++ b/internal/commands/mcp_input_dir_test.go @@ -0,0 +1,397 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +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, 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()) + 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"), + } + 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) + } + } + 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}, + "report-dir": {"pro", "setup", "--report-dir", fx.sub}, + } + 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) + } +} + +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) + } +} + +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_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/commands/mcp_protect_report_client_secret_test.go b/internal/commands/mcp_protect_report_client_secret_test.go new file mode 100644 index 00000000..be07a6da --- /dev/null +++ b/internal/commands/mcp_protect_report_client_secret_test.go @@ -0,0 +1,201 @@ +// Copyright 2026, Jamf Software LLC + +package commands + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/Jamf-Concepts/jamfprotect-go-sdk/jamfprotect" +) + +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) && !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) + } + }) + } + + 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) + } + } +} + +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/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_resolver_state_test.go b/internal/commands/mcp_resolver_state_test.go new file mode 100644 index 00000000..61580ebd --- /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_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) + } +} 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..bf1aebf3 --- /dev/null +++ b/internal/commands/mcp_run_command_boundary_test.go @@ -0,0 +1,359 @@ +// 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"}, + {[]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"}, + {[]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) { + 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) { + 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", 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 { + 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) + } + }) + } +} 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..3a0528b6 --- /dev/null +++ b/internal/commands/mcp_run_command_guard_test.go @@ -0,0 +1,435 @@ +// Copyright 2026, Jamf Software LLC + +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 _, 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 { + 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") + case f.Value.Type() == "bool": + 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) { + 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 classified: + used[f.Name] = true + if refusedWhole { + return + } + 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 + } + 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) + } + } + 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) + } +} + +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", "-vv"}, + {"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) + } +} + +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/mcp_secret_leaves_test.go b/internal/commands/mcp_secret_leaves_test.go new file mode 100644 index 00000000..4bbe25ef --- /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 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, + "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", "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/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) + } + } +} 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..1c4a4a87 100644 --- a/internal/commands/pro/generated/classic_macos_config_profiles.go +++ b/internal/commands/pro/generated/classic_macos_config_profiles.go @@ -328,8 +328,14 @@ func newClassicMacosConfigProfilesListCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + 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 = 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 +403,14 @@ func newClassicMacosConfigProfilesGetCmd(ctx *registry.CLIContext) *cobra.Comman if err != nil { return err } + 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 = 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..81500d7a 100644 --- a/internal/commands/pro/generated/classic_mobile_config_profiles.go +++ b/internal/commands/pro/generated/classic_mobile_config_profiles.go @@ -315,8 +315,14 @@ func newClassicMobileConfigProfilesListCmd(ctx *registry.CLIContext) *cobra.Comm if err != nil { return err } + 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 = 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 +390,14 @@ func newClassicMobileConfigProfilesGetCmd(ctx *registry.CLIContext) *cobra.Comma if err != nil { return err } + 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 = 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_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) + } + } +} 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..c93dbebd --- /dev/null +++ b/internal/commands/pro/generated/classic_read_redaction_test.go @@ -0,0 +1,181 @@ +// 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, 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 + 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 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) + } + } + } + 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) + } + } +} diff --git a/internal/commands/pro/generated/classic_registry.go b/internal/commands/pro/generated/classic_registry.go index f78b9d4f..d613905d 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 @@ -110,6 +188,101 @@ 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) { + 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") + } + 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 +} + +// 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/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/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 { diff --git a/internal/commands/pro_diff.go b/internal/commands/pro_diff.go index 57ea2e9c..3a025100 100644 --- a/internal/commands/pro_diff.go +++ b/internal/commands/pro_diff.go @@ -19,7 +19,9 @@ 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/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" @@ -95,6 +97,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 +124,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) @@ -145,27 +156,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, @@ -196,7 +211,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 @@ -212,7 +229,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 @@ -257,6 +276,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) @@ -655,6 +677,10 @@ 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 { fmt.Fprintln(os.Stderr, "No differences found.") @@ -679,3 +705,159 @@ 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 +} + +// 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. +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 +} diff --git a/internal/commands/protect_action_configs.go b/internal/commands/protect_action_configs.go index d9048cd4..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" @@ -68,7 +70,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 +112,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 +129,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 +196,70 @@ func newProtectActionConfigsExportCmd(cliCtx *registry.CLIContext) *cobra.Comman } } +// printActionConfig prints an action configuration. In a child of `mcp serve` +// 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) + } + 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 + } + c.Params.URL = redactURLSecrets(c.Params.URL) + clients[i] = c + } + a.Clients = clients + } + 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_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_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_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_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) }, } diff --git a/internal/commands/protect_plans.go b/internal/commands/protect_plans.go index 78ae11ce..7cae903e 100644 --- a/internal/commands/protect_plans.go +++ b/internal/commands/protect_plans.go @@ -226,6 +226,19 @@ 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, `/\`) { + 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 +} + func newProtectPlansConfigProfileCmd(cliCtx *registry.CLIContext) *cobra.Command { var ( outPath string @@ -250,6 +263,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 +307,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) diff --git a/internal/commands/root.go b/internal/commands/root.go index 8762ea86..80d8d852 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 @@ -926,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} } diff --git a/internal/profileconvert/payload_secrets.go b/internal/profileconvert/payload_secrets.go new file mode 100644 index 00000000..5b6a5ea6 --- /dev/null +++ b/internal/profileconvert/payload_secrets.go @@ -0,0 +1,190 @@ +// 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 = "" + +// 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) + 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 (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) { + 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..33ec6f41 --- /dev/null +++ b/internal/profileconvert/payload_secrets_test.go @@ -0,0 +1,125 @@ +// 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) + } +} + +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 +VPNCredentialS3CRET-cred +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", "VPNCredential"} { + 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]) + } + } +} 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 {