fix(mcp): judge run_command on the command and flags cobra resolves - #402
Conversation
…al paths Failing tests for five confirmed findings against `mcp serve` run_command: aliases and flag-first spellings of refused commands, `pro diff` against a foreign profile, local input-path flags, local output-path flags, and `jcds sync --dir --delete` on all three mounts. The dir-vs-dir diff test passes and guards what the fix must keep. Co-Authored-By: Claude Opus 5.5 <[email protected]>
run_command matched refused commands as a literal prefix of the model's argv and refused flags token by token. An alias (`cfg set-default`, `pro jcds sync`, `db`), a persistent flag ahead of the command path (`-q pro backup`), a `pro diff` side naming another profile, and every local input or output path flag all reached the child. buildChildArgs now resolves the child argv the way ExecuteC does (Find, then ParseAll with a callback that never calls Set). It judges the resolved CommandPath against mcpRefusedCommands: multi, mcp, the config writers, every setup, both backups, and jcds sync on both mounts. It judges each resolved flag against the credential prefixes and the local-path classification, where --dir is classified per command. `pro diff --source/--target` must be a directory or the pinned profile. Cobra's __complete is refused by name, because it is added only when invoked and parses no flags of its own. The resolve runs against the tree `mcp serve` is executing, installed at startup, and holds a lock for its whole length. Building a fresh NewRootCmd per call rebound every root flag variable in the serving process to its default, and main's error path reads those after serve returns. Tool calls run concurrently. The child repeats the check on its own parse in PersistentPreRunE when JAMF_CLI_MCP=1, against the pinned profile in JAMF_CLI_MCP_PROFILE, which childEnv strips from the inherited environment. Tree-walk tests fail on a stale refused entry, an unrefused setup, a flag-parsing-disabled command that calls an API, and any unclassified path-shaped flag. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ut-dir Commit 2 refuses every local path flag over run_command, which also refuses the legitimate case: a model asked to create a policy from a file the administrator prepared. `mcp serve --input-dir <dir>` is an operator-only flag on serve, with no config key. The directory is resolved once at startup (Abs, then EvalSymlinks), and serve refuses to start when it is missing or is not a directory. With it set, a read-side path flag is accepted when its value exists and resolves inside the directory, symlinks followed: from-file, file, script-file, mobileconfig-file, appconfig-file, every custom-payload-file occurrence, body-file, input, and the --dir of the two protect imports. A `..` path, an absolute path elsewhere, a symlink out of the directory, an empty value and a nonexistent path are refused with a message naming the directory. With it unset, the refusal names --input-dir as the remedy. --password-file stays refused, as do --token-file and every write-side path flag (save-to, a leaf --output, report-dir, jcds sync --dir). The child enforces the same rule on its own parse. It reads the resolved directory from JAMF_CLI_MCP_INPUT_DIR, which childEnv strips from the inherited environment and pinnedChildEnv sets. The run_command description names the directory, so the model knows where it may point. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A `pro diff --source/--target` directory is a local read: the diff renders the backup files under whatever directory the model names. Until now any directory was accepted over run_command. A directory side now follows the same rule as the other read-side path flags. It is accepted only when it exists and resolves inside --input-dir (Abs, then EvalSymlinks, then a prefix check). It is refused when --input-dir is unset, with a message naming --input-dir. A side naming the pinned profile stays allowed. The server and the child both enforce this. `~/` is expanded by expandDiffDir, which loadSnapshotFromDirectory now shares, so the check judges the path the child opens. TestBuildChildArgs_AllowsDiffWithinThePinnedProfile keeps its three spellings and gains an input directory holding them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keep credentials of every profile out of run_command results. `config show` printed each profile's token, client ID and client secret verbatim; in an MCP child it now shows each as <redacted>. `doctor` echoed each credential field and probed another profile's URL, and `config validate` resolves every profile's secrets and probes their URLs, so both are refused over MCP, with `completion`, server-side and child-side. `config list --status` in an MCP child checks only the pinned profile. Judge containment with filepath.Rel, so `--input-dir /` accepts every path beneath it. An MCP child with an input directory now refuses a `pro diff` backup file, or a `protect analytics` / `unified-logging-filters import --dir` entry, or a `protect restore --input` file, singleton or resource directory, whose symlink resolves outside it. `protect plans config-profile` keeps a plan name as its default file name, and refuses one that is not a single path segment (a separator, empty, "." or "..") with a pointer to -O/--output. Tests cover a sibling directory sharing the input directory's prefix on every side, the child environment runChild passes, `mcp serve` and the credential readers on both sides, and a usage-text path heuristic whose matches are each classified. Co-Authored-By: Claude Opus 5.5 <[email protected]>
run_command still returned a live bearer token from `platform auth token`, `pro auth token`, `protect auth token` and `pro api-authentication token`, `oauth-token` and `keep-alive`. The token works outside mcp serve and every refusal it applies until it expires, so the pinned profile stops mattering once the model holds one. All six are now refused with a reason that names the token; `invalidate-token` prints none and stays allowed. CHANGELOG records the breaking MCP boundary change with --input-dir as the migration, and the protect plans config-profile plan-name refusal. Co-Authored-By: Claude Opus 5.5 <[email protected]>
protect action-configs get/apply print report-client header values, data-forwarding get/update print the Sentinel shared key, and api-clients get prints its password field in an MCP child. action-configs export, pro api-integrations client-credentials and protect api-clients apply are not refused by run_command. Co-Authored-By: Claude Opus 5.5 <[email protected]>
run_command now refuses protect action-configs export (an apply-ready document whose redacted copy would overwrite the real header values), pro api-integrations client-credentials and protect api-clients apply, which mint a credential and print it. In an MCP child, protect action-configs get and apply print each report client's header values as <redacted>, data-forwarding get and update the Sentinel shared key, and api-clients get the password. Outside MCP the output is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
neilmartin83
left a comment
There was a problem hiding this comment.
Warning
run_command checks onto the command and flags cobra resolves, adds --input-dir, and now refuses token printers and redacts Protect secrets. Both round 1 findings are fixed.
Blocking: (1), (2). See the collapsed section for 6 nice-to-have suggestions.
Rating: 3/5
- Would be a 5 with the remaining credential-printing commands refused (1) and Classic secret fields redacted or refused in the MCP child (2).
Findings
internal/commands/mcp.go:552: three generated commands still print a live credential
The latest commit refuses pro api-integrations client-credentials because it prints a credential that works outside the server. The same rule applies to three commands that are not on the list:
pro jamf-cloud-distribution-service renew-credentials(POST/v1/jcds/renew-credentials)pro jamf-cloud-distribution-service-files create(POST/v1/jcds/files)pro sso-oauth-session-tokens list(GET/v1/oauth2/session-tokens)
The first two return the Credentials schema (specs/JamfProAPI.yaml:6745), which holds accessKeyID, secretAccessKey and sessionToken for the tenant's JCDS S3 bucket. The third returns accessToken and idToken. Nothing sweeps the tree for this class. TestMCPRefusedCommands_EveryEntryNamesACommandInTheTree catches a stale entry and not a missing one, so the next make generate adds new holes without a failing test.
Failure scenario: run_command ["pro","jamf-cloud-distribution-service","renew-credentials"] resolves to a path not in mcpRefusedCommands. The child prints AWS STS keys into the model's context, and they keep working against the JCDS bucket after mcp serve exits.
Suggested fix:
{"jamf-cli pro api-integrations client-credentials", refusedMintsClientSecret},
+ {"jamf-cli pro jamf-cloud-distribution-service renew-credentials", refusedMintsUploadCredentials},
+ {"jamf-cli pro jamf-cloud-distribution-service-files create", refusedMintsUploadCredentials},
+ {"jamf-cli pro sso-oauth-session-tokens", refusedPrintsToken},Add a tree-walk test that fails on any leaf whose name, Short or Long matches token|secret|credential|password|private.?key. A matching leaf must be refused or sit in an exemptions map with a reason, the same shape as notALocalPathFlags.
Fixed when: the three paths refuse over MCP with a reason that names the credential. A new generated leaf matching that pattern fails go test until someone classifies it.
internal/commands/mcp.go:536: Classic get prints secret fields to the model unredacted
The PR redacts three Protect third-party secrets in the MCP child. The Classic resources get no such treatment. specs/classic/schemas.json declares string secrets in read schemas: vpp_account.service_token (the Apple sToken), smtp_server.password, ldap_server.connection.account.password, webhook.password and directory_binding.password. internal/client/client.go:520 already records that a Classic read of a distribution point or SMTP server returns its password field. The Classic credential policy covers --set writes only, and no read path masks these fields.
Failure scenario: run_command ["pro","classic-vpp-accounts","get","1","-o","json"] is not refused. The child prints service_token, and the model can then use the VPP sToken against Apple outside the server.
Suggested fix: in an MCP child, pass every Classic get and list response through the existing credentialFieldNames / credentialFieldPaths matcher at the one shared Classic print path, and print <redacted> for each match. Do not redact resource by resource.
Fixed when: a table-driven test walks every string-typed secret field in specs/classic/schemas.json, reusing the sweep behind TestEverySecretBearingFieldIsRefusedForSet. It shows that an MCP-child get prints the redaction marker for each field.
This covers all findings — addressing the above gets this PR to merge-ready.
Nice-to-have suggestions (6 items)
💡 NICE-TO-HAVE (3) (usability, via usability-reviewer) — internal/commands/protect_plans.go:234: the plan-name refusal tells the model to pass -O, and MCP refuses -O. Over MCP, drop the remedy or say the plan cannot be saved through run_command.
💡 NICE-TO-HAVE (4) (security, via security-reviewer+silent-failure-hunter) — internal/commands/protect_action_configs.go:207: redactReportClientHeaders masks header values but leaves Params.URL. A Slack, Teams or HEC webhook often carries its secret in the URL path or query. Mask the userinfo and query, or say in the help that the URL is shown.
💡 NICE-TO-HAVE (5) (security, via security-reviewer) — internal/commands/mcp.go:536: device secrets are still readable. These include the LAPS password (pro local-admin-password password), the FileVault personal recovery key, the recovery lock password and the mobile device unlockToken. Refuse them or allow them, and record which in the mcpRefusedCommands comment.
Promote to IMPORTANT when: the threat model includes prompt injection that steers the model into device-credential exfiltration.
💡 NICE-TO-HAVE (6) (security, via security-reviewer) — internal/commands/protect_org.go:403: protect downloads websocket-auth and csr write a .p12 into the server's working directory with no path flag, so the model can trigger a key-material write and overwrite a same-named file. Refuse them in the MCP child.
💡 NICE-TO-HAVE (7) (silent-failure, via silent-failure-hunter) — internal/commands/mcp.go:283: mcp serve --input-dir "$DIR" with DIR unset starts as if no input dir were given. The failure is closed, but the operator gets no signal. Error on an empty value when the flag is Changed.
💡 NICE-TO-HAVE (8) (usability, via usability-reviewer) — internal/commands/mcp.go:799: the --output refusal does not say that -o <format> is still fine. A relative read path resolves against the server's cwd, not --input-dir, so the "outside the input directory" refusal should tell the model to pass an absolute path.
PR decomposition
Keep as one PR. All 8 commits harden the run_command boundary in mcp.go, and each layer depends on the one before it: the resolver, then --input-dir, then the pro diff sides, then the token refusals and the secret redaction. Optional carve-out: the protect plans config-profile plan-name refusal (about 40 lines) changes non-MCP behaviour and could merge first on its own. The CHANGELOG already gives it its own entry, so keeping it here is acceptable.
Review coverage and scope
- Correctness: resolver, child re-check,
pro diffsides, input-dir containment, Protect redaction helpers - Security: credential-printing commands (1), Classic reads (2), Protect redaction paths across every
-oformat - Test coverage: both suites pass; 6/6 guard mutations killed
- Silent failures: no fail-open;
FindandParseAllerrors fall to the child's own parse - Documentation: CHANGELOG,
agent_context.md,mcp serve --helpand the tool description matchmcpRefusedCommands - Project rules:
credentials-and-auth.md,coding-style.md,classic-api.md - [na] Performance, frontend, dependencies, migrations
Diff: 20 files, +2206/−131, head 41f1f70, incremental +353/−11 since 7111d7e. High risk (security boundary, size). Lanes run: security-reviewer, silent-failure-hunter, test-quality-reviewer, devil-advocate, usability-reviewer, scope-reviewer. Never run: performance-reviewer (inapplicable: no query, cache or hot-path change), fidelity-reviewer (inapplicable: no linked spec). Conventions: root CLAUDE.md and 3 .claude/rules/*.md, from the session load at an unchanged revision; the PR changes none of them.
Prior review status
| # | Location | Rounds | State | Notes |
|---|---|---|---|---|
| (1) | internal/commands/mcp.go:518 |
1 | ✅ Fixed | auth token ×3 and api-authentication token/oauth-token/keep-alive refused in f77cc32 |
| (2) | CHANGELOG.md:12 |
1 | ✅ Fixed | ## Unreleased carries the MCP breaking entry with --input-dir as the migration, plus the plan-name line |
What's done well
✅ The child repeats the refusal in root PersistentPreRunE from its own env, so a server-side miss is still caught. Mutation C showed that seven child-spawning tests depend on it.
🤖 Generated by the pr-review:review skill v1.39.0 · reviewed head 41f1f70
A tree walk fails on any leaf whose name or help names a token, secret, credential, password or private key and that is neither refused over MCP nor exempted with a reason. Four leaves fail it today: the SSO session token reader, the cloud distribution point patch (its CloudFront signing key is readable), and the two commands that set a Jamf Pro login password to a value the model chooses. A sweep over every string-typed credential field the Classic generator refuses for --set shows that an MCP-child get prints each one verbatim, in every output format. The same reads outside MCP must stay byte-identical. Also pins the nice-to-have fixes: the Protect .p12 downloads, an empty --input-dir, the --output and relative-path refusal wording, the plan-name remedy over MCP, and report-client URL userinfo and query. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… secrets run_command now refuses the SSO session token reader, the cloud distribution point reads and writes whose response carries the CloudFront signing key, and the commands that set a Jamf Pro login password to a value the model chose. It also refuses the two Protect downloads that write .p12 key material into the server's directory. The device secrets of the pinned tenant and the JCDS upload credentials stay available by the operator's decision, and the mcpRefusedCommands comment records that line. Every generated Classic get and list passes its body through one helper before choosing a format. In an MCP child it replaces the text of each element the resource's --set refuses as a credential with the redaction marker, so JSON, YAML, table, plain, XML and raw all print it. A body it cannot parse is refused rather than printed. Outside MCP nothing changes. The child flag moves into registry so generated code can read it. Report-client URLs lose their userinfo and query over MCP, an empty --input-dir is an error, the --output refusal names -o <format>, a relative path outside the input directory asks for an absolute one, and the plan-name refusal no longer offers -O over MCP. Co-Authored-By: Claude Opus 5.5 <[email protected]>
pro diff runs over MCP against the pinned profile or a backup directory inside --input-dir, and it reports each changed field's old and new value. For a policy's account password, a disk encryption configuration's institutional keystore and an account's password hash, those values are the credential, so an MCP child prints them verbatim. Outside MCP the diff must keep printing them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The generated Classic registry now exports each command group's credential element names, from the same body spec the get and list redaction uses. In an MCP child, pro diff masks every old and new value that is, or holds, one of those fields for its resource, after comparing, so a changed credential is still reported as modified. Outside MCP nothing changes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…acted In an MCP child, a client decorator replaces privateKey and password in the GET /v1/cloud-distribution-point response with <redacted> before any formatter sees it, so every output format, --select and --field print the redacted record. Outside MCP the response is unchanged. create and patch stay refused. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thanks. Round 2 is addressed in (1) Credential-printing commands.
Two decisions the maintainer made while going through the classification:
(2) Classic reads. Every generated Classic (3)–(8).
|
#407 added the --set credential-refusal sentence to cloud-ldap update's help, so the guard now sees it. It sends the keystore and its password and its response keystore carries only the name, type and expiry. Co-Authored-By: Claude Opus 5.5 <[email protected]>
neilmartin83
left a comment
There was a problem hiding this comment.
Warning
pro-nmartin: the new Classic redaction prints <redacted> in every output format. One new gap is open: Wi-Fi, VPN and certificate secrets inside a configuration profile's <payloads> still reach the model, and neither the help nor the policy comment says they are shown.
Blocking: (1). See the collapsed section for 1 nice-to-have suggestion.
Rating: 3/5
- Would be a 5 once profile-payload secrets are either redacted, or recorded as shown in the policy comment,
mcp serve --helpand the tool description (1).
Findings
generator/classic/generator.go:1709: configuration-profile payload secrets print to the model, and nothing says they do
redactClassicReadInMCPChild redacts only elements named after a schema credential field. A profile's secrets are not elements. They sit inside the escaped plist in <payloads>, so the element walk never sees them. I checked this on the wire: I created a prreview-402-wifi macOS profile on pro-nmartin with a Wi-Fi payload whose Password was a sentinel, then deleted it. An MCP child (JAMF_CLI_MCP=1, pinned profile) printed the sentinel verbatim with -o raw and with -o json.
A Wi-Fi PSK, an 802.1X or VPN password, a VPN shared secret, a SCEP challenge, or a PKCS#12 blob with its password works outside this server. By the rule in the mcpRefusedCommands comment (internal/commands/mcp.go:572), such a secret is refused or redacted. It is not a device secret, so the device-secret exemption does not cover it. The new help text (mcp.go:140) lists what is shown, and payloads are not on that list. An operator who reads "every Classic credential field prints as <redacted>" will assume a Wi-Fi password is covered. The same payloads reach the model through classic-mobile-config-profiles get and through pro diff on the profiles filter. Blueprint configuration components carry them too.
Failure scenario: run_command ["pro","classic-macos-config-profiles","get","<id>","-o","json"] on a tenant with an enterprise Wi-Fi profile. The child prints <key>Password</key><string>…</string> into the model's context.
Suggested fix: pick one of these and state it in the policy comment.
- Record it (the smallest fix). Add profile payloads to the "shown" sentence in
mcp serve --help, in the tool description, and in themcpRefusedCommandscomment, beside the LAPS password. - Redact it. In an MCP child, pass
<payloads>throughprofileconvert's plist decode and replace the string or data value of the known secret keys (Password,SharedSecret,XAuthPassword,Challenge,PayloadCertificatePassword, acom.apple.security.pkcs12PayloadContent) with the marker.
Fixed when: either the three policy texts name profile payloads as shown, or an MCP-child get of a Wi-Fi-payload fixture prints the redaction marker in place of the PSK in every -o format, and pro diff on profiles prints it too.
This covers all findings — addressing the above gets this PR to merge-ready.
Nice-to-have suggestions (1 item)
💡 NICE-TO-HAVE (2) (test-coverage) — internal/commands/mcp_secret_leaves_test.go:130: TestMCPSecretNamingLeaves_AreClassified decides on help text, not on what the response carries. pro cloud-distribution-point list is the proof. It returns the CloudFront privateKey, it passes the guard, and it is not in notACredentialPrinter, so its help never names the key. The guard would not have caught it. A sweep of specs/JamfProAPI.yaml and the Platform and Security specs finds no other unclassified response that carries a readable secret today, so nothing is open now. The next spec ingest can still add one with no failing test. A second guard that walks 2xx response schemas for a string property matching the same pattern, and not marked writeOnly, covers what the help-text walk misses.
Prior findings status
| # | Location | Rounds | State | Notes |
|---|---|---|---|---|
| (1) | internal/commands/mcp.go:552 |
2 | ✅ Fixed | sso-oauth-session-tokens refused. Both JCDS leaves are allowed by the maintainer's decision, and that decision is recorded with its reason. The tree-walk guard now exists and fails on a stale exemption |
| (2) | internal/commands/mcp.go:536 |
2 | ✅ Fixed | Every generated Classic get/list redacts at one shared helper (53 files), and pro diff masks the same leaves. Wire-checked on classic-ldap-servers get 31: <redacted> in json, raw, xml, table and yaml |
| (3) | internal/commands/protect_plans.go:234 |
2 | ✅ Fixed | Over MCP the error says the profile cannot be saved through run_command |
| (4) | internal/commands/protect_action_configs.go:207 |
2 | ✅ Fixed | The userinfo and query are masked, and a malformed URL fails closed. The help says a path secret still shows |
| (5) | internal/commands/mcp.go:536 |
2 | ✅ Fixed | Device-secret policy recorded in the mcpRefusedCommands comment, the help and the tool description |
| (6) | internal/commands/protect_org.go:403 |
2 | ✅ Fixed | protect downloads csr and websocket-auth refused |
| (7) | internal/commands/mcp.go:283 |
2 | ✅ Fixed | An empty --input-dir is an error |
| (8) | internal/commands/mcp.go:799 |
2 | ✅ Fixed | The refusal says -o <format> still works, and a relative path gets the absolute-path hint |
Review coverage and scope
- Correctness: Classic span redaction (nested, self-closing and CDATA cases), the CDP client decorator,
pro diffmasking, the URL redaction, the empty--input-dircheck - Security: Classic reads wire-checked; profile payloads wire-checked (1); response-schema sweep of the Pro, Platform and Security specs (2)
- Test coverage:
internal/commands,pro/generated,registryandgenerator/...pass at8b7ab94 - Silent failures: Classic redaction fails closed on a non-XML or unparsable body.
redactCloudDistributionPointreturns the raw bytes when decode fails, which is acceptable for an endpoint that only answers with an object - Documentation: CHANGELOG,
agent_context.md,mcp serve --helpand the tool description matchmcpRefusedCommands, except for (1) - Project rules: root
CLAUDE.mdand three.claude/rules/*.md, from the session load. The PR changes none of them - [na] Performance: one XML token pass per Classic read, MCP child only. Fidelity: no linked spec
Grading: this round graded only the author's six commits since 41f1f70 (9ca8c830, 9aef814a, 87f58e01, c5337dfe, 9f23d678, 8b7ab942). Both merges of main resolved only CHANGELOG.md, and both resolutions are clean. git merge-tree against current main, which now includes #404 and #407, reports no conflict. Specialist lanes were not re-dispatched this round. Security and silent-failure on the delta were covered inline with wire probes. Findings were scored by the orchestrator: (1) is wire-verified, and (2) is proven by the passing guard.
🤖 Generated by the pr-review:review skill v1.39.0 · reviewed head 8b7ab94
An MCP-child get of a Classic macOS or mobile profile prints a Wi-Fi password, an EAP password, a VPN shared secret and XAuth password, a SCEP challenge and a PKCS#12 blob with its password in every -o format, because they sit inside the escaped plist in <payloads>. pro diff on the profiles filter prints them too. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Classic profile's Wi-Fi, EAP, VPN, SCEP and identity secrets sit in the escaped plist in <payloads>, which the element-level Classic redaction never sees. In an MCP child, get and list on both profile resources now decode that plist with profileconvert and replace each string or data value under a key ending in password or secret, a Challenge, and a com.apple.security.pkcs12 PayloadContent, matched case-insensitively at any depth. The result is re-escaped into <payloads>, so the body stays a profile document. A payload that does not decode is replaced whole, and a body that is not XML is refused. pro diff applies the same redaction to the profiles filter. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The mcpRefusedCommands policy comment, mcp serve --help, the run_command tool description, agent_context.md and the CHANGELOG now say that the secrets inside a Classic configuration profile's payloads print as <redacted>, and that blueprint configuration is shown: the Platform SDK decodes each response inside its transport, so there is no per-response hook to redact it at. Co-Authored-By: Claude Opus 5.5 <[email protected]>
TestMCPSecretNamingLeaves_AreClassified judges a leaf on its help, so pro cloud-distribution-point list, whose response carries the CloudFront private key, passed it. The new guard walks the 2xx response schemas of the Pro, Platform and Security Cloud specs, items and nested objects included, for a string property named like a secret that is not writeOnly, and requires each operation to carry a verdict: its leaf refused over MCP, or a reason it may reach the model (redacted, a device secret, a timestamp, an enum or a label). Each verdict lists the exact properties it judged, so a new secret on an exempted response fails. A second test feeds it a fabricated response and requires the failure. Co-Authored-By: Claude Opus 5.5 <[email protected]>
neilmartin83
left a comment
There was a problem hiding this comment.
Warning
pro-nmartin the Wi-Fi PSK now prints as <redacted> from get. Two gaps remain. pro blueprints components configuration-profile --id prints the same PSK verbatim. Secrets under token-named or key-named payload keys are not redacted, although the help says "each secret" is.
Blocking: (1), (2).
Rating: 3/5
- Would be a 5 once the blueprint converter's
--id/--namedownload is redacted in an MCP child (1), and the payload key matcher covers what the help text says it covers (2).
Findings
internal/commands/pro_blueprints.go:912: pro blueprints components configuration-profile --id <id> prints a Classic profile's payload secrets to the model
The new redaction is attached to the generated Classic get/list and to pro diff. A third reader fetches the same <payloads> and gets no redaction. components configuration-profile --id/--name calls downloadClassicProfile → fetchClassicProfile (:1051), converts the plist, and fmt.Printlns the component JSON. The command needs no path flag, so it runs over MCP without --input-dir.
Wire-checked through a real mcp serve -p pro-nmartin over stdio, against a disposable macOS profile (id 7620, since deleted) that held a Wi-Fi Password and a Chrome custom payload:
run_command args |
Wi-Fi PSK in the result |
|---|---|
pro classic-macos-config-profiles get 7620 -o json |
no, <redacted> |
pro classic-macos-config-profiles get 7620 -o raw |
no, <redacted> |
pro blueprints components configuration-profile --id 7620 |
yes, verbatim |
The help text names "blueprint configuration" as shown. That describes the Platform API's blueprint reads. This command reads a Classic profile, which is the resource this PR now says is redacted. --type mobile takes the same path.
Failure scenario: run_command ["pro","blueprints","components","configuration-profile","--id","<enterprise Wi-Fi profile id>"]. The component JSON carries "Password": "<PSK>" into the model's context.
Suggested fix: in an MCP child, redact the downloaded mobileconfig at the print site, before ConvertMobileconfig:
if registry.InMCPChild() && (profileName != "" || profileID != "") {
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)
}
}Do not put this in fetchClassicProfile. import-profile also calls it, and there the plist is written into a new blueprint, so redacting it would store <redacted> as the real Wi-Fi password.
Fixed when: an MCP-child run of components configuration-profile --id on a Wi-Fi-payload fixture prints the marker instead of the PSK, import-profile still copies the real value, and a test holds both.
internal/profileconvert/payload_secrets.go:22: isSecretPayloadKey misses token-named and key-named secrets, but the help says "each secret" in a payload is redacted
The matcher redacts a key that ends in password or secret, or is Challenge. Custom-settings payloads often carry credentials under other names. The same probe profile carried two of them, and both printed verbatim through get -o json and -o raw:
CloudManagementEnrollmentToken(Chrome Browser Cloud Management). This token enrolls any browser into the organization.TailscaleAuthKey, a pre-auth key.
mcp serve --help (internal/commands/mcp.go:137) says "Each secret inside a configuration profile's payloads … is redacted too". The CHANGELOG and agent_context.md use the same words. The PR's own guard regex, namesASecret (mcp_secret_leaves_test.go:13), counts token and private.?key as secret names, so the two matchers in this PR disagree about what a secret is called.
Failure scenario: a tenant deploys Chrome CBCM through a Jamf custom-settings profile. get <id> -o json over MCP returns the enrollment token.
Suggested fix: pick one of these.
- Widen the matcher. Also match a key that ends in
token,authkey,apikey,accesskeyorprivatekey. A false positive costs one redacted value in an MCP child, and a miss leaks a credential, so err wide. - Narrow the claim. Change the help, the tool description, the CHANGELOG and
agent_context.mdto say which keys are redacted ("keys named*Password,*Secret,Challenge, and PKCS#12 content"). Also say that other custom-payload values are shown.
Fixed when: either a payload fixture with CloudManagementEnrollmentToken and AuthKey prints the marker for both, with a profileconvert test, or the four policy texts name the matched keys and say what is not matched.
This covers all findings — addressing the above gets this PR to merge-ready.
Prior findings status
| # | Location | Rounds | State | Notes |
|---|---|---|---|---|
| (1) | generator/classic/generator.go:1709 |
2 | ✅ Fixed | get/list on both profile resources and pro diff on profiles redact payload secrets (2b4bd96e). Wire-checked: the Wi-Fi PSK prints as <redacted> in -o json and -o raw. Policy texts updated (2b9f2de6). The gaps found next to it are new findings (1) and (2) |
| (2) | internal/commands/mcp_secret_leaves_test.go:130 |
2 | ✅ Fixed | TestMCPResponseSecrets_AreClassified (11e1092b) walks the 2xx response schemas of the Pro, Platform and Security specs. It classifies the CloudFront privateKey and fails on stale verdicts. A synthetic test confirms it fires |
Review coverage and scope
- Correctness:
RedactClassicProfilePayloadsspan rewrite (nested, CDATA, undecodable payloads fail closed, depth check on EOF), plist round trip of the redacted payload,pro diff's shallow-field path throughredactPayloadsKeys - Security: wire-checked through a real
mcp servesession. Coveredget -o json,get -o rawand the blueprint converter, then grepped every other Classic-profile reader (pro_blueprints.go,pro_group_tools.go,pro_report_mdm.go, dashboards). Only the converter prints payload content, which is (1) - Test coverage:
internal/profileconvert,internal/commands/pro/generated,internal/commands(MCP|Diff|Payload|Redact) andgenerator/...pass at11e1092 - Silent failures: an undecodable payload becomes the marker, and a body that is not XML is refused.
redactDiffPayloadValueleaves a value that is not JSON alone, which is safe here becausediffObjectsis shallow and a nestedgeneralarrives as JSON - Documentation: CHANGELOG,
agent_context.md,mcp serve --help, the tool description and themcpRefusedCommandscomment agree with one another. They overclaim against the matcher, which is (2) - Project rules: root
CLAUDE.mdand the three.claude/rules/*.md, from the session load. The PR changes none of them. The generated files changed throughgenerator/classic/generator.go, as the generated-code rule requires - [na] Performance: one plist decode per profile
getin an MCP child only. Fidelity: no linked spec
Grading: this round graded the author's four commits since 8b7ab94 (a7612bfe, 2b4bd96e, 2b9f2de6, 11e1092b). Merge f919e4e9, which brought in #404, matches a clean git merge-tree of its parents. git merge-tree against current main reports no conflict. Specialist lanes were not re-dispatched, because this run could not spawn subagents. The new redaction predicate is a shared guard, which would normally re-run security and silent-failure, so both were covered inline with wire probes. The orchestrator scored the findings itself: both are wire-verified, and (2) is at c=75 because its severity rests on how the help text reads.
🤖 Generated by the pr-review:review skill v1.39.0 · reviewed head 11e1092
…leak pro blueprints components configuration-profile --id/--name downloads a Classic profile and prints the converted component with its Wi-Fi password verbatim over MCP, for both --type values. import-profile is held to the opposite: it copies the profile into a new blueprint, so the create request must carry the real value. Payload keys ending in token, authkey, apikey, accesskey, privatekey, secretkey or passcode print verbatim: a Chrome enrollment token, a Tailscale auth key and an API key among them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ile downloads With --id or --name the command downloads a Classic profile and prints the converted component, so in an MCP child the downloaded mobileconfig now goes through profileconvert.RedactPayloadSecrets before conversion, and a payload that does not decode is refused rather than printed. The redaction sits at this print site and not in fetchClassicProfile, because import-profile copies the same payloads into a new blueprint. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… shown isSecretPayloadKey matched only keys ending in password or secret, so a Chrome enrollment token, a Tailscale auth key and API keys printed verbatim from a custom-settings payload while the help said each secret was redacted. It now also matches, case-insensitively and on the whole suffix, token, authkey, apikey, accesskey, privatekey, secretkey, passcode and credential, the last aligning with the words the MCP guards count as a secret name. A bare pin is not matched. mcp serve --help, the run_command description, agent_context.md and the CHANGELOG now name those suffixes, name the blueprint converter, and say every other payload value is shown. A test holds all four to profileconvert.SecretPayloadKeySuffixes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Round 4 is addressed in c17fad5 (failing tests), 5070faa and b70aed3. 1. Blueprint converter Caller sweep of the Classic profile fetchers:
2. Key matcher coverage. Gates: no test failures, |
neilmartin83
left a comment
There was a problem hiding this comment.
Warning
--id/--name download in an MCP child, and the payload key matcher now covers token-named and key-named keys. The four policy texts also name the suffixes. One new gap is open. -vvv is not refused over MCP, and it writes the Classic response body to stderr, where the <payloads> plist is not redacted. run_command returns stderr to the model.
Blocking: (1).
Rating: 4/5
- Would be a 5 once a
run_commandwith-vvvcan no longer print a body that the PR's redaction removes from stdout (1).
Findings
internal/commands/root.go:923: -vvv logs the unredacted Classic profile body, and run_command returns that log to the model
--verbose is not in blockedChildFlagPrefixes (internal/commands/mcp.go:567), and refuseOverMCP has no rule for it. In the child, client.WithVerbose(verboseLevel) goes into client.New. The body is logged inside the client, before any MCP-child decorator (cdnKeyRedactingClient) and before the print-site redaction (redactClassicProfilePayloadsInMCPChild, the blueprint converter's RedactPayloadSecrets). runChild uses CombinedOutput() (mcp.go:497), so stderr reaches the model.
The log goes through redactBodyForLog → RedactCredentialBody. That function matches a credential by name in three forms: a JSON key, a form field, and a leaf XML element (credentialXMLElementRe, internal/client/client.go:504). A profile's secret is in none of those forms. It is a plist inside <payloads>, escaped as element text, and the plist puts the value in a <string> beside a <key>Password</key>. I ran the redactor's regex against such a body. The output was byte-identical to the input:
<payloads><dict><key>Password</key><string>HUNTER2PSK</string></dict></payloads>
So the redaction from rounds 2 to 4 holds on stdout and can be skipped with one flag. The JSON and leaf-element credentials (service_token, distribution point and SMTP passwords, privateKey) are matched in the log, so the payload plist is the gap. The token-named payload keys this round added (CloudManagementEnrollmentToken, TailscaleAuthKey) are not in credentialNamePattern either.
Failure scenario: run_command ["pro","classic-macos-config-profiles","get","<Wi-Fi profile id>","-vvv"]. stdout shows <redacted>, and the stderr body log in the same tool result carries the PSK. The same happens with pro blueprints components configuration-profile --id <id> -vvv.
Suggested fix: in an MCP child, cap or refuse body logging. Do not try to teach the log redactor about plists:
- In
refuseOverMCP, refuse a resolved--verbosewhose count is 3 or more, with a reason ("bodies are not logged over MCP"). This fits the PR's model: the parent refuses and the child checks again. - Or, in the child's
PersistentPreRunE, clampverboseLevelto 2 whenregistry.InMCPChild(), and say so on stderr.
Refusing is better. A clamp leaves -vvv advertised in the tool and silently changes what it does. Also check what -vv headers show. Authorization is redacted, but Set-Cookie/Cookie (the session-affinity cookies the jar carries) are logged in full.
Fixed when: a buildChildArgs test refuses -vvv, -v -v -v and --verbose=3 (and -vvv placed after the command path), the child-side check refuses the same argv with JAMF_CLI_MCP=1, and the policy texts say body logging is not available over MCP.
This covers all findings — addressing the above gets this PR to merge-ready.
Prior findings status
| # | Location | Rounds | State | Notes |
|---|---|---|---|---|
| (1) | internal/commands/pro_blueprints.go:912 |
2 | ✅ Fixed | 5070faa1 redacts the download in an MCP child, before ConvertMobileconfig, and fails closed when the payload does not decode. It is placed at the print site, so import-profile still sends the real values, and a test holds that. Both --type computer and --type mobile are covered |
| (2) | internal/profileconvert/payload_secrets.go:22 |
2 | ✅ Fixed | b70aed36 uses the exported SecretPayloadKeySuffixes (adds token, authkey, apikey, accesskey, privatekey, secretkey, passcode, credential). isScalarSecret limits it to string and data values, so boolean policy keys such as RequirePasscode are not touched. TestMCPPolicyTexts_NameEveryPayloadSecretSuffix holds all four policy texts to the list |
Review coverage and scope
- Correctness: the new suffix matcher, scalar-only replacement, and the redaction placed only on the converter's download branch (
--from-fileinput is the operator's file inside--input-dir) - Security: swept other routes by which a fetched profile body can reach the child's combined output. Found
-vvv(1). CheckedcredentialNamePatternagainst the Classic schema's token field names: the VPP token isservice_token, which is matched - Test coverage:
go test ./internal/profileconvert/andgo test ./internal/commands/ -run 'MCP|Blueprint|Payload|Redact|PolicyText'pass atb70aed3 - Silent failures: an undecodable download is refused, not printed
- Documentation: help, tool description, CHANGELOG and
agent_context.mdagree with the matcher and are held to it by a test. They do not mention-vvv, which is part of (1) - Project rules: root
CLAUDE.mdand the three.claude/rules/*.md, from the session load. The PR changes none of them. No generated files changed this round - [na] Performance, fidelity (no linked spec)
Grading: this round graded the three commits since 11e1092 (c17fad57, 5070faa1, b70aed36). No specialist lanes were dispatched. The delta is 292 lines across two redaction call sites already covered by the security and silent-failure lanes in earlier rounds. (1) was found inline and checked by running the log redactor's regex on a synthetic escaped-payload body. It was not wire-checked through a live mcp serve, which is why it is scored c=75 and not 100.
🤖 Generated by the pr-review:review skill v1.39.0 · reviewed head b70aed3
A --verbose count of 3 or more logs each response body to stderr inside the client, before any MCP redaction, and run_command returns stderr to the model. Neither buildChildArgs nor the child's own check refuses it, in any spelling: -vvv, -v -v -v, --verbose=3, -vv -v, or after the command path. -vv headers print Set-Cookie and Cookie values in full. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The -vv header log printed session-affinity cookies in full on every response. A Cookie or Set-Cookie value now prints as [redacted] in every mode, the way a request's Authorization header already did. Co-Authored-By: Claude Opus 5.5 <[email protected]>
At a --verbose level of 3 or more the client logs each response body to stderr before any MCP redaction sees it, and run_command returns stderr to the model, so a Classic profile's payload secrets printed there while stdout showed <redacted>. refuseOverMCP, which serves both the parent check and the child's own re-check, now resolves the level the way pflag's count flag does and refuses 3 or more, in every spelling and position. The child also drops JAMF_CLI_ARGS, and anything prepended to its argv is parsed and judged the same way. -vv and less stay available. mcp serve --help, the run_command description, agent_context.md and the CHANGELOG say body logging is not available over MCP. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Round 5, finding (1): wire-checked. Confidence is now 100. I ran the PR binary at
The flag is accepted both before and after the command path. Nothing refuses it in the parent or in the child. Correction to (1): please ignore my aside about cookies at |
…ver MCP Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Round 5 is addressed in ad18fdb (failing tests), 7ddd0a0, ca06b80 and 87b521c.
Cookies at Test change. With the refusal removed, all 14 refusal checks fail. With the cookie redaction removed, both cookie checks fail. Gates: no test failures, |
|
Thanks for the wire check. On cookies: noted that your tenant logged no |
neilmartin83
left a comment
There was a problem hiding this comment.
Tip
✅ Merge-ready. Round 5's finding (1) is fixed, and I checked the fix through a real mcp serve -p pro-nmartin. run_command now refuses --verbose at level 3 or more in both the parent and the child. The -vv header log also redacts Cookie/Set-Cookie values, in every mode. One nice-to-have below, not blocking: the child already catches what it describes.
Rating: 5/5
- The last open gap is closed and wire-verified. The one remaining item is defence-in-depth parity, and the child's check already covers it.
Findings
1 nice-to-have suggestion
💡 NICE-TO-HAVE (1) (correctness) — internal/commands/mcp.go:865: verboseCount parses with strconv.Atoi, while pflag's count flag uses strconv.ParseInt(s, 0, 0)
pflag reads --verbose=0x3 and --verbose=0b11 as 3. Atoi fails on both, so the parent leaves n at 0 and spawns the child. The child then refuses, because it reads the final count, so nothing leaks. I confirmed this on the wire: both spellings came back as the child's refusal (command failed: exit status 1 around the refusal envelope), with no request body in the result. The cost is that the parent check is not the boundary its comment claims ("resolves --verbose the way pflag's count flag does"), and the refusal comes back in a different shape.
Suggested fix: strconv.ParseInt(s.value, 0, 0) instead of Atoi, and add --verbose=0x3 to verboseBodyForms.
Fixed when: TestMCP_RefusesVerboseBodyLogging refuses --verbose=0x3 at buildChildArgs.
Prior findings status
| # | Location | Rounds | State | Notes |
|---|---|---|---|---|
| (1) | internal/commands/root.go:923 |
1 | ✅ Fixed | ca06b80d refuses --verbose ≥ 3 in refuseOverMCP, which the child also runs. Wire-checked against a disposable profile (id 7622, since deleted) whose Wi-Fi Password is a sentinel. -vvv before or after the path, -v -v -v, -vv -v, --verbose=3, --verbose=2 -v, --verbose=+1 -vv, -v=3 and the blueprint converter with -vvv are all refused, with no sentinel. -vv runs and carries no sentinel. Plain get and the converter still run and are still redacted. 7ddd0a04 covers my withdrawn cookie aside anyway, which is harmless |
Review coverage and scope
- Correctness:
verboseCountagainst pflag'scountValue.Set(+1increments, an explicit value sets it, so order is respected). Every body log (internal/client/client.goand the Platform SDK transport inpro_platform_helpers.go) is gated on the same globalverboseLevel ≥ 3. No env var turns on body logging.JAMF_CLI_ARGSis stripped bychildEnv - Security: wire-checked through a real
mcp serveas above, including the0x3/0b11spellings that only the child catches, which is (1) - Test coverage:
go test ./internal/client/ ./internal/profileconvert/ ./internal/commands/passes at87b521c. The new tests cover the parent and the child for 7 refused and 4 allowed spellings - Documentation:
mcp serve --help, the tool description, the CHANGELOG andagent_context.mdall name the-vvvrefusal and the cookie redaction - Project rules: root
CLAUDE.mdand the three.claude/rules/*.md, from the session load. The PR changes none of them - [na] Performance, fidelity (no linked spec)
Grading: this round graded the four commits since b70aed3 (ad18fdb8, 7ddd0a04, ca06b80d, 87b521c6), 134 lines. No specialist lanes were dispatched. The delta is one predicate and one header-log condition, and both were checked inline and on the wire.
🤖 Generated by the pr-review:review skill v1.39.0 · reviewed head 87b521c
The merge of #402 brought a guard that every path-shaped flag is classified. --new-password-file, --pin-file and --unlock-token-file read a credential from a local file, as --password-file does, and are classified the same way. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This branch gave clear-passcode a Long that names the unlock token, so the guard from #402 requires the leaf to be classified. It sets a secret of the pinned tenant's devices, like set-recovery-lock. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…dry-run output (#406) * test: pin -vvv and --dry-run body redaction of token, pin and keystore fields The -vvv body redactor misses token-shaped fields (token, pin, unlockToken, accessToken, serverToken, identityKeystore, keystoreBytes, XML <token>, form token=), and the three --dry-run previews print request bodies with no redaction at all. These tests fail until the redactor is widened and the previews route through it. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(redact): redact token, pin, passcode and keystore fields in -vvv and --dry-run The -vvv body redactor matched a fixed alternation of credential words and missed token-shaped fields (token, pin, unlockToken, accessToken, serverToken, identityKeystore, keystoreBytes, XML <token>, form token=). The three --dry-run previews printed request bodies with no redaction at all. The rule moves to internal/redact, a leaf package, because internal/client depends on internal/security and the Security Cloud dry-run reporter could not import it. A field is redacted when its last word is token, pin or passcode, or when its name ends in keystore, keystore bytes, authorization or authorization header, or holds an existing credential word anywhere. The client's exported redactors delegate to it, and commands' identifier splitter moves with it. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(pro)!: read Recovery Lock, lock PIN and unlock token from a file --new-password, --pin and --unlock-token took a device secret on argv, where ps, shell history and a CI log could read it. They are replaced by --new-password-file, --pin-file and --unlock-token-file (which takes - for stdin), and set-recovery-lock prompts without echo on a terminal and refuses under --no-input. Clearing a Recovery Lock is now --clear rather than omitting the flag. renamedFlags points each old name at its file flag. No command now registers a secret-named string flag, and TestNoFlagTakesASecretOnArgv keeps it that way, so refuseStrayPositionals' carriesASecretFlag term could never fire. It is removed with the test that required such a leaf to exist; the matcher moves to a test helper, where one remaining test uses it to vet a probe leaf. The MCP child-flag exemption moves from --unlock-token to --unlock-token-file. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(redact): close the review-round gaps in body, URL and secret-file handling Body redaction learns three shapes it judged by the leaf name alone: every value inside an institutional_recovery_key element or object (the inventory status form is left alone), a plist <key>/<string> pair raw, entity-escaped or inside a JSON string, and a string array under a credential name. challenge, signature, credential(s) and keystore file join the suffixes, and a number is redacted only under a pin or passcode name. The Classic generator now reads redact's CredentialFieldPaths, so --set and the redactor share one list. HTTP error messages, the -v request line and a JCDS download error are redacted too: the first quoted a raw response body, the second printed credential query parameters, the third a pre-signed URL's query. A --*-file read error names the flag and not the path, and a stray positional is redacted again on a command with a secret --*-file flag. The argv guard adds otp, passwd, credentials and key, with pro setup --credentials exempted as a source selector. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(redact): redact erase positionals, presigned build errors, userinfo and plist char refs erase takes a Find My PIN in a body with no flag to name it, so a stray positional there now refuses with <redacted> through a jamf:secret-positional annotation. A JCDS request that fails to build wraps its *url.Error through withoutURLQuery as the transport failure already did. redact.URL masks a userinfo password via Redacted, and the plist pair rule accepts numeric character references ( , 
) between </key> and <string>. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(redact): redact a credential element whose value is in CDATA A Classic body writes a password holding & or < as a CDATA section, whose opening < the leaf text run stopped at, so -vvv, --dry-run and error bodies printed it in full. The element and the credential-container leaf now accept CDATA sections, across lines, inside the text run. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(mcp): hold the device-secret --*-file flags to --input-dir The merge of #402 brought a guard that every path-shaped flag is classified. --new-password-file, --pin-file and --unlock-token-file read a credential from a local file, as --password-file does, and are classified the same way. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(redact): format the CDATA cases with gofumpt Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(mcp): classify clear-passcode as setting a device secret This branch gave clear-passcode a Long that names the unlock token, so the guard from #402 requires the leaf to be classified. It sets a secret of the pinned tenant's devices, like set-recovery-lock. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(pro)!: refuse - on the device-secret --*-file flags The credential policy rules out reading a credential from stdin, and --unlock-token-file advertised - for it. --new-password-file, --pin-file, --unlock-token-file and set-auto-admin-password --password-file now refuse - with a usage error (exit 2) that asks for a file path. A process substitution such as <(printf '%s' "$TOKEN") still works, because it is a path. The stdin subtests of TestSecretFileFlags_SendTheFileContent and TestSecretFileFlags_RefuseAnEmptySource are removed with the author's approval, and TestSecretFileFlags_RefuseStdin pins the refusal for each flag. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
The /v2/groups lookup now reads the returned groupName back before it accepts a match, and the real endpoint always returns one. The fixture from #402 omitted it, so the group lookup refused the record. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…408) * test: pin name resolution to exactly one record Failing repros for five resolver defects: School device actions match a name before a serial and take the last duplicate; the Classic static-group fallback compares coerced names and runs after a smart-group ambiguity refusal; packages upload interpolates the filename into RSQL unescaped; Protect computers let a hostname shadow a serial; Protect applies create on any lookup error. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: resolve an identifier to exactly one record School device actions, Protect computers, the Pro static-group fallback and packages upload could act on a record other than the one named. A name was checked before a serial, duplicates resolved last-wins, the Classic group scan compared float-coerced names, and an unescaped RSQL filter let a file name select an unrelated package. Protect applies took the create branch on any lookup error. internal/pickone resolves an argument against ordered identifier tiers and refuses when the deciding tier matches more than one candidate. The School, Protect computer and Classic group resolvers use it. EscapeRSQL now escapes every RSQL metacharacter and is the single escaper. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(scope): resolve --name to exactly one record for every resource scope get/add/remove/set --name fetched through the Classic /name/ endpoint, which answers with one record when two share a name, so the scope was written to the server's pick. Every scopeable resource now lists the collection and resolves the name to one id, refusing a collision with the ids named, and then fetches and writes by id. TestFetchScope_ByNameUsesTheNameEndpoint asserted the one-request /name/ lookup; it now asserts the listing and the refusal (approved). Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(resolve): resolve flush-commands --group to exactly one group flush-commands --group took its id from the Classic /name/ endpoint, which answers with one group when two share a name, so the commands were flushed from the server's pick. The id now comes from the group collection under the same rule as the static-group member lookup: a unique exact name wins, then a unique case-insensitive one, and a shared name is refused with every id. The smart-group ambiguity error now names the colliding ids too. An assembled-root test drives computer-inventory erase, mobile-devices erase and both flush-commands --group against a loopback fake holding two groups per name, and asserts a refusal naming both ids with no write sent. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: resolve the remaining name lookups to exactly one record Review round 1 on this branch. - pro bulk send-command --group, add-to-group and remove-from-group resolve groups through the Classic collection picker instead of the first case-insensitive match. - Blueprint group scoping asks for two results, refuses a shared name with both ids, and accepts only a result carrying the exact name. - Every Protect name resolver, the analytics index and both YAML imports pick through pickone, so a shared name is refused with every id instead of the last record winning; absent still matches protect.ErrNotFound. - EscapeRSQL escapes only backslash and quote. A wildcard therefore reaches the server, so every lookup that acts on a result reads the requested value back off it: device serial, name, management ID and UDID, smart group name, blueprint group name and package file name. A truncated package match that holds no exact name is refused. - School device actions send an unlisted argument only when it has a UDID's form; the device ID lookup moves on to serial and name only on a 404; scope name lookup treats an error status as an error; the flush prompt names the resolved group and id. Tests drive the bulk commands and a unique-group control through the assembled root, all 13 Protect applies and 14 resolvers against 401, 403, 500, GraphQL-error, timeout and duplicate listings, and the backslash escape. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: read back the identity of four more lookups before acting Review round 2 on this branch. - pro setup accepts an API role or integration search result only when its displayName is the one searched for, so a filter that matched another record cannot have it updated or its credentials rotated. - set-auto-admin-password refuses a --user-name matching several LAPS accounts, and without one uses the single MDM-created account or refuses listing the accounts, instead of taking the first. - The device-identifier serial step reads hardware.serialNumber back. - protect restore picks insights by label through PickNamed, so a shared label is refused. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(device): resolve a serial or name past the server's INVALID_ID answer Jamf Pro answers a non-numeric id on /v4/computers-inventory-detail with 400 INVALID_ID, not 404, so `pro device <serial>` and `pro classic-computer-app-usage --serial/--name` stopped at the ID probe. The probe now runs only for a numeric identifier, and a 400 carrying INVALID_ID is read as "no device with that ID". 401, 403, 5xx and other 400s still stop resolution. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(resolve): request HARDWARE so a mobile --serial survives its read-back /v2/mobile-devices/detail answers "hardware": null unless HARDWARE is requested, and parseMobileDevice read serialNumber only at the top level, so the read-back refused every mobile --serial match ("whose serial number is \"\""). Both the single lookup and the --from-file batch now request section=GENERAL§ion=HARDWARE, and parseMobileDevice falls back to hardware.serialNumber and general.udid after the top-level fields. Co-Authored-By: Claude Opus 5.5 <[email protected]> * style(resolve): gofmt mobileEntrySpec Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(platform-devices): resolve a serial to exactly one device resolveDeviceIDDirect built the filter with Go %q quoting, left `*` a wildcard, and returned the first result, so `pro platform devices delete 'C02*' --yes` deleted whichever device the server listed first. The filter now uses resolve.EscapeRSQL, only a device whose serial equals the argument (case-insensitively) is accepted, zero or several such devices are refused, and the delete confirmation names the resolved device rather than echoing the typed argument. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(blueprints): read back a group name case-insensitively /v2/groups filters groupName case-insensitively, so `excluded` returns the group named `Excluded`, and the case-exact read-back reported "no group found". It now compares with EqualFold, like every other read-back; more than one match is still refused. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(resolve): give mobileV2Response the /v2/mobile-devices/detail shape The fixture carried the flat list-endpoint shape (top-level serialNumber, udid, displayName), so the serial read-back passed against a response the detail endpoint never sends. It now nests displayName, udid and managementId under general and serialNumber under hardware, which is what the detail endpoint answers when HARDWARE is requested. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(device): answer a non-numeric id with the server's 400 INVALID_ID Jamf Pro answers a non-numeric id on /v4/computers-inventory-detail with 400 INVALID_ID, not 404. The serial and name resolution tests mocked a 404, which hid that the ID probe stopped resolution for every serial and name. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(device): an all-digit identifier that is not an ID goes on to the serial and name searches client.Do returns an error for a 404 or 400, never a response, so the ID probe's not-found branches never ran and `pro device 99999` stopped at the probe. The probe now allows 404 and 400 so it can read them itself. deviceResolveMockClient now fails any status of 400 or above that the caller did not allow, the way client.Do does. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(resolve): a smart-group search that answers 404 falls back to the static group The smart-group search now allows a 404, so resolveGroupIDByName sees it and reports errGroupNotFound. Before, client.Do turned the 404 into its own error type and the fallback never ran. mockClient now fails any status of 400 or above that the caller did not allow, the way client.Do does. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(resolve): hold the exactly-one read-back and mobile ambiguity guards Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(packages): upload replaces a package whose stored file name differs only in case Jamf Pro matches fileName== case-insensitively and refuses a second package that differs only in case with 400 DUPLICATE_FIELD. Compare the returned name with strings.EqualFold, as main's lookup effectively did. Two records that share a name are still refused. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test: give the import-profile MCP fixture's platform group its groupName The /v2/groups lookup now reads the returned groupName back before it accepts a match, and the real endpoint always returns one. The fixture from #402 omitted it, so the group lookup refused the record. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
Why
jamf-cli mcp servelets a connected model run CLI commands throughrun_command. The operator pins the profile, instance, credentials and local destinations. The model is only meant to choose which command to run. Five findings showed that the model could get past that boundary:cfgalias, a leading flag (--no-color multi …) or a flag inside the path (pro -q backup) reachedmulti, the config writers and both backups. Verified end to end:cfg set-report-dirand--no-color config set-defaultran.pro diff --target <other-profile>used that profile's stored credentials and returned its script bodies to the model.--script-file,--from-fileand the other input-path flags read any local file. With-n, the body came back to the model with no login at all.--save-to,-Oand the Protect--outputflags wrote to any local path.pro jcds sync --dir X --deletedeleted every file in X that JCDS did not list, with no prompt.What changed
run_commandresolves the argv with cobra'sFind, on the treemcp serveis already running and under one lock. It collects every flag occurrence with pflagParseAll. Refusals are judged on the resolved command path and the resolved flag name, not on the raw tokens.multi,mcp,completion, theconfigwriters,config validate,doctor, everysetup, both backups, and both mounts ofjcds sync. Also refused are the commands that print a live access token:auth tokenunderplatform,proandprotect, andpro api-authentication token,oauth-tokenandkeep-alive.--save-to, a command's own--output,--report-dir, and--dirwhere it writes.--password-file, always.--from-file,--file,--script-file,--input, and the others) are refused unless the operator startsmcp serve --input-dir <dir>. With--input-dirset, a path is allowed only if it resolves inside that directory, afterEvalSymlinks. The directory-valued readers (pro diff, the Protect imports andprotect restore) apply the same check to each file they open.pro diff. Each side must be the pinned profile, or a directory inside--input-dir.config show. In an MCP child it prints<redacted>for the token, client ID and client secret.config list --statusprobes only the pinned profile.JAMF_CLI_MCP=1, the child runs the same refusal in rootPersistentPreRunE, before any auth-skip return. It compares againstJAMF_CLI_MCP_PROFILEandJAMF_CLI_MCP_INPUT_DIR.childEnvstrips both from the inherited environment.protect plans config-profile(outside MCP too). It now refuses a plan name that contains/or\, or is empty,.or... Such a name would place the file outside the working directory, and the error tells the user to pass-O. Ordinary names are unchanged.Breaking for MCP users
--input-dir. This includes creating a script from a file.pro diffwith a directory side now needs--input-dir.doctor,config validate,completionand the token-printing commands are no longer available throughrun_command.CHANGELOG.mdrecords all of this under## Unreleased, with--input-diras the migration.Verification
The first commit adds the repro tests alone, one or more per finding. On that commit they fail, with 8 failing tests. The later commits make them pass. No test that exists on
mainwas changed.Review ran in two rounds. The lanes were security (twice), silent failures and test quality, with mutation checks on each fix. The security lane reproduced its findings through a real
mcp servewith fake credentials.Residual gap. The child opens a path again after the check passes. So a symlink swapped in between the check and the open is not caught.
🤖 Generated with Claude Code