Conversation
Fails on purpose: run_command accepts the Classic account users and groups, the Classic LDAP servers, cloud LDAP and Azure identity providers, both SMTP server commands, SSO settings and certificate writes, and Platform SSO connections. Each grants a Jamf Pro login the model chose, which works outside the server. The walk covers every leaf under those resources, so a write a later spec adds fails here until it is refused or given a reason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
run_command refused `pro accounts create/update/apply` because they set a login password, and let through every other write that grants a Jamf Pro login with no password in the request. A model could create a Classic administrator, bind an account group to an LDAP server it runs, point SSO at its own identity provider, or route password-reset mail to its own SMTP server. Each login works outside the MCP server. Those writes are now refused with refusedChangesLoginAuthority: the Classic account users and groups, Classic LDAP servers, cloud LDAP and Azure identity providers, both SMTP server commands, SSO settings, certificate and OIDC broker writes, and Platform SSO connections. Reads, delete, history notes and connection tests still run. The eight notACredentialPrinter exemptions for leaves now refused are removed, since the classification test refuses an entry that is both. BREAKING CHANGE: over MCP, these commands now fail with a refusal. Outside MCP nothing changes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Fails on purpose: `protect users`, `groups` and `roles apply` create or change a Jamf Protect console login and its role, `school users apply` sends a login password, and `school groups apply` sets the group ACL that grants the teacher and parent app roles. Each runs over MCP when its file sits in --input-dir. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The login-authority refusal covered Jamf Pro and Platform SSO only. `protect users`, `groups` and `roles apply` create a Protect console login or change what its role may do, `school users apply` sets a Jamf School login password, and `school groups apply` sets the ACL that grants the teacher and parent app roles. Each ran over MCP when its body file sat inside --input-dir. refusedChangesLoginAuthority now names any Jamf product, and the help, tool description and changelog list the five commands. Co-Authored-By: Claude Opus 5.5 <[email protected]>
ktn-jamf
left a comment
There was a problem hiding this comment.
Warning
Blocking: (1), (2). Two nice-to-have suggestions sit in the collapsed section.
Rating: 3/5
- Would be a 5 with
protect restorerefused in (1) and the agent guide's refusal list brought current in (2).
Findings
internal/commands/mcp.go:690-692: protect restore writes Protect users, groups and roles and is not refused
protect restore (protect_backup.go, RunE near :1737) applies a backup directory roles, then groups, then users. Those three table entries (:584, :606, :645) carry no RestoreSkipReason. mcpRefusedCommands refuses protect backup (:705) and the three apply leaves, but refuseOverMCP (:878) matches only the path or a path plus a space, so jamf-cli protect restore passes. --input is a pathRead flag (:756), so restore runs from inside --input-dir. The PR body names that exact precondition as its reason to refuse apply. The walk cannot see it, because restore is a sibling of the listed resources.
Failure scenario: mcp serve --input-dir D, with a backup-shaped directory under D whose users/ document grants an admin role → the model runs protect restore --input D/bk --yes → a Protect console login or role is created or changed. That is the grant this PR refuses for protect users apply.
Suggested fix:
{"jamf-cli protect roles apply", refusedChangesLoginAuthority},
+ {"jamf-cli protect restore", refusedChangesLoginAuthority},Add "protect restore" to loginAuthorityResources, and name it in the mcp serve help, the run_command description and the CHANGELOG entry.
Fixed when: buildChildArgs("prod", []string{"protect", "restore", "--input", <dir>}) returns an MCP refusal naming "who can log in", and dropping the entry fails TestMCP_RefusesEveryWriteThatChangesWhoCanLogIn.
internal/commands/agent_context.md:106-122: the agent guide's MCP refusal list omits every refusal this PR adds
jamf-cli agent-context prints this file to agents. Its MCP section enumerates what run_command rejects. It names "the commands that set a Jamf Pro login password" and stops there. None of the refusedChangesLoginAuthority paths appear in it. #402 updated this paragraph when it added the pro accounts refusals. agent_context.go:12-19 makes this sync the contributor's job, backed by the documentation-currency check of PR review.
Failure scenario: an agent reads agent-context and plans a run that calls pro classic-account-users create over MCP, which the list implies is allowed → the call is refused, and the guide contradicts the server it describes.
Suggested fix: after the pro accounts create, update, apply clause, add a clause matching the mcp serve help: "the writes that change who can log in to a Jamf product (create, update and apply on the Classic account users, account groups and LDAP servers, cloud-ldap and cloud-azure; the two SMTP updates; the SSO settings, cert and OIDC broker writes; platform sso-connections writes; apply on Protect users, groups and roles and School users and groups)".
Fixed when: every resource the mcp serve help lists under the login-authority bullet also appears in agent_context.md's MCP section.
Nice-to-have suggestions (2 items)
💡 NICE-TO-HAVE (3) (test-coverage, via test-quality-reviewer+devil-advocate) — internal/commands/mcp.go:117-126, :264-272: the help and the run_command description restate the list by hand, and no test ties them to mcpRefusedCommands
Deleting the Protect and School clause from the tool description left every TestMCP* test green. Both texts also omit the sso-settings-cert spelling that the CHANGELOG names.
Fixed when: removing any refusedChangesLoginAuthority resource from either text fails a test, modelled on mcp_payload_policy_text_test.go.
💡 NICE-TO-HAVE (4) (test-coverage, via security-reviewer+devil-advocate+test-quality-reviewer) — internal/commands/mcp_login_authority_test.go:17-36: the resource list omits pro oidc and the read-only login resources
pro oidc generate-certificate carries Update SSO Settings (pro/generated/oidc.go:137), the privilege the refused sso-settings disable carries, and runs unclassified. pro account-groups, classic-accounts, classic-ldap and ldap are absent too, so a write a later spec adds under them passes the walk.
Fixed when: those resources are listed, and generate-certificate is refused or allowed with a reason.
This covers all findings — addressing the above gets this PR to merge-ready.
Review coverage and scope
- Design and architecture: per-path deny list plus walk; sibling write path is (1)
- Correctness:
refuseOverMCPprefix match, alias resolution via cobraFind - Security: whole command tree swept for login-granting writes; gap is (1)
- Test coverage: walk mutation-checked, 3 of 3 killed; text sync is (3)
- Documentation currency: help, tool description, CHANGELOG current;
agent_context.mdis (2) - Project rules compliance:
coding-style.md,credentials-and-auth.md,classic-api.md - [na] Performance, reliability, simplification, frontend.
Diff: 4 files, +237/−19, head bbe51467, plus protect_backup.go, agent_context.md, pro/generated/oidc.go, the built command tree. Tier: high by edit shape (guard list). Lanes run: security-reviewer, test-quality-reviewer, devil-advocate. Eligible, not dispatched under the high-risk cap of 3: usability-reviewer, silent-failure-hunter. Never run: fidelity-reviewer skipped (no spec), performance-reviewer inapplicable, scope-reviewer inapplicable. Conventions: 1 root CLAUDE.md, 3 rules, from the worktree; no nested CLAUDE.md (walked root → internal/commands/).
What's done well
✅ The walk fails in both directions: on a write with no reason, and on a stale or contradicting allowance. Mutation checks confirm it catches a dropped refusal, including cloud-ldap update-mappings, which no argv case covers.
🤖 Generated by the pr-review:review skill v1.43.0 · reviewed head bbe51467
…usal list protect restore applies a backup directory's roles, groups and users, the same writes refused as protect users/groups/roles apply, and it ran over MCP from inside --input-dir. It is now refused whole, as protect backup is, and the login-authority walk covers it. agent_context.md's MCP section now lists the login-authority refusals, and TestMCPPolicyTexts_NameEveryLoginAuthorityRefusal requires it, the mcp serve help and the run_command description to name every refusedChangesLoginAuthority resource. The walk also covers pro oidc and the read-only login resources, with generate-certificate allowed with its reason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
ktn-jamf
left a comment
There was a problem hiding this comment.
Tip
✅ Merge-ready. Refuses over MCP the writes that change who can log in to a Jamf product, protect restore now included, and holds the help, tool description and agent guide to the refusal list. No critical or important issues found.
See the collapsed section for 2 nice-to-have suggestions.
Rating: 5/5
- Round 1's blocking items (1) and (2) are fixed, and verified against a live MCP probe. The 2 nice-to-have suggestions below are optional and do not hold the merge.
Prior findings status
| # | Location | Rounds | State | Notes |
|---|---|---|---|---|
| (1) | internal/commands/mcp.go:701 |
1 | ✅ Fixed | protect restore refused. The walk and an argv case hold it, and a dropped entry fails the walk |
| (2) | internal/commands/agent_context.md:118 |
1 | ✅ Fixed | MCP section lists every login-authority refusal and is held by the new sync test |
| (3) | internal/commands/mcp.go:264 |
1 | Sync test added. Its matcher misses one shape, now (1) below | |
| (4) | internal/commands/mcp_login_authority_test.go:17 |
1 | ✅ Fixed | Walk covers pro oidc, the read-only login resources and protect restore |
Nice-to-have suggestions (2 items)
💡 NICE-TO-HAVE (1) (test-coverage, via test-quality-reviewer+security-reviewer) — internal/commands/mcp_login_authority_test.go:194: the sync matcher makes the product optional, so 'groups' in the School clause satisfies protect groups
Dropping 'groups' from the Protect clause of both the help and loginAuthorityToolNote left TestMCPPolicyTexts_NameEveryLoginAuthorityRefusal green. protect roles has the same shape.
Fixed when: removing a resource from one product's clause fails the test while the other product's clause stays.
💡 NICE-TO-HAVE (2) (documentation, via devil-advocate) — internal/commands/mcp.go:126-127, CHANGELOG.md:38-40: both say protect restore "applies a backup's roles, groups and users"
Restore replays the whole backup, plans and analytics included (protect_backup.go restore Long), and the refusal blocks all of it, the dry run included. The CHANGELOG's reason should name the missing guard on --resources/--exclude, which is the real reason, rather than the model choosing them.
Fixed when: both texts say restore is refused whole, -n included.
Review coverage and scope
- Design and architecture: whole-command
protect restorerefusal matchesprotect backup - Correctness:
refuseOverMCPprefix match on resolved path; alias handling unchanged - Security: Protect and School trees swept for other restore or import paths; none found
- Test coverage: dropping restore entry kills the walk; matcher gap is (1)
- Documentation currency: help, tool note, agent guide, CHANGELOG; wording is (2)
- Project rules compliance:
coding-style.md,credentials-and-auth.md,classic-api.md - [na] Performance, reliability, simplification, frontend.
Diff: incremental 5 files, +94/−14 since bbe51467, head 2e4c6a69, plus protect_backup.go, skills/skills/jamf-migrate/SKILL.md, specs/JamfProAPI.yaml. PR description edited this round: removed derived counts and restated the restore bullet. Lanes run: security-reviewer, test-quality-reviewer, devil-advocate (re-spawned, paths changed; first dispatch lost to a session limit, retried once). Eligible, not dispatched under the high-risk cap of 3: usability-reviewer, silent-failure-hunter. Never run: fidelity-reviewer skipped (no spec), performance-reviewer inapplicable, scope-reviewer inapplicable. Conventions: 1 root CLAUDE.md, 3 rules, from the worktree; no nested CLAUDE.md (walked root → internal/commands/).
What's done well
✅ The run_command description now reads the refusal list from one constant that a test checks, so the model's copy of the list can no longer drift from the one in the code.
🤖 Generated by the pr-review:review skill v1.43.0 · reviewed head 2e4c6a69
Summary
MCP
run_commandrefusedpro accounts create,updateandapplybecause they set a login password. Other writes grant a login with no password in the request, and those still ran. A prompt-injected model could do any of these:Each of these gives a login that works outside the MCP server. This PR closes two security-scan findings: "MCP refuses pro accounts but not Classic account twins that mint a Jamf Pro admin login" and "MCP allows sso-settings update to point Jamf Pro SAML login at a model-chosen IdP" (both CWE-863). It also closes the Protect and Jamf School gap of the same class. A background security review of the first fix commit flagged that gap as an incomplete deny-list.
Change
A new refusal reason,
refusedChangesLoginAuthority, covers these writes:create,updateand, where it exists,applyonpro classic-account-users,classic-account-groups,classic-ldap-servers,cloud-ldapandcloud-azure, pluspro cloud-ldap update-mappings.pro classic-smtp-server updateandpro smtp-server update.pro sso-settings update,disable,cert create,cert updateandoidc-broker-config update, plus thesso-settings-certspelling.platform sso-connections createandupdate.applyonprotect users,groupsandroles, and onschool usersandgroups. These read their body only from a file, so they ran only with that file inside--input-dir.protect restore, which replays a Protect backup, its roles, groups and users included. It is refused whole,-nincluded, because no guard reads its--resourcesand--excludevalues.Reads,
delete, history notes and connection tests on these resources still run. That matchespro accounts delete, which was not refused before either. Themcp servehelp, therun_commandtool description, the MCP section ofagent_context.md(printed byjamf-cli agent-context) andCHANGELOG.mdlist the new refusals.TestMCP_RefusesEveryWriteThatChangesWhoCanLogInwalks every leaf under these resources. It fails on any leaf that runs over MCP without a named reason. So a write that a later spec adds fails CI until someone classifies it. It also fails on a stale allowance.TestMCP_RefusesCommandsThatPrintOrSetALoginCredentialnow holds the exact argv from both findings, including an alias and a leading flag.The walk also covers
pro oidcand the read-only login resources (pro account-groups,classic-accounts,classic-ldap,ldap), so a write a later spec adds under them fails CI too.pro oidc generate-certificatestays allowed, with its reason recorded: it replaces the keystore Jamf Pro signs its own OIDC messages with, generated server-side and never returned.TestMCPPolicyTexts_NameEveryLoginAuthorityRefusalrequires themcp servehelp, therun_commanddescription andagent_context.mdto name the resource of everyrefusedChangesLoginAuthorityentry.Existing tests changed
notACredentialPrinterinmcp_secret_leaves_test.go. I removed the exemptions forpro sso-settings update,pro smtp-server update,pro cloud-ldap update, both SSO cert updates,oidc-broker-config update,platform sso-connections createandupdate, andprotect restore. These commands are now refused, and the classification test rejects an entry that is both refused and exempt.Verification
main.go test ./... -count=1passes, andgolangci-lint run ./internal/commands/...reports 0 issues.mcp serveover stdio JSON-RPC against fake profiles that pointed athttps://127.0.0.1:1. Every attack argv from the findings was refused, including-q pro sso-settings-cert updateand-q platform ssoc update 1. The Protect and Schoolapplywrites were refused too, with their body files inside--input-dir.protect restore --input <dir> --yes, with and without--resources users,roles,groupsand with-n, was refused too; the build before that fix let it through toresolving role "Full Admin". The read controls (classic-account-users list,sso-settings get,platform sso-connections list,protect users list) passed the guard and stopped only at the refused connection.Not in scope
platform sso-domains createandverifystill run. A domain grants a login only through an SSO connection, and SSO connections are now refused.pro api-rolesandpro api-integrationswrites still run. They change what an API client may do, but the model cannot get a client's secret, becauseclient-credentialsis refused. That reason does not cover one case:pro api-roles updatecan raise the privileges of a role that an existing client already uses, the server's own client included. A refusedclassic-account-groups updatedoes the same for an account group. Whether API-client authority belongs in this class is left open for a follow-up.🤖 Generated with Claude Code