feat(protect): backup, restore, and the tenant overlay on Jamf-managed analytics - #330
Conversation
Jamf publishes analytics centrally. A stock tenant returns ~156 of them, all
flagged `jamf: true`, and the server refuses `updateAnalytic` against any:
This mutation may only be used for custom analytics.
What a tenant can change is an overlay — the severity it reports at and the
actions it triggers — written by a separate `updateInternalAnalytic` mutation.
The SDK has exposed `UpdateInternalAnalytic` since v0.8.0; nothing in the CLI
called it, so that overlay was neither readable in a useful shape nor writable
at all.
That mattered more than it looked. Because the definitions are identical in
every tenant, the overlay is the *only* part of a Jamf-managed analytic worth
capturing — and it was the part the CLI dropped. `analytics list` reports
Jamf's baseline severity, so an analytic a customer had downgraded to Low still
displayed as High, and `analytics export` emitted the community-schema
definition with `tenantSeverity`/`tenantActions` discarded entirely.
Adds `protect analytics overrides` with list/get/set/clear/export/apply.
`export` keys entries by analytic name rather than UUID, for the same reason
analytic sets and plans export member names: it is what lets the document
carry to another tenant. `apply` matches by name and skips entries that are
absent or custom instead of aborting a 156-entry document partway.
`apply` treats an absent half as "no override" and clears it, which is what
makes replaying the same document idempotent; `set` instead leaves an omitted
half untouched, for partial edits. The SDK's TenantSeverityNull/
TenantActionsNull tri-state is what allows both to be expressed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`analytics export` emits the community YAML schema — the one the
jamf/jamfprotect repo publishes and `analytics import` consumes. `analytics
apply` decoded straight into the SDK's AnalyticInput. The two disagree
irreconcilably on one field: community `actions` is a list of
{name, parameters} objects, while AnalyticInput.Actions is a list of strings
and keeps its objects under `analyticActions`. Both the JSON and the YAML
decode therefore failed, and the pipe documented in CLAUDE.md —
jamf-cli protect analytics export X | jamf-cli protect analytics apply
— died with "input is not valid JSON or YAML" for every analytic. Analytics
were the only resource where that documented pattern did not hold.
apply now sniffs which schema it was handed and converts, so both the export
output and `apply --scaffold` output are accepted. Detection compares keys
lowercased: the SDK input struct carries no json/yaml tags, so as JSON its keys
are Go field names ("AnalyticActions") while yaml.v3 lowercases them.
Two related losses fixed while here:
analyticYAMLToInput dropped longDescription and remediation, both of which
analyticToYAML emits — so even the working export/import round-trip silently
discarded them. Verified lossless against a live tenant now.
apply against a Jamf-managed analytic spent a call to be told "This mutation
may only be used for custom analytics", a server message that names no way
forward. It now fails up front pointing at `analytics overrides set`, which is
the operation that actually changes what a Jamf-managed analytic does. Getting
the analytic rather than just its UUID also drops apply from two list calls to
one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Their exports emitted the server's own identifiers — RoleIDs, GroupIDs,
ConnectionID. Role IDs are small sequential integers, so a document exported
from one tenant and applied to another did not fail: "2" resolved to whatever
role happened to hold that integer in the target. The wrong grant, silently,
with no error to notice.
Every reference now exports by name, the way analytic sets, ULF sets and plans
already did, and apply resolves names against the target tenant. An absent name
is a hard error naming the role, so the failure is loud instead of silent:
resolving role "No Such Role": role "No Such Role" not found;
use 'protect roles list' to see available names
ID-shaped documents are still accepted on input, detected by the presence of a
roleids/groupids/connectionid key, so files written before this change keep
applying and pass their IDs through untouched rather than being read as names.
Adds Resolver.ResolveConnectionID for the identity provider reference, caching
per resource type like its siblings. The API client export gains an explicit
guarantee that it carries no secret: the server returns one only at creation,
so it could never have round-tripped, and a test now asserts the rendered
document contains neither the password nor the client ID.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jamf Pro has had `backup` and `diff` for a while; Protect had neither, and no
product in this repo had a restore. Cloning or rebuilding a Protect tenant meant
a hand-rolled shell loop over fifteen `export` commands in the right order.
`protect backup` writes every object to its own file under a per-resource
directory, in the portable form the matching `export` already produces. `protect
restore` walks that directory in dependency order — members before the sets that
name them, every set before the plans that bind them, roles before groups before
users — because each reference resolves by name against the target as it is
applied. Existing objects are updated, absent ones created; nothing is deleted.
The ordering is the correctness argument, so a test asserts the chain rather than
the numbers, and every resource must either be restorable or carry a reason it
is not (a nil handler with no reason would silently skip at restore time).
What is deliberately not replayed:
- Jamf-managed analytics, analytic sets and exception sets. Published
centrally, identical in every tenant, and the server refuses to write them.
What a tenant changed about them travels as analytic-overrides instead.
- Tenant defaults — the built-in roles, the Default group, the Default
Analytic Set. Skipping them is safe precisely because references resolve by
name: a restored group naming "Full Admin" binds to the target's own copy.
--include-defaults overrides.
- API clients, because the server issues a new secret on create and never
returns the existing one, so a restored client cannot reuse its credentials.
- Data forwarding, whose response is not its update shape and carries
third-party credentials the API never returns.
Both commands take --resources (allowlist) and --exclude (denylist), which
compose; restore also takes --dry-run. Selecting nothing is an error rather than
a silent no-op run, and empty resources are reported as 0 rather than omitted,
because a missing line reads as "not checked" when it means "nothing there".
Three fidelity bugs found by running it against two real tenants:
- Action config export was unrestorable. params is read back as an object but
the input schema declares it AWSJSON!, a JSON-encoded string; the object was
refused, and so was dropping it. The response also populates every member of
the ReportClientParams union at its zero value, so a JamfCloud client
carried an empty host, scheme and port 0 — and batchConfig.sizeInBytes came
back 0 against a documented minimum of 1000.
- Data retention exported the response shape, which is nested, while the
update input is flat. Replaying it sent zeros, which the server rejects as
"not one of [30, 60, 90, 180, 365]".
- Plan set membership exported in server order, so two identical plans diffed.
Now sorted, which is what makes a backup diffable across runs and tenants.
Verified end to end between two tenants: every restored object re-exports byte
identical to its source. The only remaining difference is commsConfig.fqdn, the
region-assigned IoT endpoint, where the target correctly keeps its own value —
left in place rather than stripped, since fqdn is String! inside CommsConfigInput
and dropping it would mean dropping protocol with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user may belong to groups without holding any direct role, and a group or API
client may carry no roles at all. The name-based converters left those slices
nil, which marshals as null, and the API rejects it:
input → roleIds: None is not of type 'array'
So creating a groups-only user failed outright. The lists are now initialised
empty so they marshal as [].
Found by cloning a synthetic role/group/user graph between two live tenants,
which also confirmed what the name-based references buy: source role ids
2346/2347 and group ids 2311/2312 came out as 537/538 and 567/568 in the target,
with every user and group binding pointing at the target's own ids. The same run
showed why raw ids were dangerous — id 2311 is a role in the source tenant and a
group in the target.
Also aligns the backup writer to printExport's two-space YAML indent, so a backup
file and the output of the matching `export` command are byte-identical for the
same object and can be diffed against each other.
CLAUDE.md records the wire facts this exercise established, none of them
derivable from the schema: Jamf-published analytic UUIDs are stable across
tenants while custom ones are not, identity provider connection names are
tenant-specific, USB vendor/product ids need an 0x prefix, commsConfig is always
sent so its protocol must be valid, and data retention updates are rate-limited
to once per 24 hours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two resources export through a hand-written struct rather than the SDK input
type, and both had drifted from it, so a backup omitted fields the API happily
accepts and a restore left the target's own values in place:
- plans lost threatPreventionStrategy (LEGACY / MANAGED / CUSTOM_ENGINES) and
customEngineConfig, so a plan's threat prevention posture did not travel.
- analytics lost startup, label and matchReason. Every Jamf-published analytic
carries a label, so every analytic export had been losing one.
The other thirteen resources export the SDK input type directly, where coverage
is complete by construction — which is why none of them had this bug, and why
the two hand-written shapes both did. Tests now pin the fields, and the analytic
extras are omitempty so a plain analytic still renders byte-identically to a
community-schema file.
Adds docs/solutions/logic-errors/response-shape-is-not-input-shape: six bugs
across four commits shared one root cause — assuming the shape you read is the
shape you can write — and none were caught by unit tests, because both
directions of a converter tend to be wrong in the same way. The check that finds
them is backup A, restore into B, backup B, diff.
Also records two server behaviours found while exercising this: telemetry
`events` is an undeclared allowlist where only `network_connect` of the values
tried is accepted, and `accessGroup: true` on a connection-less local group is
accepted by createGroup but refused by updateGroup, which makes a re-run of an
otherwise complete restore report a failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three resources were missing from backup/restore entirely, all of them tenant
configuration rather than runtime state:
- insights — the compliance catalogue is Jamf-published, so only the
enabled/disabled split is a tenant's own data. The document records both
lists explicitly rather than treating absent as disabled, and restore flips
the target's own copies to match.
- config-freeze — a single boolean.
- connections — identity provider connections have no create mutation, so they
are captured for reference and never replayed. Worth capturing because
connection names are tenant-specific, and a user or group naming one restores
only where that name also exists.
Both writable ones compare before writing. That is not an optimisation: the API
refuses a write of the value already held — disabling a freeze that is not on
answers "Tenant '...' is not in a change freeze" — so comparing first is what
makes replaying a backup idempotent. It also keeps a several-hundred-entry
insight catalogue from becoming several hundred mutations against a live tenant.
Verified against two tenants: 42 documents applied, 0 failed, and re-running is
clean. Of the objects a restore is expected to reproduce, 37 re-export byte
identical and 4 differ only in commsConfig.fqdn, the region-assigned endpoint the
target correctly keeps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both skills opened by declaring themselves a "Jamf Pro assistant" and every command example was `jamf-cli pro ...`, so an agent following either would not discover that Protect has its own backup, or that it has the restore Pro lacks. The asymmetry is the part worth stating plainly, and both skills now lead with it: Pro has backup and diff but no restore, so promotion is object by object; Protect has backup and restore but no diff, so comparison is `diff -r` over the directories. Each skill now asks which product before running anything, rather than assuming. The Protect migration workflow tells the agent to dry-run first and show that output verbatim as the plan, instead of hand-building one — the dry run already prints every document in apply order plus every skip and its reason. It also records the operational facts an agent would otherwise learn by breaking something: configure identity provider connections in the target first because connection names are tenant-specific; restoring an alert-enabled user points a tenant's alert email at a real person; per-object control is deleting files from the backup directory; and three failures that do not mean the migration is broken (the data retention 24h limit, accessGroup on a connection-less group, and commsConfig.fqdn always differing). `jamf-cli`'s own skill needs no change — it loads the CLI reference dynamically rather than hardcoding a command list. go fix reports nothing to apply: its only candidate is omitempty → omitzero, which it declines as a behaviour change, and which would be wrong here anyway since yaml.v3 does not support omitzero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bility Found reviewing #330 and confirmed against a live tenant. Credentials no longer land in a world-readable file. An HTTP action config's params carry its request headers and the SDK's query selects `headers { header value }` in full, so a bearer token is captured verbatim — and the jamf-backup skill tells users to git-init the backup directory. Resources that can hold a third-party credential now declare a SensitiveReason: those files are written 0600 and named in a warning before the operator commits the tree. The header values cannot be redacted because restore needs them, but the legacy ForwardSentinel.sharedKey can be and is, since data forwarding is never replayed. SentinelV2 got this right already and reports secretExists instead. Backup exits non-zero when a resource failed, with --allow-partial-failure to downgrade, matching what `pro backup` has always done. A backup that exits 0 with a resource missing is indistinguishable from a good one to the job that scheduled it, which is the whole point of having an exit code. Object names no longer overwrite each other. protectFileNameSafe is lossy in two directions — runs of illegal characters collapse to one "_", and a case-insensitive filesystem folds case — so "Alert: High" and "Alert/High" resolved to one file while the count reported two. protectNameAllocator appends a discriminator derived from the object's own name, so a backup directory under version control still diffs cleanly. Exception sets export their target analytic by name and rebind it on restore. Custom analytics get per-tenant UUIDs, so the uuid alone was portable only for Jamf-published ones. Probing established that the server rejects a foreign uuid rather than accepting it: two documents differing only in that field, the real uuid created the set and the foreign one answered "Action blocked due to dependencies on this resource" — an error naming neither the analytic, nor the uuid, nor the reason. So this was an unactionable restore failure, not a silently dead exception. exception-sets moves to Order 20 accordingly, since a name only resolves once the analytic exists in the target. The document is key-compatible in both directions: YAML export is byte-identical to before, and JSON top-level keys go PascalCase to lowerCamel (the SDK input type carried no json tags) which encoding/json matches case-insensitively. Tests pin both legacy shapes. Verified on iacsandbox: a name-only document with no uuid resolved on the wire, export | apply round-trips byte-identical, only the two sensitive resources are 0600, and the restore order is correct. Probe fixtures removed and the tenant returned to its prior state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le set exports Follow-up to the review of #330, covering the nice-to-haves and two defects the wire probe turned up. Only "absent" may become a create. The resolver now returns a protect.ErrNotFound sentinel across all seventeen lookups — message text unchanged, carried by a small error type with an Is method — and upsertByName checks it with errors.Is. A transient list failure, an expired token or a permission problem used to be read as "not there, so create it", which mutated the tenant a different way than the backup described and surfaced the resulting duplicate-name error instead of the real cause. The single-object apply commands still take the old path; converting those is separate work. data-retention compares before writing, the way config-freeze and insights already do. Retention updates are rate-limited to once per 24 hours, so an unconditional write made every re-run report a failure for a resource already in the desired state. Confirmed live: back-to-back restores now both report "already matches" and exit 0. overrides apply reports and continues instead of abandoning the document at the first refusal, so the summary always says how many landed and a retry is safe to reason about. Exits non-zero if any failed. Matches protect restore. An absent `startup` stays absent. analyticYAML.Startup is a pointer, so a community file from jamf/jamfprotect that declares no startup key no longer has an explicit false forced onto the wire over whatever the server defaults to. An explicit false is still sent. Export is unchanged — startup is false on every analytic in the tenant, so omitempty keeps exports byte-identical to the community files. Two found by pointing each export at its own apply: unified-logging-filters apply could not consume unified-logging-filters export. ulfToYAML writes the community schema, whose predicate key is `predicate`, while the SDK input calls the same field `Filter`; apply decoded the SDK shape directly, so the predicate never bound and the server refused every filter in every tenant with "input → filter: '' should be non-empty". This is a seventh instance of the bug class this branch already documented, in the resource next to the one it fixed — backup/restore were unaffected because they go through the YAML converters. ulfInputFromDocument sniffs the shape, mirroring analyticInputFromDocument, and the postmortem records the probe that found it. analytic-sets and unified-logging-filter-sets exported membership in the server's order, which rotates after a rewrite, so two backups of an unchanged set diffed by their entire membership — sixty lines of churn for the Default Analytic Set, in a directory the workflow keeps under git. Both sort now, as planToExport already did for its three lists. Verified on iacsandbox: every export | apply pipe across thirteen resources round-trips, a full self-restore applies 14 documents with 0 failures and leaves the tenant byte-identical, and self-applying both set types produces no diff. All probe fixtures removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
neilmartin83
left a comment
There was a problem hiding this comment.
Scope of review
PR #330 — protect backup/restore, protect analytics overrides, six export/apply fidelity fixes. 3464/-91 across 24 files. High risk (>500 lines, new source files, changes RBAC reference handling, adds credential-bearing on-disk artefacts).
Reviewed against the worktree at 1cd63c9. Verified the load-bearing wire claims independently against jamfprotect-go-sdk@v0.8.0's schema/schema.graphql and query text rather than taking the PR body at its word. go build ./... and go test ./internal/commands/ ./internal/protect/ both pass locally. No Jira key in title, description, or branch name. No prior reviews.
Specialist agents were not dispatched (session policy); their lanes — silent-failure, security, test quality — were covered inline.
Prior review status
No prior reviews, review comments, or issue comments on this PR.
This is unusually good work. The wire-fact documentation, the Order-chain test that asserts the dependency relation rather than the numbers, and the docs/solutions postmortem are all things most PRs of this size skip. Every one of the six claimed bug fixes has a test that would catch a regression, and I confirmed the updateInternalAnalytic semantics the design rests on: buildInternalAnalyticVariables omits unset fields and the mutation passes them as GraphQL variables into the input object, so "an omitted flag leaves that half untouched" is genuinely true.
The findings below are about what happens at the edges of the new bulk paths — where a single-object export becomes a 15-resource sweep to disk.
Findings
🟧 (1) protect backup writes third-party bearer tokens to plaintext files, in a directory the skill tells users to git-init — (security, confidence: high)
internal/commands/protect_action_configs.go:211 · internal/commands/protect_backup.go:437
actionConfigToInput re-encodes each client's params blob verbatim into the exported document. For an HTTP report client that blob contains headers, and I confirmed the SDK's query selects them in full:
params {
... on HttpClientParams {
headers { header value }
method
url
}pruneEmptyValues does not recurse into []any, so Authorization: Bearer … survives byte-for-byte into action-configs/<name>.yaml at mode 0o644. The same applies to data-forwarding.yaml: ForwardSentinel.sharedKey is a plain String in the schema (only ForwardSentinelV2 uses the secretExists: Boolean pattern) and data_forwarding.go:30 selects it.
Two things make this a new exposure rather than a pre-existing one. protect backup sweeps every action config to disk in one unattended command, where action-configs export was a deliberate per-object act. And skills/skills/jamf-backup/SKILL.md — modified in this same PR — carries "Offer git initialization for backup directories to enable version tracking", so the default guided workflow commits those files to a repo.
Suggested fix: redact secret-shaped values on export — HTTP header values and sharedKey — writing a sentinel ("<redacted>") and reporting it, the way apiClientExport already deliberately drops the client secret. If redaction breaks the restore path for those resources, prefer 0o600 on any file that can carry one plus an explicit warning line, and add a sentence to the skill telling users to --exclude action-configs,data-forwarding before git-initialising.
Acceptance criteria: a backup of a tenant with an HTTP action config carrying an Authorization header contains no bearer token in cleartext, or the command says on stderr that it just wrote one and where.
🟧 (2) protect backup exits 0 on partial failure, unlike pro backup — (correctness, confidence: high)
internal/commands/protect_backup.go:497-504
if len(failures) > 0 {
...
fmt.Fprintf(os.Stderr, "\nCompleted with %d failure(s) — see %s%s\n", ...)
return nil
}pro backup does the opposite for the identical condition (pro_backup.go:356-362): it returns exitcode.PartialOrPropagate(...) and requires an explicit --allow-partial-failure to downgrade to success. protect backup has no such flag and no way to get a non-zero code.
The consequence is concrete rather than stylistic. A scheduled protect backup && git commit sees exit 0 and commits a backup that is silently missing a resource; the failure is only visible to whoever reads stderr, which in CI nobody does. The command's own Long text says it mirrors the Pro layout — _meta/_failures filenames were chosen for exactly that — so the divergence is in the one place a script can actually observe.
Suggested fix: mirror pro_backup.go: return exitcode.PartialOrPropagate(total, len(failures), nil, msg) and add --allow-partial-failure with the same semantics and help string.
Acceptance criteria: a test where one resource's Export returns an error asserts a non-zero exit, and asserts --allow-partial-failure downgrades it to nil.
🟧 (3) File-name collisions silently drop objects while the count still reports them — (correctness, confidence: high)
internal/commands/protect_backup.go:400-411, protectFileNameSafe at :738
protectFileNameSafe collapses every run of non-[\w.@-] to a single _. Nothing tracks which safe names have been used:
path := filepath.Join(dir, protectFileNameSafe(e.Name)+ext)
if err := os.WriteFile(path, data, 0o644); err != nil { ... }
written++os.WriteFile truncates, so two objects whose names normalise identically leave one file — while written increments twice, so _meta.yaml reports 2 and the summary line reports a count the directory does not contain. Protect names are free text, and the function's own comment says they "routinely contain slashes and colons", so Alert: High and Alert - High, or Test/1 and Test 1, both collide. On macOS the default APFS volume is case-insensitive, which extends this to any pair differing only in case — including two user documents keyed on emails like A@example.com and a@example.com.
A silent object loss in a backup tool is the failure mode that matters most, because it is only discovered at restore.
Suggested fix: keep a map[string]bool of safe names per resource directory; on collision, append a short deterministic discriminator (e.g. the first 8 chars of a SHA-256 of the original name) so both objects survive and the mapping stays stable across runs. Lowercase the key when checking so case-insensitive filesystems are covered.
Acceptance criteria: TestProtectFileNameSafe (or a new sibling) covers {"Alert: High", "Alert - High"} and {"MyPlan", "myplan"} producing two distinct paths, and counts equals the number of files actually on disk.
🟧 (4) Exception sets export a raw analyticUuid, so a cross-tenant restore fails unactionably — (correctness, confidence: high — wire-verified)
internal/commands/protect_backup.go (exception-sets, Order 10) → protect_exception_sets.go:183-185
backup's help promises "cross-resource references are names, not IDs, so a backup can be restored into a different tenant". One reference escapes that: exceptionToInput copies e.Analytic.UUID through unchanged, and rebuildExceptionSetInput is what the backup writes.
The PR knows this — CLAUDE.md now states an exception set's analyticUuid "is portable when it points at a Jamf analytic and broken when it points at a custom one" — but nothing in the code acts on it.
Corrected after wire-probing (iacsandbox): the server does not silently accept a foreign UUID. Applying two documents differing only in that one field, the real UUID created the set and the foreign one was refused with:
createExceptionSet: jamfprotect: graphql error: Action blocked due to dependencies on this resource.
So the failure mode is a loud but unactionable one, not a dangling reference: a cross-tenant restore of an exception set targeting a custom analytic fails, naming neither the analytic, nor the UUID, nor the reason. Still worth fixing — an operator has nothing to act on — but it does not silently misconfigure suppression, and severity rests on the failed restore rather than on a security hole.
Everything needed to fix it is already in hand: analytics is exported at the same Order and custom analytics are exported by name, so the resolver can rebind.
Suggested fix: carry the analytic name in the exception document alongside (or instead of) the UUID and resolve it against the target at restore, matching what analyticSetExportToInput and planExportToInput already do. If resolution by name is out of scope for this PR, at minimum have the exception-sets export warn per-object when an exception names an analytic that is not Jamf-managed, so the operator learns at backup time rather than never.
Acceptance criteria: a restore of an exception set whose exception targets a custom analytic either binds to the target tenant's UUID for that analytic, or reports the unresolvable reference on stderr.
🟩 (5) data-retention restore skips the compare-before-write the other two settings do — (correctness, confidence: high)
internal/commands/protect_backup.go (data-retention, Order 70)
config-freeze and insights both read current state and write only real differences, and CLAUDE.md explains why that is correctness rather than optimisation. data-retention writes unconditionally, and the PR itself documents the outcome: "re-running a restore reports that resource as failed even though the desired state is already applied." GetDataRetention is already in the interface and dataRetentionToInput already normalises the response — comparing the two structs is a three-line change that makes replay idempotent and removes a spurious failure from every re-run inside 24 hours.
🟩 (6) upsertByName treats every resolve error as "absent" — (reliability, confidence: high)
internal/commands/protect_backup.go:88-105
id, err := resolve(ctx, name)
if err != nil {
if _, err := create(ctx, input); err != nil { ... }The resolvers return an indistinguishable error for "not found" and for "the list call failed" (resolve.go:363: fmt.Errorf("listing connections: %w", err)). A transient network blip or an expired token during a restore therefore converts an update into a create, and the operator sees whatever confusing duplicate-name error the server returns instead of the real cause.
This generalises a pattern the single-object apply commands already use, so it is consistent — but the blast radius is different: this is now the shared path for all fifteen resources in an unattended 42-document run. Worth a typed sentinel (protect.ErrNotFound) checked with errors.Is, so a genuine API failure aborts the object rather than mutating the tenant a different way.
🟩 (7) overrides apply aborts mid-document with no partial-progress report — (reliability, confidence: high)
internal/commands/protect_analytic_overrides.go (apply RunE)
A failing UpdateInternalAnalytic returns immediately, so an operator applying 40 overrides who fails at #20 gets an error naming only that one analytic and no indication that 19 already landed. restore gets this right — count, continue, report, non-zero at the end. Applying the same shape here keeps the two commands' behaviour predictable and makes a retry safe to reason about.
🟩 (8) Hand-rolled contains/indexOf in the test file — (code quality, confidence: high)
internal/commands/protect_backup_test.go:341-354
These reimplement strings.Contains with a redundant guard clause, and they take the unqualified names contains and indexOf at package scope in package commands — a package with a lot of test files, so the names are worth not claiming. Use strings.Contains.
🟩 (9) startup is now always sent for community-schema imports — (correctness, confidence: medium)
internal/commands/protect_analytics.go:464 — Startup: &ay.Startup
AnalyticInput.Startup is *bool and buildAnalyticVariables omits the variable when nil, so before this change a community YAML file with no startup: key left the field to the server's default. It now always resolves to a non-nil false and is always sent. That is correct for export | apply round-trips (which is the bug being fixed) but changes behaviour for analytics import of upstream jamf/jamfprotect files, which don't declare the field. If the server default is anything other than false, those imports now differ from before. Worth confirming on the wire, or making the field *bool in analyticYAML so absent stays absent.
🟩 (10) The RBAC export shape change is user-facing and isn't flagged as one — (documentation, confidence: high)
protect groups export, protect users export, and protect api-clients export previously emitted RoleIDs/ConnectionID; they now emit roles:/connection:/groups: as names. Input compatibility is preserved by rbacDocumentUsesIDs, which is the right call — but anything that parses the export output (a script reading .RoleIDs, a Terraform-adjacent wrapper) breaks silently. The PR body frames this under "Bugs fixed", which is accurate about the cause and quiet about the consequence. Worth an explicit "output shape changed" line in the PR body and in the wiki pages for those three commands, so the release notes carry it.
🟥 (11) unified-logging-filters apply cannot consume unified-logging-filters export — (correctness, confidence: high — wire-verified)
internal/commands/protect_ulf.go:128
Found by pointing each export at its own apply against a live tenant:
$ jamf-cli protect unified-logging-filters export "Some filter" -o yaml | \
jamf-cli protect unified-logging-filters apply --yes
CreateUnifiedLoggingFilter: input → filter: '' should be non-empty
ulfToYAML writes the community schema, whose predicate key is predicate; jamfprotect.UnifiedLoggingFilterInput calls the same field Filter. apply decoded the SDK shape directly, so the predicate never bound and the server refused every filter in every tenant.
This is a seventh instance of the bug class this PR documents in docs/solutions/logic-errors/response-shape-is-not-input-shape-2026-08-18.md — in the resource sitting next to the one the PR fixed. backup/restore were unaffected because they route through the YAML converters, which is precisely why unit tests over the converters didn't catch it.
Suggested fix: ulfInputFromDocument, mirroring the analyticInputFromDocument the PR already added; predicate vs filter is an unambiguous discriminator.
Acceptance criteria: export | apply round-trips for a filter, --scaffold | apply still works, and both shapes are covered by tests.
🟧 (12) Set exports churn because membership isn't sorted — (correctness, confidence: high — wire-verified)
internal/commands/protect_analytic_sets.go:339 · internal/commands/protect_ulf_sets.go:352
planToExport gained sort.Strings in this PR with the right reasoning attached — "the server returns it in its own order, so an unsorted export makes two identical plans diff". analyticSetToExport and ulfSetToExport have the same problem and didn't get the same fix.
Observed: backup, self-apply the Default Analytic Set, backup again → the two backups differ by the set's entire 33-line membership, rotated, with nothing actually changed. That lands directly on the workflow the jamf-backup skill recommends (git-tracked backup directories), where it reads as a real configuration change every time.
Suggested fix: sort.Strings(names) in both, matching planToExport.
Acceptance criteria: the same membership in two different server orders produces the same document.
What's genuinely good
- ✅
TestProtectResourceOrderEncodesDependenciesasserts the dependency relation rather than the order numbers. That is the test that keeps working after someone renumbers the table, and it is the one most people don't write. - ✅
TestProtectResourcesAreRestorableOrExplainedmakes "not replayable" a declared property with a reason string instead of a nil function nobody notices. Both directions are asserted, including the both-set contradiction. - ✅ The compare-before-write on
config-freezeandinsights, with the comment explaining that it is idempotency and not an optimisation. That reasoning is exactly why a reader won't "simplify" it away later. - ✅ Verifying the SDK's own semantics before relying on them: I checked
buildInternalAnalyticVariablesand theupdateInternalAnalyticmutation text myself, and the "omitted flag leaves that half untouched" contract holds precisely as documented. - ✅
docs/solutions/logic-errors/response-shape-is-not-input-shape-2026-08-18.mdgeneralises six specific bugs into one reusable rule, with frontmatter that the repo's own convention will surface to the next person working ininternal/commands.
Review coverage
- Design and architecture — table-driven resource set,
Orderas the correctness argument, genericupsertByName - Correctness — findings 2, 3, 4, 5, 6, 9; verified SDK variable-omission semantics and the GraphQL mutation text
- Security — finding 1 (secret-bearing export content); confirmed
apiClientExportcorrectly omits the client secret - Cross-repo / wire contracts — verified
InternalAnalyticInput,HttpClientParams,ForwardSentinel,DataRetentionSettingsagainstjamfprotect-go-sdk@v0.8.0's schema and query text - Reliability — findings 6, 7; restore's per-object tolerate-and-count is otherwise right
- Test coverage — each of the six bug fixes has a targeted regression test; gaps noted under 2 and 3
- Code quality — finding 8
- Documentation — CLAUDE.md, solutions doc, both skills; gap noted under 10
- [na] Performance — bounded per-object fetches for the two summary-only list queries, correctly explained in the struct comment
- [na] Database migrations, infrastructure, dependencies — none touched
- [na] Frontend — none touched
- Whether the server accepts a foreign-tenant
analyticUuid(finding 4) — resolved by wire-probing: it rejects it with an opaque error, so the finding is a failed restore rather than a silent misconfiguration
Rating: 4/5
Excellent engineering with genuinely exemplary documentation and test design — the Order-chain test, the restorable-or-explained test, and the postmortem are all better than what most PRs this size ship. What holds it off 5 is that the PR's own thesis wasn't applied exhaustively: the seventh instance of "response shape is not input shape" was one resource away from the six it fixed, and the plans-membership sort it added wasn't carried to the two sibling set types.
All twelve findings are now fixed on this branch (bf51f93, 0cf1041) and wire-verified against iacsandbox: every export | apply pipe round-trips across thirteen resources, a full self-restore applies 14 documents with 0 failures and leaves the tenant byte-identical, self-applying both set types produces no diff, and back-to-back retention restores both report "already matches". 29 packages pass, lint 0 issues, verify-generated and verify-site clean. Probe fixtures removed and the tenant returned to its prior state.
Note
Findings 11 and 12 were found by a one-line probe worth adopting for any export/apply pair: pipe each export straight into its own apply. Unit tests over the converters passed the whole time, because both directions were wrong in the same way.
Reviewed with Claude Code. Findings verified against jamfprotect-go-sdk@v0.8.0's schema and query text, and wire-probed against a live tenant.
Ran the verification the postmortem calls definitive — backup one live tenant, restore into a second, back that up, diff — on 0cf1041, which no prior run covered: the original clone predated the fix commits, and those changed the exception-set document shape, the restore Order, filename allocation and retention. Result: 26 documents applied, 0 failed, re-run idempotent, 24 of 32 files byte-identical. All eight differences accounted for — three plans differ only by commsConfig.fqdn, the exception set only by its rebound analyticuuid, and the rest are resources restore deliberately never replays. Every reference rebound to the target's own ids: custom analytic uuids, the analytic set naming them, the plan binding it, role 2348 → 541, group 2313 → 570. Two claims in the docs were wrong and are corrected. A clone leaves two nameable differences, not one. An exception's analyticuuid *must* differ for a custom analytic, because restore rebound it from the analytic: name to the target's own uuid — so equality there is the failure signal, not the success one. Anyone re-running this check would otherwise chase it as a bug. Also noted that the target keeps whatever the backup does not mention, so a per-file comparison is the right check rather than a whole-tree diff. The data retention note still described the failure the previous commit fixed. It now explains why the compare-before-write is there, and the unguarded-re-run list drops from two cases to one. Two new wire facts while building fixtures: a plan's customEngineConfig has PascalCase JSON keys and all-lowercase YAML keys, because CustomEngineConfigInput carries json tags and no yaml tags; and plans apply requires actionConfig, since omitting it answers "actionConfigs: contains invalid characters". Both tenants returned to their prior state — 31 and 22 objects, all fixtures removed. Real users were held back from the restore rather than recreated, since eight of the nine had email alerts enabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st discoverable Two follow-ups from the review. A round-trip test now drives what the live probe did by hand. For twelve resources it takes a fixture, runs the resource's own Export, marshals the document as both YAML and JSON, and feeds it back through two decoders: the restore closure, and the apply command's. Both axes matter because they are different code for six of these resources, and the gap between them is where the seventh instance of this bug class lived — ULF restore decoded the community struct and was always right, ULF apply decoded the SDK input and was broken for every filter in every tenant. The first version of this test was worthless and I nearly shipped it: it drove only the restore closure, so it passed with the ULF bug reintroduced. Mutation testing caught that. With the apply axis added, disabling the sniffer fails with the real symptom and prints the offending document. Both formats are exercised because their key derivation differs: yaml.v3 lowercases the Go field name and matches case-sensitively, encoding/json uses the json tag and matches case-insensitively, so a shape can round-trip in one and not the other. The assertions are the specific fields the seven real bugs dropped — predicate, analyticActions, longDescription, label, matchReason, startup, threatPreventionStrategy, customEngineConfig, rebound role and analytic ids. A companion test fails if a restorable resource is added to protectResources() without a fixture or a written exemption, so coverage cannot be lost silently the way it was. Second: the resource vocabulary was effectively undiscoverable. --resources shell-completed, --exclude did not complete at all despite taking the same vocabulary and being documented as composing with it, and --help never listed the names — so the only ways to find them were completion or passing a wrong value to read the error. Both flags now complete on both commands, and each command's help renders the list from protectResources() itself, marking singletons and, for restore, the resources backup captures but never replays. Generated rather than written out, because a hand-maintained list drifts silently. Tests pin the help to the table and assert both flags complete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… documents Two remaining restore-correctness items from the review. An access group could be restored once and never again. createGroup accepts accessGroup: true on a connection-less local group and stores true; updateGroup refuses it — "Local groups cannot be designated as access groups" — even when true is already the stored value. Established on the wire: create with true succeeds, the identical document re-applied fails, and the flag can be turned off via update but never back on. So every restore after the first failed, with a server message naming nothing the operator could act on. restoreGroup now reads the target's copy first. Where the desired state already holds there is nothing to do and it says so; where the target has the flag disabled the operation is genuinely impossible via update, so it explains that the server allows it only at create rather than passing the bare error through. Verified against a live tenant: three consecutive restores of the same document now report 0 failed, where the second used to fail. Backup prunes documents an earlier run left that no longer match the tenant, and reports each. Without it a backup directory is the union of every run that ever wrote to it, and because restore applies whatever it finds, an object deleted from the tenant was silently recreated by the next restore. Switching --format had the same effect in reverse, leaving .yaml beside .json for restore to apply both. pro backup does not do this, so this is a deliberate divergence rather than drift: Pro has no restore, so a stale file there only misleads a diff instead of resurrecting an object. Noted as such in CLAUDE.md and the skill. The rails matter more than the feature, and they are what the tests mostly cover. Only files this command could itself have written are ever considered — inside a directory named after one of its own resources, or for a singleton the one file that resource owns, and only with an extension restore reads. A resource whose export failed is never pruned, because the true object set is unknown and deleting on a failed read is how a backup tool loses the data it exists to protect. Probed live with a planted ghost document, a README and a notes.txt in the resource directory: the ghost went, both bystanders stayed. --no-prune opts out. Both probe fixtures removed; the tenant is back to its 22 objects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ackup --allow-partial-failure already exists as a root persistent flag bound to the package-level allowPartialFailure var, which is what pro_backup.go reads. Adding a local flag of the same name to protect backup shadowed it, so `jamf-cli protect backup --allow-partial-failure` worked while `jamf-cli --allow-partial-failure protect backup` set the global var that nothing then read — silently ignored, in the position a CI wrapper is most likely to use since it can put global flags ahead of the subcommand. Found while verifying flags for a test-team guide, which is the sort of thing writing the docs catches and the tests did not: the local flag satisfied its own test because the test passed the value as a parameter. Now reads the package var directly, the same way pro backup does, and the test sets that var with a deferred restore instead of threading a parameter. Verified both positions parse and behave against a live tenant, and that the flag is registered exactly once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ktn-jamf
left a comment
There was a problem hiding this comment.
Warning
protect backup/restore, protect analytics overrides, and seven export/apply fidelity fixes.
Blocking: (1), (2), (3), (4), (5). See collapsed sections for 9 nice-to-have suggestions and a decomposition recommendation.
Rating: 3/5
- Would be a 5 with the
0600guarantee in (1), the tenant guard on pruning in (2), plan-membership convergence in (3), and the two consistency defects in (4) and (5). - Coverage:
security-reviewer,usability-reviewer,performance-reviewer,scope-reviewer,silent-failure-hunter,devil-advocateandtest-quality-reviewerhave never been dispatched on this PR — their lanes were covered inline by the orchestrator, so the rating reflects one reader's pass over those dimensions rather than seven independent ones.
Findings
🟧 (1) (security, c=100) — internal/commands/protect_backup.go:1271: the 0600 the run promises is not applied to a file that already exists
mode is chosen at :1244-1248 and handed to os.WriteFile, which passes perm to OpenFile(…, O_CREATE|O_TRUNC, perm) — so the mode applies only when the file is created. Every re-run into an existing tree keeps whatever mode the file already had, while :1329 still prints WARNING: … were written 0600. Git does not record non-exec permissions, so the workflow skills/skills/jamf-backup/SKILL.md recommends — git-init the backup directory — reproduces the file at 0644 on every clone. TestRunProtectBackupTightensPermissionsOnSensitiveResources uses a fresh t.TempDir(), so it only ever exercises the creation path.
Failure scenario: a backup repo containing action-configs/siem.yaml is cloned (git creates it 0644), then protect backup --output <that dir> re-writes it with the bearer token → the file stays world-readable and stderr reports it as 0600, reachable from the PR as shipped.
Suggested fix:
if err := os.WriteFile(path, data, mode); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
+ // os.WriteFile only applies perm at creation, so an existing file
+ // keeps whatever mode it had (a git checkout hands back 0644).
+ if mode != 0o644 {
+ if err := os.Chmod(path, mode); err != nil {
+ return fmt.Errorf("tightening permissions on %s: %w", path, err)
+ }
+ }Fixed when: a second protect backup into a directory whose sensitive document was pre-created 0644 leaves it at 0600, and TestRunProtectBackupTightensPermissionsOnSensitiveResources gains a sub-test that pre-creates the file at 0644 before the run.
🟧 (2) (correctness, c=75) — internal/commands/protect_backup.go:1285-1291: pruning is not scoped to the tenant the directory belongs to
protectPruneStale deletes every document in a resource directory that this run did not write. The run records provenance — TenantURL at :1303 — but nothing ever reads an existing _meta back, so the identity of the tree being pruned is never checked. Pointing protect backup at a directory that holds a different tenant's backup therefore deletes that tenant's documents for every object the current tenant does not have. The command carries no jamf:destructive annotation and no --yes, unlike restore, and the loss is reported only as a Pruned N document(s) count. TestBackupPrunesDocumentsThatNoLongerMatchTheTenant covers the deleted-object, format-switch, failed-export and bystander cases, but never a second tenant.
Failure scenario: jamf-cli -p protect-prod protect backup --output ./protect-backup run against a directory previously filled by -p protect-staging → every staging document for an object production lacks is removed in place, with the only signal a prune count, reachable from the PR as shipped.
Suggested fix: read the existing _meta before pruning and refuse when its tenant_url disagrees with cliCtx.ProtectURL:
+ // Pruning is destructive and keyed on "this tenant's object set", so it must
+ // not run against a directory that belongs to a different tenant.
+ if !noPrune {
+ if prior, ok := readProtectBackupMeta(outputDir); ok &&
+ prior.TenantURL != "" && prior.TenantURL != cliCtx.ProtectURL {
+ return fmt.Errorf("%s holds a backup of %s, not %s — pruning would delete that tenant's "+
+ "documents; use a different --output or pass --no-prune",
+ outputDir, prior.TenantURL, cliCtx.ProtectURL)
+ }
+ }Fixed when: a backup run whose _meta names a different tenant_url errors out (or prunes nothing) instead of deleting, and a test asserts it with two mock tenants writing to one directory.
🟧 (3) (correctness, c=100) — internal/commands/protect_backup.go:522-532: restore cannot converge a plan's membership, so a rollback silently leaves extra bindings attached
The plans closure calls planExportToInput (internal/commands/protect_plans.go:452), which sets ExceptionSets, AnalyticSets, ULFSets only under if len(…) > 0 (:470, :481, :492) and USBControlSet/Telemetry only under != "" (:503, :510). The SDK's buildPlanVariables (jamfprotect-go-sdk@v0.8.0/jamfprotect/plan.go:387) omits each of those GraphQL variables when nil, so an absent list means "leave unchanged". That is deliberate and documented for plans apply (CLAUDE.md), but restore's own help says "Existing objects of the same name are updated", and the SDK already exposes TelemetryV2Null for the explicit-null case. The three sibling set types converge correctly (analyticSetExportToInput, ulfSetExportToInput, groupExportToInput all send a non-nil empty slice), so plans are the one asymmetric resource — and omitempty on planExport means the on-disk document cannot even express "no members".
Failure scenario: a plan gains an analytic set on Monday; Tuesday's protect restore of Sunday's backup reports applied <plan> → the Monday set is still bound in the target, reachable from the PR as shipped.
Suggested fix: keep plans apply additive and make restore convergent, by parameterising the conversion rather than changing the shared one:
-func planExportToInput(ctx context.Context, e planExport, r *protect.Resolver) (jamfprotect.PlanInput, error) {
+// clearAbsent sends an empty list for a membership field the document omits, so
+// restore converges the target. `plans apply` passes false: CLAUDE.md documents
+// that an omitted list there leaves membership alone.
+func planExportToInput(ctx context.Context, e planExport, r *protect.Resolver, clearAbsent bool) (jamfprotect.PlanInput, error) {
...
+ if clearAbsent {
+ if e.ExceptionSets == nil { input.ExceptionSets = []string{} }
+ if e.AnalyticSets == nil { input.AnalyticSets = []jamfprotect.PlanAnalyticSetInput{} }
+ if e.ULFSets == nil { input.UnifiedLoggingFilterSets = []string{} }
+ if e.Telemetry == "" { input.TelemetryV2Null = true }
+ }Fixed when: restoring a plan document with no analyticSets/unifiedLoggingFilterSets/exceptionSets/telemetry clears those bindings in the target, plans apply keeps its documented additive behaviour, and protect_roundtrip_test.go gains an empty-membership plan fixture asserting the cleared input.
🟧 (4) (reliability, c=100) — internal/commands/protect_backup.go:404: the analytic-overrides restore closure aborts at the first refused override, and the sibling command's comment claims it does not
overrides apply was changed to report-and-continue, with the reasoning spelled out at internal/commands/protect_analytic_overrides.go:452-456 — "'protect restore' takes the same approach". It does not: the restore closure returns on the first UpdateInternalAnalytic error, so the entries after it are never attempted and the applied count computed at :407 is discarded. The whole singleton then reports as one FAILED line. That is the exact defect the author fixed one file over, with a comment asserting parity that the code does not hold.
Failure scenario: a 40-entry analytic-overrides.yaml whose 20th entry names an action the target tenant has not provisioned → restore prints one FAILED analytic-overrides.yaml line, 19 overrides are already written and unreported, and entries 21–40 are never attempted, reachable from the PR as shipped.
Suggested fix:
if _, err := c.UpdateInternalAnalytic(ctx, a.UUID, input); err != nil {
- return "", fmt.Errorf("applying override for %q: %w", o.Analytic, err)
+ fmt.Fprintf(os.Stderr, " FAILED override for %q: %v\n", o.Analytic, err)
+ failed++
+ continue
}
applied++
}
- return fmt.Sprintf("%d override(s)", applied), nil
+ if failed > 0 {
+ return "", fmt.Errorf("%d of %d override(s) failed (%d applied)", failed, len(doc.Overrides), applied)
+ }
+ return fmt.Sprintf("%d override(s)", applied), nilFixed when: a refused override is reported and the remaining entries still apply, the summary names both counts, and the resource still exits non-zero — matching what overrides apply now does and what its comment claims.
🟧 (5) (usability, c=100) — internal/commands/protect_backup.go:1163 and :1403: the two new commands declare local flags that shadow root persistent flags, removing -o and -n
--output (:1163) and --dry-run (:1403) collide by name with the root persistent -o, --output (output format) and -n, --dry-run (internal/commands/root.go:794, :800). Cobra's AddFlagSet skips an inherited flag whose name is already taken, so the shorthand goes with it. Probed on a binary built at head:
$ jamf-cli protect backup -o json --output /tmp/x → unknown shorthand flag: 'o' in -o (exit 2)
$ jamf-cli protect restore -n --input /tmp/x → unknown shorthand flag: 'n' in -n (exit 2)
Neither -o, --output nor -n, --dry-run appears in those commands' --help Global Flags list. This is the same mechanism the head commit (7e90e09) fixed for --allow-partial-failure, whose replacement comment at :1189 states the rule. pro backup shares the --output collision, so that half is precedent; --dry-run has no precedent, and the root flag already means exactly "preview changes without executing".
Failure scenario: the documented CI mechanism JAMF_CLI_ARGS='-o json' jamf-cli protect backup --output ./d exits 2 with unknown shorthand flag: 'o', reachable from the PR as shipped (verified).
Suggested fix: drop the local --dry-run and read the package var the way allowPartialFailure is now read; if --output is kept for parity with pro backup, say in the Long text that -o is unavailable on this command.
- cmd.Flags().BoolVar(&dryRun, "dry-run", false, "report what would be applied without calling the API")
+ // --dry-run is the root persistent flag (-n), read directly the way
+ // allowPartialFailure is. A local flag of the same name drops the shorthand.(and take dryRun from the package var in runProtectRestore's caller)
Fixed when: protect restore -n --input <dir> performs a dry run, and either protect backup -o json parses or the help text says why it cannot.
This covers all findings — addressing the above gets this PR to merge-ready.
Nice-to-have suggestions (9 items)
🟩 (6) (correctness, c=75) — internal/commands/protect_groups.go:149: groups apply does not share restoreGroup's access-group workaround, so groups export | groups apply still fails for a connection-less local access group with the raw server message the PR set out to replace. Route groups apply's update branch through restoreGroup.
🟩 (7) (code-quality, c=100) — internal/commands/protect_groups.go:112, protect_users.go:124, protect_api_clients.go:108: apply --scaffold still prints the SDK input shape (roleids/connectionid) while export now emits names. exception-sets got its scaffold updated in this PR; these three teach the shape the PR deprecates.
🟩 (8) (reliability, c=100) — internal/commands/protect_backup.go:1338-1341: the _failures manifest is written under if err == nil { _ = os.WriteFile(…) }, so a marshal or write failure is silent while the very next line tells the operator to "see _failures.yaml". Report the write error rather than swallowing it.
🟩 (9) (correctness, c=100) — internal/commands/protect_backup.go:1430 vs :1046: singleton restore accepts only .yaml/.json, but protectRestoreExts (used for pruning) and the collection walk at :1454 both accept .yml. So insights.yml is deleted by a backup run and ignored by a restore. Use protectRestoreExts in all three places.
🟩 (10) (code-quality, c=100) — internal/commands/protect_backup.go:1109: pruned singletons are reported as <resource>/<file> (e.g. insights/insights.json) though the file lives at the directory root. Join only when !res.Singleton.
🟩 (11) (usability, c=100) — internal/commands/protect_backup.go:1546: restore returns a plain error, so it exits General (1) rather than PartialFailure (7) and ignores --allow-partial-failure, unlike the backup path at :1352. Use exitcode.PartialOrPropagate(applied, failed, nil, msg) for symmetry.
🟩 (12) (correctness, c=100) — internal/commands/protect_backup.go:668: the insights restore iterates the want map, so the order of live mutations and of the log lines is nondeterministic — against the "Deterministic order so a restore is reproducible and its log diffable" rule stated at :1458. Sort the labels before the loop.
🟩 (13) (reliability, c=75) — internal/commands/protect_action_configs.go:248-252: a json.Marshal failure substitutes "{}", so an action client whose params cannot be encoded is backed up with empty params and restored as a broken client. Return the error instead — params is AWSJSON! and a silent {} is not a valid fallback.
🟩 (14) (performance, c=75) — internal/commands/protect_backup.go:198 and :355: analytics and analytic-overrides each call ListAnalytics (~156 rows in a stock tenant) on every full backup. One fetch shared between the two closures would halve it; worth it only if the resource table grows a way to share state.
PR decomposition
Scope Review
Recommendation: split into 3, merge in order. At +5970/−117 across 30 files this is past the "very large" threshold, and it carries three concerns that are independently valuable, independently testable, and independently revertable. The strongest evidence that they are separable is the PR's own narrative: the seven export/apply fixes were discovered by building backup/restore, but none of them depends on backup/restore existing, and one of them (the RBAC shape change) is a breaking output change that deserves its own release note rather than a row in a feature PR's table.
- PR 1 — export/apply fidelity (
~1400lines).protect_analytics.go,protect_ulf.go,protect_plans.go,protect_exception_sets.go,protect_rbac_refs.go,protect_groups.go,protect_users.go,protect_api_clients.go,protect_action_configs.go,protect_org.go,protect_conversions_test.go,protect_exception_sets_test.go,protect_rbac_refs_test.go, plusdocs/solutions/logic-errors/response-shape-is-not-input-shape-2026-08-18.md. Ships the seven bug fixes and the breaking four-resource output-shape change on their own, so the release note is about exactly that. Findings (6) and (7) land here. - PR 2 —
protect analytics overrides(~700lines).protect_analytic_overrides.go+ its test, theregistry.goUpdateInternalAnalyticaddition, the CLAUDE.md overrides paragraphs. A self-contained subcommand tree with no dependency on backup. - PR 3 —
protect backup/restore(~3500lines).protect_backup.go,protect_backup_test.go,protect_roundtrip_test.go,resolve.go'sErrNotFound, the wiring inprotect.go/groups.go/root.go, and the two skills. Depends on both predecessors — which is the argument for the order, not for the merge. Findings (1)–(5) all land here, and reviewing 3500 lines of new backup/restore semantics without 2500 lines of unrelated converter churn in the same diff is the concrete benefit.
Nothing here argues the work is wrong or that it should be redone — it argues that the five 🟧 findings above all sit in one of the three parts, and a reviewer looking only at that part would have had a better chance of catching them. If the author prefers to land it as one PR, that is a defensible call for a single-author repo; the recommendation carries no severity and does not hold the merge.
Review coverage
- Design and architecture: the table-driven
protectResources()withOrderas the correctness argument is the right shape;upsertByNamegeneric and theExport/Restoreclosure pair keep the set declarative. ReadprotectResources()end to end and traced everyOrderband against its references. The one design gap is the plan-membership asymmetry in (3). - Correctness: findings (2), (3), (4), (6), (9), (10), (12), (14). Traced restore ordering (
analytics/unified-logging-filters10 → sets 20 →plans30 →roles40 →groups50 →users/api-clients60), the per-resource resolver reset atprotect_backup.go:1519-1526,protectSelectResourcesinclude/exclude composition,protectNameAllocatorcollision handling, andprotectPruneStale's candidate derivation for both the singleton and collection cases. - Security: finding (1). Confirmed
pruneEmptyValuesdoes not recurse into[]any, soHttpClientParams.headersvalues survive verbatim intoaction-configs/*.yaml— which is whatSensitiveReasonexists for; confirmedapiClientExportomits the client secret andredactDataForwardingreplacesForwardSentinel.SharedKeywithout mutating its input. Verifiedos.WriteFile's perm-on-create-only semantics with a standalone probe. - Cross-repo / wire contracts: resolved the load-bearing SDK couplings against
jamfprotect-go-sdk@v0.8.0source rather than the PR body —buildPlanVariables(plan.go:387) confirms the nil-omission that finding (3) rests on, and confirmsTelemetryV2Nullexists as the explicit-null mechanism the fix can use. No cross-repo assumption left unresolved. - Reliability: findings (4), (8), (13). Verified
protect.ErrNotFound/notFoundError.Isand thatupsertByNameonly creates on a genuine not-found, including through the%wwrappers ingroupExportToInput/planExportToInput. - Performance: finding (14). The two N+1 fetches (
action-configs,exception-sets) are forced by summary-only list queries and are documented at theExportfield comment; restore's per-resource resolver reset is a deliberate correctness trade. - Test coverage: 12-resource × 2-format round-trip harness plus
TestProtectRoundTripTableCoversEveryRestorableResource's exempt-with-a-reason gate is the strongest part of the PR. Gaps named under (1) (creation path only), (2) (no second tenant), (3) (no empty-membership plan fixture), (4) (no override-failure fixture). - Code quality: findings (7), (10). Comment density is high but every long comment carries a wire fact, not narration. Prior review's hand-rolled
contains/indexOfare gone. - Simplification: no redundant abstraction introduced;
protectResourceListHelpgenerating the vocabulary from the table is the right call over a hand-maintained list. - [na] Frontend concerns: no UI, site, or template files changed.
- Documentation currency: CLAUDE.md gains the routing-table rows, the backup/restore section, and the wire-fact list; both skills learn the Pro/Protect asymmetry; the postmortem's frontmatter (
module: internal/commands,category: logic-errors) matches the existingdocs/solutions/logic-errors/convention. No consolidated documentation finding warranted — but note finding (4)'s comment is the one place a doc claim and the code disagree. - Scope and decomposition: covered in the PR decomposition section — +5970 across three separable concerns.
- Project rules compliance: no
.claude/rules/directory. Checked againstCLAUDE.md: credential-input policy (no new credential flags —--token-file/env only, unaffected), generated-code boundary (no files under*/generated/touched), Protect conventions (positional<name>args,applyupsert,--yes/interactive confirm on destructive paths —restoreandoverrides clearboth annotatedjamf:destructiveand gated;backup's prune is the exception, see (2)), filename prefixes (protect_*), help groups and aliases wired ingroups.go.
Important files changed
| File | Score | Notes |
|---|---|---|
internal/commands/protect_backup.go |
3/5 | 1549 new lines carrying every 🟧 in this review |
internal/commands/protect_roundtrip_test.go |
5/5 | Certified clean: the two-axis (restore closure + apply decoder) × two-format harness, plus the coverage gate that fails when a restorable resource is added without a fixture, is the right test for this bug class and has no gap I could find |
internal/protect/resolve.go |
5/5 | Certified clean: notFoundError.Is gives every one of the 17 resolvers an errors.Is-matchable sentinel without changing a single user-visible message |
internal/commands/protect_action_configs.go |
4/5 | The AWSJSON! re-encoding and the batchConfig Int!-vs-nullable split are correct and well-argued; the marshal fallback is (13) |
internal/commands/protect_plans.go |
4/5 | threatPreventionStrategy/customEngineConfig recovery and the three membership sorts are right; the conversion's clearing semantics are (3) |
internal/commands/protect_rbac_refs.go |
5/5 | Certified clean: name-based references with []string{} initialisation in all three converters, and rbacDocumentUsesIDs keeps the legacy shape readable in both encodings |
Scope of review
Full diff (30 files, +5970/−117) at head 7e90e09, base origin/main (027396a), in the worktree at /tmp/pr-review-Jamf-Concepts-jamf-cli-330.
Read in full: internal/commands/protect_backup.go (1549 lines, whole file at head), internal/commands/protect_analytic_overrides.go, internal/commands/protect_rbac_refs.go, and the complete diffs of protect_plans.go, protect_exception_sets.go, protect_ulf.go, protect_ulf_sets.go, protect_analytic_sets.go, protect_analytics.go, protect_action_configs.go, protect_org.go, protect_groups.go, protect_users.go, protect_api_clients.go, internal/protect/resolve.go, internal/registry/registry.go, internal/commands/root.go, groups.go, protect.go, and CLAUDE.md.
Sampled rather than read line by line (per the very-large-diff guidance): the 2900 lines of test additions — read protect_roundtrip_test.go's driver and coverage gate in full, and in protect_backup_test.go the permission, partial-failure, prune, upsertByName and restoreGroup tests in full; the remaining ~60 conversion/flatten unit tests were read at the level of their names and assertions. Both skills/skills/*/SKILL.md and the postmortem were read for factual agreement with the code, not line by line.
Beyond the diff: internal/commands/pro_backup.go (partial-failure and --output parity), internal/exitcode/exitcode.go (PartialOrPropagate semantics), internal/commands/root.go:793-813 (persistent flag set), and jamfprotect-go-sdk@v0.8.0/jamfprotect/plan.go (buildPlanVariables).
Executed: go build ./..., go vet ./internal/... (clean), go test ./internal/commands/ ./internal/protect/ (both pass), plus three behavioural probes on a binary built at head — the -o/-n shadowing in (5), and a standalone Go program confirming os.WriteFile's perm-on-create-only semantics for (1). No live tenant was available, so every wire claim in the PR body about server behaviour (the updateInternalAnalytic semantics, the foreign-analyticUuid rejection, the retention rate limit, the accessGroup create-only asymmetry, the telemetry event allowlist) is taken as reported and not independently verified; the SDK-side half of each was checked against v0.8.0's source.
Never-run specialist lanes. No agents were dispatched this round (session policy: the orchestrator covered the lanes inline). Eligible but never dispatched on this PR: security-reviewer, usability-reviewer, performance-reviewer, scope-reviewer, silent-failure-hunter, devil-advocate, test-quality-reviewer — all seven eligible on a high-risk diff of this shape, all seven covered inline instead, which is one reader rather than seven. fidelity-reviewer is inapplicable: no Jira key in the title, branch or description, and no linked issue, so there is no spec to check against — the PR body was used as the statement of intent instead.
Unresolved: none. No cross-repo contract was left unchecked.
Prior review status
One prior review by @neilmartin83 at 0cf1041 (round 1, marker ran= ever=), commit VALID (reachable from head), carrying 12 findings and claiming all 12 fixed. Verified each against head 7e90e09: 11 fixed — (1) secrets now declared via SensitiveReason, written 0600 and reported (but see (1) above, which is the residual); (2) exitcode.PartialOrPropagate plus the root --allow-partial-failure, and the duplicate local flag dropped in 7e90e09; (3) protectNameAllocator with a name-derived discriminator and a case-folded key; (4) exception sets carry analytic: and rebind by name; (5) data-retention compares before writing; (6) protect.ErrNotFound + errors.Is; (8) hand-rolled contains/indexOf replaced by strings.Contains; (9) analyticYAML.Startup is now *bool; (10) the shape change is called out in the PR body and CLAUDE.md; (11) ulfInputFromDocument; (12) sort.Strings in both set exports. 1 partially fixed: (7) — overrides apply now reports-and-continues, but the analytic-overrides restore closure still aborts at the first failure, which is promoted to (4) above.
What's done well
- ✅
TestProtectRoundTripTableCoversEveryRestorableResourcemakes coverage a declared property: adding a restorable resource without a round-trip fixture fails the build, and each exemption carries a reason string rather than being skipped by a type check. Paired with the two-axis driver — the restore closure and theapplydecoder, which are different code for six resources — this is the test that would actually have caught the seventh bug, and it is the one most authors do not write. - ✅ The prune rails are argued from the failure mode rather than the feature: only files this command could have written, only extensions restore would read, and never after a failed export because "the true object set is unknown".
TestBackupPrunesDocumentsThatNoLongerMatchTheTenant's bystander sub-test pins the boundary. Finding (2) is a missing dimension on that reasoning, not a contradiction of it. - ✅ Recording the wire facts that are not derivable from the schema —
Analytic.hashbeing a revision stamp, theaccessGroupcreate-only asymmetry,paramsbeingAWSJSON!but non-nullable, the undeclared telemetry event allowlist — directly in CLAUDE.md, each with the exact server message. That is the half of this work that will still be paying off in a year.
🤖 Generated by the pr-review:review skill · reviewed head 7e90e09
Generated by pr-review v1.30.0, a Jamf Claude Code skill
…ergence, flag shadowing
Five blocking findings from the round-2 review, plus eight of the nine
nice-to-haves.
Blocking:
- 0600 is now delivered on a re-run. os.WriteFile passes perm to
OpenFile(O_CREATE|O_TRUNC), so it applies only at creation; git records no
non-exec permissions, so a clone of a backup repo hands every file back 0644
and the next run kept it there while still reporting 0600. An explicit
os.Chmod follows the write.
- Pruning is scoped to the tenant the directory belongs to. It is keyed on
"this tenant's object set", so pointed at another tenant's backup it deleted
that tenant's documents for every object this one lacks — reported only as a
count, with no confirmation and no destructive annotation. An existing _meta
naming a different tenant_url now refuses; --no-prune writes alongside.
- restore converges a plan's membership. planExportToInput takes clearAbsent:
restore passes true and sends an empty list (and TelemetryV2Null) for an
absent field, so a binding added after the backup does not survive a
rollback; 'plans apply' passes false and keeps its documented additive
behaviour. usbControlSet has no explicit-null in the SDK and is called out.
- The analytic-overrides restore closure reports and continues, matching what
'overrides apply' does and what its comment claims. Aborting at the first
refused entry left the earlier ones written and unreported and the rest
never attempted.
- 'protect restore' no longer shadows the root -n. Cobra's AddFlagSet skips an
inherited flag whose name is taken and the shorthand goes with it, so
'restore -n' failed with "unknown shorthand flag". It reads the package var
the way allowPartialFailure is read. --output stays on backup for parity with
'pro backup', and the help now says -o is unavailable there.
Also: 'groups apply' shares restoreGroup's accessGroup guard (extracted as
protectGroupUpdateSatisfied); the three RBAC scaffolds print the export shape
rather than the deprecated SDK input shape, and those exports drop omitempty on
their reference fields so a document is self-describing; the _failures manifest
reports its own write errors; singleton restore reads .yml, so what backup
prunes is what restore applies; a pruned singleton is reported at its real
path; restore exits PartialFailure and honours --allow-partial-failure; the
insights restore iterates in sorted order; and a params marshal failure is an
error rather than a silent "{}".
Declined: sharing one ListAnalytics between the analytics and
analytic-overrides exports. It saves one GraphQL call per backup at the cost of
mutable state shared across a resource table that tests and completion
instantiate freely — a bad trade for the size of the win.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed in Blocking(1) Worth adding to the record: the mode protects the working copy, not the repository. Git stores no non-exec permissions, so a clone reproduces (2) Pruning not scoped to the tenant — fixed as suggested. (3) Plan membership not convergent — fixed with the parameterisation you proposed. One gap I could not close:
(4) (5) Flags shadowing root persistent flags —
Nice-to-haves
(14) declined. Sharing one DecompositionTaking the "single-author repo, defensible call" branch and landing it as one PR. The three parts are separable as you describe, but PR 3 depends on both predecessors, so splitting now means three sequential review rounds on code that is already reviewed twice — and the five 🟧 findings were all found, which is the outcome the split was meant to buy. Noted for the next feature of this size: the fidelity fixes should have gone out first, before backup/restore existed to discover them.
|
ktn-jamf
left a comment
There was a problem hiding this comment.
Warning
433b72e. All five round-1 blockers are genuinely fixed and mutation-checked; two of the fixes are incomplete in a way that reopens the risk they were meant to close.
Blocking: (1) --no-prune rewrites _meta, disarming the new tenant guard for the next run. (2) The shared access-group guard now reports success while dropping a requested change.
Rating: 3/5
- Would be a 5 with the
_metaprovenance made durable in (1) and the access-group guard distinguishing "nothing to do" from "cannot do this" in (2). Everything from round 1 is closed. - Coverage:
security-reviewer,usability-reviewer,performance-reviewer,scope-reviewer,silent-failure-hunter,devil-advocateandtest-quality-reviewerhave never run on this PR — their lanes were covered inline by the orchestrator across both rounds, so the rating reflects one reader's pass over those dimensions rather than seven independent ones.
Prior findings status (14 items)
| # | Location | State | Notes |
|---|---|---|---|
(1) c=100 |
protect_backup.go:1342 |
✅ Fixed | Explicit os.Chmod after os.WriteFile, gated on mode != 0o644 (the only other value is 0o600, :1307-1309). Mutation-checked: deleting the chmod block fails TestRunProtectBackupTightensPermissionsOnAnExistingFile with mode = 0644, want 0600. The added CLAUDE.md and SKILL.md wording about 0600 not surviving a clone is correct and was not asked for. |
(2) c=75 |
protect_backup.go:1258-1264 |
The guard is exactly as suggested, refuses before anything is written or deleted, and is pinned by TestBackupRefusesToPruneAnotherTenantsDirectory (mutation-checked — stubbing the condition to false fails it). But _meta is rewritten unconditionally at :1371-1377, including on the --no-prune run the guard's own error recommends, so the directory's recorded tenant flips and the next pruning run deletes the first tenant's documents. Carried forward as (1) below, verified by execution. |
|
(3) c=100 |
protect_plans.go:534-555, protect_backup.go:533 |
✅ Fixed | clearAbsent parameterisation exactly as proposed; restore passes true, plans apply passes false, and TestPlanExportToInputClearsAbsentMembershipOnlyForRestore asserts both directions plus the populated-list case. The usbControlSet gap is real and the author's reason checks out: verified in jamfprotect-go-sdk@v0.8.0/jamfprotect/plan.go:160,417 that it is a bare *string with no Null sibling, while TelemetryV2Null (:154-155, :412) does emit telemetryV2: null. Two residuals are 🟩 (3) and 🟩 (4) below. |
(4) c=100 |
protect_backup.go:396-414 |
✅ Fixed | Report-and-continue, then fail with both counts so the resource still counts as failed. TestRestoreAnalyticOverridesReportsAndContinues asserts internalAnalyticWrites == [a1, a3]; mutation-checked (restoring the early return fails it). The protect_analytic_overrides.go comment that claimed parity is now true. |
(5) c=100 |
protect_backup.go:1443-1485 |
✅ Fixed | Local --dry-run dropped; runProtectRestore's caller reads the package var. Verified on a binary built at 433b72e: protect restore -n --input … now parses through to the auth check, and -n, --dry-run appears in protect backup --help's Global Flags. --output is kept for pro backup parity with the Long text saying -o is unavailable — protect backup -o json still exits 2 with unknown shorthand flag: 'o' in -o, which is the "say why it cannot" branch of the round-1 acceptance criteria. The new CLAUDE.md Conventions rule is the right place for it. |
(6) c=75 |
protect_groups.go:114-125, :186-196 |
The guard is genuinely shared now and TestGroupsApplySharesTheAccessGroupGuard drives both outcomes end to end. But extracting it also gave groups apply a success path that skips the update for reasons unrelated to what the document asks for. Carried forward as (2) below, verified by execution. |
|
(7) c=100 |
protect_groups.go:143, protect_users.go:124, protect_api_clients.go:108 |
✅ Fixed | All three scaffolds now print the export shape, and all three apply commands decode either shape (groupInputFromDocument/userInputFromDocument/apiClientInputFromDocument), so the scaffold is accepted by the command that prints it — checked on the binary. The omitempty removal that made this possible is argued and documented; one format-asymmetry consequence is 🟩 (5). |
(8) c=100 |
protect_backup.go:1411-1419 |
✅ Fixed | Both the marshal and the write are returned. |
(9) c=100 |
protect_backup.go:1511, :1537 |
✅ Fixed | All three sites read protectRestoreExts; the collection walk uses slices.Contains over the same list. Mutation-checked — narrowing the singleton set back to .yaml/.json fails TestCollectProtectRestoreFilesAcceptsYmlForSingletons. |
(10) c=100 |
protect_backup.go:1144-1150 |
✅ Fixed | Singletons report the bare file name. |
(11) c=100 |
protect_backup.go:1630-1638 |
✅ Fixed | Byte-for-byte the shape pro_backup.go:357-362 and protect_backup.go:1423-1431 use, so all three report a mixed result identically. Mutation-checked against TestRunProtectRestoreExitsPartialOnMixedResults. |
(12) c=100 |
protect_backup.go:673-682 |
✅ Fixed | Labels sorted before the loop. |
(13) c=75 |
protect_action_configs.go:252-256 |
✅ Fixed | actionConfigToInput returns (input, error); both call sites propagate. |
(14) c=75 |
protect_backup.go:198, :355 |
💬 Dismissed | Author: sharing one ListAnalytics "is a memo captured in protectResources() — mutable state shared across a table that tests, completion and help all instantiate freely. Bad trade for the size of the win". Reasonable, and it matches the condition the round-1 finding itself attached. |
Findings
🟧 (1) (correctness, c=100) — internal/commands/protect_backup.go:1371-1377: _meta is rewritten on every run including --no-prune, so following the tenant guard's own advice disarms it
The guard at :1258-1264 is correct in isolation. What it trusts is not: TenantURL at :1376 is overwritten unconditionally at the end of every successful run, --no-prune included. So the sequence the refusal message itself recommends — "use a different --output, or pass --no-prune to write alongside them" — relabels the directory as belonging to the second tenant, and the next run by that tenant passes the guard and prunes the first tenant's documents. The --no-prune sub-test in TestBackupRefusesToPruneAnotherTenantsDirectory stops one run short of this.
Two smaller cracks in the same trust: _meta is written last (:1371), so a run that failed or was interrupted leaves documents with no provenance at all; and readProtectBackupMeta treats an unparseable manifest as "unknown" (:1068-1081) rather than as a reason not to prune. Both leave the guard blind on a directory that is demonstrably not empty.
Failure scenario (reproduced in the worktree, three runProtectBackup calls against one directory):
staging → dir : writes zz-staging-only.yaml, zz-shared.yaml, _meta(staging)
prod → dir --no-prune : refusal avoided as documented; _meta now says prod
prod → dir : "Pruned 1 document(s)" — zz-staging-only.yaml is gone
Reachable from the PR as shipped, and reached by doing exactly what the error text says.
Suggested fix: make the provenance record cumulative rather than last-writer-wins, and prune only when the current tenant is its sole owner.
- TenantURL: cliCtx.ProtectURL,
+ TenantURL: cliCtx.ProtectURL,
+ // Append-only: a --no-prune run adds itself rather than relabelling the
+ // directory, so a later pruning run still sees the other tenant.
+ Tenants: appendTenant(prior.Tenants, prior.TenantURL, cliCtx.ProtectURL),and at :1258, refuse when prior names any tenant other than this one — including via Tenants — and treat an unparseable _meta beside a non-empty resource directory the same way.
Fixed when: the three-call sequence above leaves zz-staging-only.yaml on disk, a test pins it, and a corrupt _meta in a directory that already holds documents does not silently enable pruning.
🟧 (2) (reliability, c=100) — internal/commands/protect_groups.go:114-125 and :186-196: the shared access-group guard reports "nothing to update" for any change, so groups apply silently discards one
protectGroupUpdateSatisfied returns satisfied = true on input.AccessGroup && isLocal && existing.AccessGroup alone — it never compares roles, connection, or anything else the document carries. groups apply (:186-196) then prints Group %q is already an access group; nothing to update, echoes the existing group through printResult, and exits 0. Before this round the same input reached UpdateGroup and failed loudly with the server's message; the round-1 (6) fix replaced a bad error with a lost update. restoreGroup (protect_backup.go:157-162) has the same behaviour, so a restore of an edited backup reports applied for a group it did not change.
The server genuinely cannot perform that update — that is the wire fact CLAUDE.md records — so the right answer is not to attempt it, but the command has to say the change could not be applied rather than that there was nothing to apply.
Failure scenario (reproduced in the worktree): target holds local access group zz-group with no roles; document is its own export with roles: [zz-role] added. groups apply --from-file … --yes → updateGroup calls 0, exit 0, message nothing to update. The role grant is silently dropped, and the same document restores as applied.
Suggested fix: split the two cases.
- if existing.AccessGroup {
- // Desired state already holds; updateGroup could only refuse it.
- return true, nil
- }
+ if existing.AccessGroup {
+ // updateGroup would refuse this document outright, so nothing can be sent.
+ // Only "no other change was asked for" makes that a no-op rather than a
+ // dropped change.
+ if diff := groupUpdateWouldChange(existing, input); diff != "" {
+ return false, fmt.Errorf("group %q is a connection-less local access group, so updateGroup "+
+ "refuses any update to it, and this document changes %s — clear accessGroup in the "+
+ "document, or delete and recreate the group in the target", input.Name, diff)
+ }
+ return true, nil
+ }Fixed when: applying a document that changes a local access group's roles exits non-zero naming the field it could not change, re-applying an unchanged one still makes no updateGroup call, and TestGroupsApplySharesTheAccessGroupGuard gains a changed-roles sub-test.
Those are the only two blocking items — with them addressed this is merge-ready.
Nice-to-have suggestions (4 items)
🟩 (3) (test coverage, c=100) — internal/commands/protect_backup.go:533: the blocking fix for round-1 (3) is not pinned at its call site. Flipping planExportToInput(ctx, e, r, true) to false — i.e. reverting the whole convergence fix — leaves go test ./internal/commands/ green (verified). The new conversion test covers the function with both flag values, and the round-trip harness's plan case passes false because it exercises the apply decoder, so nothing asserts that the restore closure asks for clearing. The round-1 acceptance criterion was an empty-membership plan fixture in protect_roundtrip_test.go asserting the cleared input; a cheaper equivalent is a direct assertion on the resource's Restore closure with a mock recording the PlanInput it receives.
🟩 (4) (correctness, c=100) — internal/commands/protect_plans.go:534-555: restore's convergence has a second unconverged field beyond the documented one. usbControlSet is called out in the code and in CLAUDE.md, and the SDK limitation is real (verified at plan.go:160,417). The legacy telemetry reference is not: buildPlanVariables omits telemetry when nil (plan.go:409-411) and clearAbsent only sets TelemetryV2Null, while planToExport (protect_plans.go:416-419) deliberately reads a plan bound through the legacy field. So a plan whose telemetry lives in telemetry rather than telemetryV2 keeps it through a rollback, with no comment saying so. Either null it too or give it the same one-line note usbControlSet got.
🟩 (5) (code-quality, c=100) — internal/commands/protect_rbac_refs.go:36, :43, :54 vs :85, :141, :203: dropping omitempty does not achieve the stated goal in JSON. The three converters leave the slices nil when the object holds no members (e.Roles = append(...) on a zero value), and a nil slice marshals as roles: null in JSON but roles: [] in YAML — confirmed with a standalone probe. Nothing breaks (the converters always send [] on the way in, and the round-trip harness passes in both formats), but a JSON backup now carries an explicit null where the comment promises a self-describing empty list, and the two formats' documents for the same object disagree. Initialise Roles/Groups to []string{} in groupToExport, userToExport and apiClientToExport.
🟩 (6) (usability, c=75) — internal/commands/protect_backup.go:1259-1264: the tenant guard compares ProtectURL as a raw string. resolveProtectClient (root.go:1155-1231) assigns the URL verbatim from --url, JAMFPROTECT_URL, the profile or JAMF_URL with no normalisation, so https://x.protect.jamfcloud.com/ and https://x.protect.jamfcloud.com are two tenants to this check — a legitimate re-run is refused with holds a backup of A, not B naming two strings that differ by a slash. Compare a normalised host (trim the trailing slash, fold case) instead.
Review coverage
- Design and architecture: the two blocking items are both design gaps in the round-1 fixes rather than coding slips — (1) is a provenance record that is not authoritative, (2) is a predicate answering a narrower question than its call sites ask. The
clearAbsentparameterisation and the extractedprotectGroupUpdateSatisfiedare otherwise the right shapes, and the CLAUDE.md Conventions rule about shadowing flags is exactly the kind of thing that stops a third instance. - Correctness: findings (1), (2), (4), (5), (6). Traced the whole new tenant-guard path including
readProtectBackupMeta's failure modes, the unconditional_metawrite, theclearAbsentbranch against the SDK'sbuildPlanVariables, and the extension-set unification across all three sites. - Security: finding (1) from round 1 is closed. Confirmed the chmod gate covers both modes the code can produce (
0o644/0o600only,:1307-1309) and that the newmode != 0o644guard cannot leave a sensitive document loose. The doc additions about0600not surviving git are accurate. - Cross-repo / wire contracts: re-resolved every SDK claim the fixes rest on against
jamfprotect-go-sdk@v0.8.0/jamfprotect/plan.gorather than the PR comment —TelemetryV2Null→vars["telemetryV2"] = nil(:412-413),USBControlSeta bare*stringwith no null sibling (:160,:417-419), and the nil-omission for the three list fields (:400-408). The author's stated SDK limitation is real. - Reliability: finding (2). The overrides report-and-continue and the
_failureswrite-error propagation are both correct; the restore partial-failure block matchespro_backup.goline for line. - Performance: (14) declined with a reason that holds. The one new API call is
GetGrouppergroups applyupdate, which is one round trip on an interactive single-object command. - Test coverage: +318 lines in
protect_backup_test.goand +94 inprotect_conversions_test.go, and they are real tests, not shape assertions — six of the seven fixes I mutation-checked were caught by a named test with a useful failure message. The one that survives is finding (3): theclearAbsentcall site.TestProtectRestoreUsesTheRootDryRunFlag'sParseFlags-then-ShorthandLookuppattern is the right way to pin a Cobra merge behaviour. - Code quality: finding (5). The comments carry wire facts and failure modes rather than narration, and each new test opens with why the bug was possible.
- Simplification: no new abstraction;
protectGroupUpdateSatisfiedis the correct extraction even though its contract needs widening. - [na] Frontend concerns: no UI, site, or template files in the incremental diff.
- Documentation currency:
CLAUDE.mdandskills/skills/jamf-backup/SKILL.mdwere read against the code. Every claim checks out — theclearAbsentparagraph, the prune-refusal sentences, theos.Chmodwire fact, the0600-is-lost-on-clone warning in both places, theprotectGroupUpdateSatisfiedsharing note, and the new Conventions rule. Two wordings to tighten rather than findings: theLongtext at:1196-1199now runs a sentence into the pre-existing paragraph (--no-prune writes alongside them. Without that a backup directory…), and the SKILL.md line "keep one directory per tenant (or pass--no-prune)" recommends the action that finding (1) shows is unsafe to repeat. - Scope and decomposition: author took the single-PR branch with a stated rationale and a note for next time. No severity; the recommendation never held the merge.
- Project rules compliance: no
.claude/rules/. Re-checked againstCLAUDE.md— no new credential flags, nothing under*/generated/,protect_*prefixes, Protect conventions intact, and the PR's own new Conventions rule is satisfied by the code it ships (protect restoreno longer declares--dry-run). Note for a follow-up ticket, not a finding: that rule already has pre-existing violations elsewhere —pro_jcds.go:463re-declares--dry-run/-n,version.go:47re-declares-v, andpro_setup.go/protect_setup.go/school_setup.go/config.gore-declare--url.
Scope of review
Incremental diff 7e90e09..433b72e (12 files, +659/−64) in the worktree at /tmp/pr-review-Jamf-Concepts-jamf-cli-330, read in full — no sampling was needed at this size. Baseline is my round-1 review at 7e90e09 (#330 (review)), whose commit is reachable from the new head, so the diff bisects cleanly.
Beyond the incremental diff: internal/commands/protect_groups.go and protect_rbac_refs.go in full at head, protect_backup.go:1240-1440 and :1500-1640 at head, internal/commands/root.go:1150-1235 (Protect URL resolution) and :790-815 (persistent flag set), internal/exitcode/exitcode.go (PartialOrPropagate), internal/commands/pro_backup.go:350-365, and jamfprotect-go-sdk@v0.8.0/jamfprotect/plan.go.
Executed: go build ./..., go vet ./internal/..., go test ./internal/commands/ ./internal/protect/ (all pass); golangci-lint run ./internal/commands/... reports 6 staticcheck issues, all pre-existing in pro_device_platform_test.go, which this PR does not touch (confirmed present on origin/main). Six mutation spot-checks on the fixes, each applied to a staged copy swapped in by rename and restored the same way, with git status --porcelain verified empty afterwards: removing the os.Chmod, stubbing the tenant guard to false, restoring the overrides early return, narrowing the singleton extension set, replacing restore's PartialOrPropagate with a plain error — all five were caught by the named new test. Flipping planExportToInput(…, true) to false at protect_backup.go:533 was not caught, which is finding (3). Two behavioural probes reproduced findings (1) and (2) as throwaway tests in the same fashion. Flag behaviour was checked on a binary built at 433b72e.
Prior feedback since the baseline: one issue comment from @neilmartin83 (2026-08-20T14:35Z) walking through all 14 findings. No new review comments, no other reviewers, nothing unaddressed from anyone else. The comment's claims were treated as claims: each was checked against the code, and the two
Not verified: no live tenant, so every server-behaviour claim remains as reported — in particular that updateGroup refuses any update to a connection-less local access group (finding (2) assumes the author's wire fact is right and argues about the CLI's response to it), and that a GraphQL exceptionSets: [] actually clears rather than being rejected. The SDK-side half of each was checked against v0.8.0.
Never-run specialist lanes. No agents were dispatched this round either (session policy: the orchestrator ran every lane inline). Eligible but never dispatched on this PR across both rounds: security-reviewer, usability-reviewer, performance-reviewer, scope-reviewer, silent-failure-hunter, devil-advocate, test-quality-reviewer. fidelity-reviewer remains inapplicable — no Jira key in the title, branch or description and no linked issue, so there is no spec to check against.
What's done well
- ✅ Every new test is written from the failure it prevents, and six of the seven survive a mutation check.
TestBackupRefusesToPruneAnotherTenantsDirectory's third sub-test — "the owning tenant still prunes" — is the one most authors skip: it pins that the new guard did not quietly turn the feature off, which is the usual cost of adding a safety check. - ✅ Reporting the
usbControlSetgap instead of papering over it, with the reason (*stringwith noNullsibling,""untested on the wire), a comment at the site, and a line in CLAUDE.md. I verified the claim against the SDK and it is exactly right. A fix that names what it could not fix is worth more than one that quietly sends"". - ✅ Turning the
-o/-ncollision into a written repo rule in CLAUDE.md's Conventions rather than a local fix, and pinning the Cobra behaviour with a test that parses the flag set instead of asserting on help text. That is the half of the round-1 finding that stops the third occurrence.
🤖 Generated by the pr-review:review skill · re-reviewed head 433b72e (baseline 7e90e09)
Generated by pr-review v1.30.0, a Jamf Claude Code skill
…p guard swallowing a change Round-2 review of #330 found both round-1 fixes incomplete in a way that reopened the risk they closed. The tenant guard trusted a record that was last-writer-wins and written last. Following its own advice — "pass --no-prune to write alongside them" — relabelled the directory as the second tenant's, so that tenant's next run passed the guard and pruned the first tenant's documents. Provenance is now cumulative (every tenant that has written appends itself to _meta's `tenants`), claimed before any document is exported rather than after (an interrupted run left documents with no provenance at all), and single-file (switching --format left a second manifest that could answer for the directory). A _meta that is present but unparseable beside documents refuses the run outright, --no-prune included: rewriting a manifest it could not read would drop another tenant's claim and hand the next run a guard that passes. The comparison folds case and a trailing slash, since resolveProtectClient takes the URL verbatim from four sources and two spellings of one tenant were refusing each other. protectGroupUpdateSatisfied answered "the flag already matches" to a question its callers ask as "is there nothing to send". A document that also granted a role printed "nothing to update" and exited 0 with the grant dropped, and restore reported the group as applied — a worse failure than the raw server error the guard replaced. groupUpdateWouldChange now compares every field updateGroup's mutation can carry plus the connection it cannot, and a non-empty diff is an error naming what could not be applied. Also from the review: pin the clearAbsent argument at restore's call site (its mutation survived the whole suite), note that the legacy telemetry reference is unconverged for the same reason usbControlSet is, and initialise the RBAC export reference lists so a JSON document does not carry a null where the type promises an empty list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round 2 addressed in 236f75d. Both blockers plus all four nice-to-haves. 🟧 (1)
|
ktn-jamf
left a comment
There was a problem hiding this comment.
Warning
236f75d. Both round-2 blockers are genuinely closed, and this is the strongest fix round of the three: eight mutation checks, eight caught by a named test. The one blocking item is not a regression in those fixes — it is a sibling of round-1 (5) that neither of the first two rounds looked for.
Blocking: (1) protect backup advertises the root -n, --dry-run and ignores it, so a "preview" run deletes files.
Rating: 4/5
- Would be a 5 with (1). Every prior finding — both 🟧 and all four 🟩 — is fixed, and each fix fails a named test when reverted.
- Coverage:
security-reviewer,usability-reviewer,performance-reviewer,scope-reviewer,silent-failure-hunter,devil-advocateandtest-quality-reviewerhave never run on this PR across all three rounds — their lanes were covered inline by the orchestrator, so the rating reflects one reader's pass over those dimensions rather than seven independent ones.
Prior findings status (6 items)
| # | Location | State | Notes |
|---|---|---|---|
(1) c=100 |
protect_backup.go:1371-1377 → now :1181-1206, :1407-1438 |
✅ Fixed | Provenance is authoritative in four ways, and I verified each independently. Cumulative: Tenants is append-only via tenantURLs(), which reads the legacy TenantURL too. Written first: the claim write at :1427 precedes the export loop; the counts write at :1542 replaces it. Single-file: writeProtectBackupMeta removes the other extension's copy. Unreadable refuses: readProtectBackupMeta now returns three states and protectBackupProvenanceUsable refuses on broken-beside-documents, --no-prune included. Executed the round-2 three-call sequence: run 3 is refused naming staging.protect.jamfcloud.com, and zz-staging-only.yaml is still on disk. Mutation-checked five ways (see Scope) — every one caught. The four edge cases I was asked to chase: a directory with no _meta, and one whose _meta parses to no tenant, still prune → 🟩 (2); empty TenantURL is skipped by tenantURLs() and harmless; a first-ever --no-prune run does record provenance (probed: tenants=[https://staging.x], and the next tenant's pruning run is refused); a --format switch leaves exactly one manifest (probed). |
(2) c=100 |
protect_groups.go:114-125, :186-196 |
✅ Fixed | groupUpdateWouldChange (:138-171) compares Name, AccessGroup, RoleIDs (both sides sorted, so an unedited export re-applies as a no-op) and the connection an update cannot carry — the last is reachable only when the document drops a connection, since a document that keeps one fails isLocal and never enters the branch. A non-empty diff is an error naming the fields; updateGroups stays at 0. Mutation-checked: stubbing the diff to "" fails both new sub-tests with expected an error: the role grant cannot be applied, so success would drop it and the restoreGroup equivalent. Roles-only change on a local access group now errors — never silently succeeds. |
(3) c=100 |
protect_backup.go:533 (now :566) |
✅ Fixed | TestPlansRestoreAsksForAbsentMembershipToBeCleared drives the plans resource's own Restore closure through a mock that records the PlanInput, and asserts all three lists non-nil plus TelemetryV2Null. Mutation-checked: flipping the call site to false — the exact revert that left round 2 green — now fails it. This is the right test: it pins the decision at the call site rather than the function. |
(4) c=100 |
protect_plans.go:534-555 |
✅ Fixed | Took the one-line-note option, and the note is correct: PlanInput.Telemetry is a bare *string and buildPlanVariables omits a nil key, so it is unconverged for the same reason as usbControlSet, not the reason the old comment implied. The comment now also says the gap is reachable because planToExport reads the legacy field deliberately. |
(5) c=100 |
protect_rbac_refs.go:36, :43, :54 |
✅ Fixed | Roles/Groups initialised to []string{} in all three *ToExport functions. Mutation-checked: reverting all three fails TestRBACExportsCarryEmptyListsInBothFormats for group, user and api-client in JSON. The failure message's wire fact (roleIds: None is not of type 'array') is the one already recorded at CLAUDE.md:268, not a new claim. |
(6) c=75 |
protect_backup.go:1259-1264 |
✅ Fixed | normaliseTenantURL trims whitespace, strips the trailing slash and folds case; tenantURLs() dedupes and sorts. Mutation-checked: making it identity fails TestBackupTenantGuardNormalisesTheURL. That test asserts the right pair of things — the differently-spelled re-run is allowed and still prunes, so a normalisation that silently disabled pruning would not pass. |
Findings
🟧 (1) (reliability, c=100) — internal/commands/protect_backup.go:1392: protect backup advertises the root -n, --dry-run and ignores it, so a "preview" run deletes files
This is the last member of round-1 finding (5)'s family, on the command that round never probed. --dry-run is a root persistent flag (root.go:800, "preview changes without executing"), protect backup declares no local flag of that name, so it is inherited cleanly and appears in the command's own help:
$ jamf-cli protect backup --help
Global Flags:
-n, --dry-run preview changes without executing
runProtectBackup never reads it. grep -n dryRun internal/commands/protect_backup.go finds it only in the restore path (:1640, :1644, :1723, :1748) — the half round 2 fixed. So the flag parses, is documented on the command, and does nothing, on the one command in this PR that removes files without a confirmation prompt and without a jamf:destructive annotation (which is exactly the asymmetry round-1 (2) called out and this round's guard was built around).
That is also out of step with the rest of Protect: confirmDelete, confirmAction and confirmReplace (protect_helpers.go:83, :106, :129) all branch on dryRun and print [dry-run] Would …. pro backup sets a partial precedent for -n still writing files — the dryRunClient wrapper (root.go:716) only intercepts mutating HTTP — but pro backup never deletes anything, so nothing in the repo makes deletion-under--n precedented.
Failure scenario (executed in a scratch copy — probe TestProbeBackupIgnoresDryRun, dryRun set to true around a second runProtectBackup): a directory holds unified-logging-filters/zz-gone.yaml from an earlier run; the object has since been deleted in the tenant; the operator runs protect backup -n --output <dir> to see what a re-run would do. Result: zz-gone.yaml is still present = false. The file is gone, and the operator never got the preview the help promised. Reachable from the PR as shipped, by using the flag as documented.
Suggested fix: the smallest correct shape is to treat -n as implying no-prune and say so, rather than teaching the whole export loop about it:
func runProtectBackup(ctx context.Context, cliCtx *registry.CLIContext, outputDir, format, filter, exclude string, noPrune bool) error {
if format != "yaml" && format != "json" {
return fmt.Errorf("invalid --format %q: must be yaml or json", format)
}
+ // -n is a root persistent flag and shows up in this command's help, so it has
+ // to mean something here. Backup's only destructive act is the prune, so a
+ // preview run reports what it would delete and deletes nothing. Writing the
+ // documents themselves is what --output asked for.
+ if dryRun {
+ noPrune = true
+ }and where pruned is reported, prefix the lines [dry-run] would prune …. If the intent is instead that backup writes nothing at all under -n, say that in the Long text; either contract is fine, silence is not.
Fixed when: protect backup -n --output <dir> leaves every file on disk that a run without -n would have pruned, reports what it would have removed, and a test asserts it by setting the package dryRun var the way TestProtectRestoreUsesTheRootDryRunFlag does for restore.
That is the only blocking item — with it addressed this is merge-ready.
Nice-to-have suggestions (4 items)
🟩 (2) (correctness, c=100) — internal/commands/protect_backup.go:1182-1187: the guard refuses an unreadable _meta beside documents but waves through an absent or contentless one
protectBackupPruneAllowed returns nil on !found, and tenantURLs() returns empty for a manifest that parsed to its zero value — both read as "no tenant to protect" and permit the prune. Two probes, both executed:
P1 documents on disk, _meta.yaml deleted → run err=<nil>, staging doc present=false
P2 documents on disk, _meta.yaml truncated to 0 → run err=<nil>, staging doc present=false
P2 is the sharper one, because a zero-length manifest is precisely what an interrupted or ENOSPC write leaves — and interruption safety is this round's stated reason for writing the manifest first. So the fix's own worst case lands in the branch the fix does not cover, and it lands there looking clean rather than broken. Both need a second tenant afterwards to cause loss, which is why this is 🟩 and not a reopened blocker.
The helper to close it already exists and is already called on the adjacent path: extend protectBackupProvenanceUsable (or protectBackupPruneAllowed) to refuse when protectBackupHoldsDocuments(dir) is true and the manifest names no tenant, absent or empty. The message can be the same one, with "cannot be read" replaced by "records no tenant".
🟩 (3) (usability, c=100) — internal/commands/protect_backup.go:1200-1203: once a second tenant has run --no-prune, the owning tenant permanently loses pruning, and the error text names no way back
Cumulative provenance is the right call, and SKILL.md states the consequence honestly ("a later run by either tenant is still refused"). Executed (P4): staging backs up → prod runs --no-prune as the refusal recommends → staging's own pruning run into its own directory is now refused with holds a backup of https://prod.x, not https://staging.x. Both remedies the message offers — a different --output, or --no-prune — abandon pruning; the only way to reclaim the directory is to hand-edit _meta, which the message does not mention and the docs do not either. Add a third clause naming it (or remove the other tenant from <dir>/_meta.yaml if you know those documents are gone), since the operator who reads this error is usually the one who caused it one run earlier.
🟩 (4) (code-quality, c=100) — internal/commands/protect_backup.go:41-43, :1434: the claim write overwrites the manifest's Resources/Counts with empties, and the struct comment promises a reader that does not exist
The claim manifest carries Resources: []string{} and Counts: map[string]int{}. Executed (P5): a good run records resources=[unified-logging-filters] counts=map[unified-logging-filters:2]; a run that then fails mid-export leaves resources=[] counts=map[] beside the previous run's intact documents — the manifest now describes a backup that never happened. Nothing breaks, because grep finds no reader of either field: readProtectBackupMeta's three call sites (:1163, :1182, :1407) are all inside backup, and restore never opens _meta at all. Which is the other half of this — the type comment says the manifest exists "so a restore can report the provenance of what it is about to write", and no restore path does. Either drop the promise from the comment, or carry prior.Resources/prior.Counts into the claim write so an interrupted run degrades to stale rather than to blank.
🟩 (5) (test quality, c=100) — internal/commands/protect_backup_test.go:1470-1478: TestRBACExportsCarryEmptyListsInBothFormats asserts on the substring null anywhere in the document
The assertion is !strings.Contains(string(data), "null") over the whole marshalled doc. It catches the defect it was written for — mutation-checked, it fails all six format×resource combinations when the initialisers are reverted — but it also fails for an object legitimately named zz-nullable-test, or the day any string field on these exports can hold null. Assert on the parsed shape instead (unmarshal to map[string]any and require roles/groups to be a non-nil []any), or scope the substring to ": null".
Review coverage
- Design and architecture: both fixes changed shape rather than adding a special case, which is the right answer to the round-2 diagnosis.
readProtectBackupMeta's three-state return (absent / parsed / broken) is the structural fix — round 2's defect was that "unknown" and "fresh" were the same value, and the new signature makes them different types of answer rather than the same answer with a comment.groupUpdateWouldChangewidens a predicate's question to match what its call sites ask, and putting the connection field in the diff (the field an update cannot carry) rather than in the no-op branch is the subtle half, done right. Finding (2) is where the new shape still has one branch that answers "no tenant" and "no manifest" identically. - Correctness: findings (2), (4). Traced the whole new provenance path at head —
tenantURLs()'s dedupe/sort and its legacy-TenantURLfold,protectBackupHoldsDocuments's singleton naming (checked againstprotectPruneStale:1252-1255, sameres.Name+extconvention, so the two agree),writeProtectBackupMeta's write-then-remove ordering, and the guard/claim/prune call order inrunProtectBackup:1403-1438(the guard runs before anything is written — confirmed by reading, and by the unreadable-manifest test asserting the document survives). Re-derivedgroupUpdateWouldChange's reachability for each of its four branches:nameandaccessGroupare dead in this branch (lookup is by name; both flags are true to get here),rolesandconnectionare the live ones. - Security: nothing new. The
os.Chmodgate from round 1 is untouched and still covers both producible modes._metais written0o644and carries a tenant URL, not a credential, unchanged from round 2.writeProtectBackupMeta'sos.Removeloop is bounded toprotectBackupMetaFile+extinsidedir— no path the caller controls reaches it. - Cross-repo / wire contracts: re-resolved the two claims the new comments rest on against
jamfprotect-go-sdk@v0.8.0/jamfprotect/plan.gorather than the PR comment —PlanInput.Telemetryis a bare*string(plan.go:153) with noNullsibling next toTelemetryV2Null(:155), andbuildPlanVariablesomits a nil key (:409-410), so the new legacy-telemetry note is accurate.jamfprotect.GroupInput(group.go:91-96) carries exactlyName,ConnectionID,AccessGroup,RoleIDs, which is whatgroupUpdateWouldChangecompares — the field set is complete, not a sample. - Reliability: finding (1). Also re-checked that the claim write cannot itself brick a fresh run (a failure there returns before any document is written) and that
protectBackupHoldsDocumentsswallowingos.ReadDirerrors is safe — it only ever downgrades to "no documents", which is the conservative direction for the positive check but is the permissive direction for the guard, i.e. the same gap as finding (2). - Performance: no change.
protectBackupHoldsDocumentswalks the resource table with oneStat/ReadDirper resource, and only on the error path. - Test coverage: +303 lines, and this is the round where the tests carry their weight. Eight mutation checks, eight caught (details in Scope). Two of the new tests are better than the fix they pin:
TestBackupClaimsTheDirectoryBeforeExportingreproduces "interrupted run" with a file placed where a resource directory belongs — a real, deterministic mid-export failure rather than a mock — andTestBackupTenantGuardNormalisesTheURLasserts the normalised match still prunes, closing the "fixed it by turning the feature off" hole.TestPlansRestoreAsksForAbsentMembershipToBeClearedfixes exactly the gap round 2 identified, at the call site rather than the function. Residual: finding (5)'s brittle substring, and no test covers the absent/empty-manifest branch of finding (2). - Code quality: findings (4), (5). Read every new comment against the code — this was the round-2 lesson and it landed.
Tenants's field comment,normaliseTenantURL's four-sources rationale,readProtectBackupMeta's three-states paragraph,protectBackupProvenanceUsable's "same data loss, one run later", theLongtext's new paragraph andgroupUpdateWouldChange's "sorted because the server returns its own order" all check out against behaviour I executed. The one comment that overstates is the pre-existingprotectBackupMetadoc line in finding (4). Nit not worth a finding:sort.Stringsinprotect_groups.go:161-162alongside a freshslicesimport —slices.Sortwould drop the second import. - Simplification: no new abstraction beyond the two small helpers, and both earn their names.
tenantURLs()as a method on the manifest is the right place for the legacy-field fold. - [na] Frontend concerns: no UI, site, or template files in the incremental diff.
- Documentation currency:
CLAUDE.md:255and:271andskills/skills/jamf-backup/SKILL.md:47read against the code, sentence by sentence, since round 2 found a comment asserting the opposite of its code. Every claim holds: "cumulative and written first", "the comparison folds case and a trailing slash", "removing any copy an earlier--formatleft in the other extension so one manifest is authoritative", "present but unparseable … refuses the run outright,--no-pruneincluded", and the group paragraph's field list (name, accessGroup, roleIds — sorted … plus the connection it cannot). SKILL.md's line no longer recommends--no-pruneas a bare workaround and now states what it records — which is what round 2 asked for. Both round-2 wording notes are closed. The undocumented behaviour is finding (1): nothing in either document says-ndoes not apply toprotect backup. - Scope and decomposition: unchanged and settled. The author took the single-PR branch with a stated rationale and a note for next time; the recommendation never held the merge.
- Project rules compliance: no
.claude/rules/. Re-checked againstCLAUDE.md— no new credential flags, nothing under*/generated/,protect_*prefixes intact. The PR's own new Conventions rule (don't declare a local flag that shadows a root persistent one) is still satisfied by the code it ships; finding (1) is the adjacent failure the rule does not cover — inheriting a root flag and then ignoring it — which is worth a second sentence in that rule.
Scope of review
Incremental diff d7d3d89..236f75d (8 files, +587/−41) in the worktree at /tmp/pr-review-Jamf-Concepts-jamf-cli-330, read in full. Baseline is my round-2 review at 433b72e (#330 (review)). d7d3d89 between them is a pure merge of main (parents 433b72e + d4fb969) bringing in merged PRs #332 and #335; it touches nothing on this PR's surface but one CLAUDE.md doc line, so the new work is exactly the range above and it bisects cleanly.
Beyond the incremental diff: internal/commands/protect_backup.go:1095-1230 and :1380-1560 at head, protect_groups.go and protect_rbac_refs.go in full at head, protect_helpers.go:75-135 (the dryRun branches finding (1) compares against), internal/commands/root.go:716, :800 (dryRunClient, the persistent flag), protect_conversions_test.go:20-75 (the mock's embedded-interface base, which is why the three new plan methods compile), and jamfprotect-go-sdk@v0.8.0/jamfprotect/plan.go.
Executed: go build ./..., go vet ./internal/..., go test ./internal/... — all pass. golangci-lint run ./internal/commands/... reports 0 issues; the six pre-existing staticcheck findings I reported in round 2 are gone from main, as the author states. Flag behaviour checked on a binary built at 236f75d.
Eight mutation checks, each applied to a full copy of the worktree in a scratch directory (the worktree itself was never modified — git status --porcelain empty before and after, verified):
| Mutation | Caught by |
|---|---|
tenants := prior.tenantURLs() → var tenants []string (last-writer-wins) |
TestBackupProvenanceSurvivesANoPruneRunByAnotherTenant |
claim write removed (_meta written last again) |
TestBackupClaimsTheDirectoryBeforeExporting — a run that died mid-export must still leave provenance |
protectBackupProvenanceUsable condition stubbed false |
TestBackupRefusesAnUnreadableManifestBesideDocuments/refused,_--no-prune_included |
normaliseTenantURL → identity |
TestBackupTenantGuardNormalisesTheURL |
| stale-manifest removal loop deleted | TestBackupKeepsOneManifestAcrossAFormatChange |
groupUpdateWouldChange diff stubbed to "" |
both new TestGroupsApplySharesTheAccessGroupGuard sub-tests |
planExportToInput(ctx, e, r, true) → false |
TestPlansRestoreAsksForAbsentMembershipToBeCleared |
Roles/Groups initialisers reverted in all three *ToExport |
TestRBACExportsCarryEmptyListsInBothFormats (all 3 resources, JSON) |
Six behavioural probes in the same scratch copy, throwaway tests, results quoted in the findings above: missing _meta beside documents (P1), zero-length _meta (P2), first-ever --no-prune run into an empty directory (P3), the owning tenant after a foreign --no-prune run (P4), manifest counts after an interrupted run (P5), and protect backup under the root --dry-run (P6, finding (1)).
Prior feedback since the baseline: one issue comment from @neilmartin83 (2026-08-20T15:32Z) walking through both blockers and all four nice-to-haves, including its own eight mutation checks. Every claim in it was treated as a claim and checked against the code — this round they all hold, and the eight mutations it names are the eight I reproduced independently. No review comments, no other reviewers, nothing unaddressed from anyone else.
Not verified: no live tenant, so the two server-behaviour claims the group fix rests on remain as reported — that updateGroup refuses any update to a connection-less local access group, and that exceptionSets: [] clears rather than being rejected. The SDK-side half of each was checked against v0.8.0. Nothing in this round's diff depends on a wire fact I could not resolve in the SDK source.
Never-run specialist lanes. No agents were dispatched this round (session constraint: the orchestrator cannot spawn subagents, and ran every lane inline). Eligible but never dispatched on this PR across all three rounds: security-reviewer, usability-reviewer, performance-reviewer, scope-reviewer, silent-failure-hunter, devil-advocate, test-quality-reviewer. fidelity-reviewer remains inapplicable — no Jira key in the title, branch or description and no linked issue, so there is no spec to check against.
What's done well
- ✅ The fix for the provenance blocker is not the fix that was suggested — it is a better one, in a way that is easy to check. Round 2 asked for a cumulative record and a refusal on a corrupt manifest. This ships both, plus two the review did not ask for: writing the manifest first so an interrupted run still leaves a claim, and collapsing the two-manifest state a
--formatswitch creates soreadProtectBackupMetacannot answer with the stale extension. Both of those are ways the suggested fix would still have been blind, found by the author rather than the reviewer. - ✅ Extending
protectBackupProvenanceUsableto run under--no-prune, and saying why in the comment ("the run is about to rewrite the manifest … same data loss, one run later"). That is a deliberately stronger stance than the finding asked for, with the reasoning written down at the site — and the test proves the stance by looping the sub-test over bothnoPrunevalues. - ✅
groupUpdateWouldChangeputting the connection in the diff. The finding only asked for roles. Recognising that a document dropping a connection is also asking for something update cannot express — and that this belongs in the "could not apply" bucket rather than the no-op one — is the generalisation that stops a fourth instance of this bug class. - ✅ Eight mutation checks reported with the mutation and the test that caught it, reproduced here independently and matching exactly. Round 2's one surviving mutation is now caught. A fix round that arrives with its own falsification evidence is the reason this review took hours rather than days.
🤖 Generated by the pr-review:review skill · re-reviewed head 236f75d (baseline 433b72e)
Generated by pr-review v1.30.0, a Jamf Claude Code skill
…o owner on record
`protect backup` inherited the root `-n, --dry-run` cleanly, so it appeared in the
command's own help — and `runProtectBackup` never read it. A "preview" run pruned
files, on the one command in this product that removes them with no confirmation
prompt and no `jamf:destructive` annotation. A documented flag that does nothing is
worse than an absent one, because the operator used it as documented.
`-n` now gates the prune and nothing else: documents are still written, because that
is what `--output` asked for and `pro backup -n` writes its files too, and every
removal the run would have made is reported `[dry-run]`. The gate is on
`protectPruneStale`'s `os.Remove` rather than on the call — deciding what to prune is
what produces the list the operator asked for, so skipping the call would delete
nothing, report nothing, and read as "there was nothing to prune".
Four smaller things, all in the same guard:
- The prune guard refused an *unreadable* `_meta` beside documents but waved through
an absent, zero-length or tenant-less one; all three read as "fresh directory". The
zero-length case is the sharp one — it is exactly what an interrupted or ENOSPC
write leaves, so the state the write-the-claim-first ordering exists to survive
landed in the one branch the guard did not cover, looking clean rather than broken.
It refuses the prune rather than the run, because unlike the unreadable case there
is no claim to overwrite and lose, which makes `--no-prune` a real way forward: it
puts the tenant on record and the next run prunes normally. The refusal says so.
- The tenant-mismatch refusal offered two remedies that both abandon pruning for the
directory. The operator reading it is usually the one who caused it one run earlier,
by taking the `--no-prune` advice, so it now names the manifest to edit — the real
file, since the extension follows `--format`.
- The claim write blanked the manifest's `Resources`/`Counts`, so an interrupted run
left a manifest describing a backup that never happened beside the intact documents
of the one that did. It carries the previous run's inventory forward instead: stale
is a worse answer than current and a much better one than blank.
- `protectBackupMeta`'s doc comment promised a restore that reports provenance. No
restore path opens the manifest; the tenant guard is its only programmatic reader.
`TestRBACExportsCarryEmptyListsInBothFormats` asserted on the substring `null`
anywhere in the document. It caught its defect, but it also failed for an object
legitimately named `zz-nullable-test` (verified) and would fail the day any string
field on these exports can hold null. It now asserts the parsed shape.
`setDryRun` restores the package var, which matters more than setting it: `dryRun` is
bound to the persistent flag, so any test calling `ParseFlags("-n")` leaked a true
into every test after it — a preview run's worth of skipped pruning inside a test
asserting a prune. `TestProtectRestoreUsesTheRootDryRunFlag` was doing exactly that.
Six mutations applied to a scratch copy, six caught by a named test — including the
smaller `if dryRun { noPrune = true }` shape, which deletes nothing but reports
nothing either and fails the reporting sub-test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round 3 addressed in 🟧 (1)
|
| # | Mutation | Test that caught it |
|---|---|---|
| M1 | protectPruneStale(…, !dryRun) → true (the shipped defect) |
TestBackupUnderTheRootDryRunFlagPrunesNothing |
| M2 | your suggested if dryRun { noPrune = true } shape |
…/what_it_would_have_pruned_is_reported only — deletes nothing, reports nothing |
| M3 | drop the no-tenant-on-record refusal | TestBackupRefusesToPruneWhenNoTenantIsOnRecord, all three states |
| M4 | drop the manifest-path clause | TestBackupTenantRefusalNamesTheManifestToEdit |
| M5 | claim write back to []string{} / map[string]int{} |
TestBackupClaimCarriesThePreviousInventoryForward |
| M6 | RBAC list initialisers back to nil |
TestRBACExportsCarryEmptyListsInBothFormats (group, user, api-client in JSON) |
One thing your round-3 findings did not name
Writing the dry-run test surfaced a leak in the existing TestProtectRestoreUsesTheRootDryRunFlag: dryRun is bound to the persistent flag, so its ParseFlags([]string{"-n"}) writes the package var and nothing put it back. It leaked a true into every test that ran after it. Harmless while nothing downstream read dryRun — and it stopped being harmless the moment backup did: my first run of the new suite failed with a prune that never happened, three sub-tests deep, for that reason. Both tests now go through setDryRun, which restores.
Coverage note
On your rating note about the never-run specialist lanes — agreed, and worth saying plainly that the same constraint applied here: no subagents, so security-reviewer, usability-reviewer, performance-reviewer, scope-reviewer, silent-failure-hunter, devil-advocate and test-quality-reviewer have still never run on this PR. Finding (1) being a sibling of round-1 (5) that two rounds missed is the concrete cost of that, and it is a fair reason to treat the 4/5 as one reader's pass rather than seven.
Verified: go build ./..., go vet ./internal/..., go test ./... all pass; golangci-lint run ./internal/commands/... reports 0 issues; make verify-generated clean; -n and the new help text checked on a binary built at c13f5bc.
ktn-jamf
left a comment
There was a problem hiding this comment.
Tip
✅ Merge-ready — round 4 on c13f5bc. The round-3 blocker is closed, and closed better than the fix that was suggested: the gate sits on the os.Remove inside protectPruneStale rather than on the call, so a preview still decides what would go and prints it. All four nice-to-haves are fixed too. Six mutations applied, six caught by a named test — including one that applies my own suggested diff and fails it.
No blocking items. Three 🟩 remain, all follow-up material.
Rating: 5/5
- Every prior finding is fixed and every fix fails a named test when reverted. The flag sweep this round was asked to do comes back clean on both
backupandrestore. What is left is one product-wide gap the PR inherits rather than creates, and two sentences of doc precision. - Coverage:
security-reviewer,usability-reviewer,performance-reviewer,scope-reviewer,silent-failure-hunter,devil-advocateandtest-quality-reviewerhave never run on this PR across all four rounds — the session cannot spawn subagents, so I ran each lane inline myself. Read the rating as one reader's pass over those seven dimensions, not seven independent ones. Round 3's blocker was the concrete cost of that; a human skim of the whole-PR diff is still worth an hour.
Prior findings status (5 items)
| # | Location | State | Notes |
|---|---|---|---|
(1) c=100 |
protect_backup.go:1392 → now :1302, :1545, :1611, :1642 |
✅ Fixed | protectPruneStale gained a remove bool, passed !dryRun from both call sites, and the reporting block branches to [dry-run] Would prune …. Executed the round-3 scenario: zz-gone.yaml survives under -n, zz-keep.yaml is still written, and the removal is reported. Mutation-checked twice. M1 (!dryRun → true, the shipped defect) fails …/the_file_a_real_run_would_prune_survives with -n deleted unified-logging-filters/zz-gone.yaml. M2 applies my own suggested shape (if !noPrune && !marshalFailed && !dryRun) and fails …/what_it_would_have_pruned_is_reported — deletes nothing, reports nothing. The author is right that my diff was the weaker fix and the test exists to say so. Third sub-test pins the flag surface itself: no local dry-run, -n parses, ShorthandLookup("n").Name == "dry-run", and Long mentions --dry-run. |
(2) c=100 |
protect_backup.go:1182-1187 → now :1198-1219 |
✅ Fixed | protectBackupPruneAllowed now drops the !found early return and refuses when tenantURLs() is empty and protectBackupHoldsDocuments(dir). All three states covered — absent, zero-length, and parses-cleanly-to-no-tenant — plus the empty-directory case still allowed. Executed: my round-3 P1 and P2 probes both now error and leave the document on disk. Mutation-checked (M3, guard short-circuited): all three sub-tests fail. The test also asserts the way forward works — --no-prune once, then the next run prunes for real — which is the assertion that stops "fixed it by disabling it". Fixing this in protectBackupPruneAllowed rather than protectBackupProvenanceUsable is the right call and the comment gives the reason: there is no claim here to overwrite, so --no-prune is a remedy rather than a bypass. |
(3) c=100 |
protect_backup.go:1200-1203 → now :1223-1251 |
✅ Fixed | Third clause added, naming protectBackupMetaPath(dir) — the file on disk, resolved across .yaml/.yml/.json, not the _meta stem. Executed: the refusal now reads … or if that tenant's documents are gone, delete its entry from <dir>/_meta.yaml. Mutation-checked (M4, back to the bare constant): TestBackupTenantRefusalNamesTheManifestToEdit fails, and it asserts the full path rather than the stem, so a fix that named _meta would not pass. |
(4) c=100 |
protect_backup.go:41-43, :1434 → now :42-47, :1495-1516 |
✅ Fixed | Both halves. The claim now carries prior.Resources/prior.Counts forward (nil-guarded, and the comment gives the JSON-null reason, which is finding (5) one field over). Mutation-checked (M5, back to empties): TestBackupClaimCarriesThePreviousInventoryForward fails with the claim wrote resources=[] counts=map[]. The type comment no longer promises a restore-side reader; I re-grepped every readProtectBackupMeta call site (:1167, :1186, :1475) and all three are inside backup, so "its only programmatic reader is backup itself … restore never opens it" is true as written. |
(5) c=100 |
protect_backup_test.go:1470-1478 → now :1736-1779 |
✅ Fixed | Now unmarshals and requires each list field present and []any. Mutation-checked (M6, initialisers back to nil): fails for group/user/api-client on JSON with has "roles" = <nil>, want an empty list. YAML passes because a nil slice marshals as [] there — which is the format disagreement the finding was about, so the asymmetric failure is correct. Checking presence as well as type is a deliberate extra: it catches a future omitempty. I also verified the author's counter-claim about my example, and it holds — zz-nullable-test contains the substring null, so the old assertion would have failed on it in both formats. |
Nice-to-have suggestions (3 items)
🟩 (1) (correctness, c=100) — internal/commands/protect_analytic_overrides.go:219-271: protect overrides set is new in this PR, inherits -n, and ignores it — while its two siblings in the same new command group honour it
This is the fourth generation of the flag-surface class, and the reason it is 🟩 rather than a fourth blocker is worth stating explicitly.
Inside protect_analytic_overrides.go, clear (:309) and apply (:409) both call confirmAction, which branches on dryRun and prints [dry-run] Would … (protect_helpers.go:106). set calls cliCtx.ProtectClient.UpdateInternalAnalytic directly with no dryRun check and no confirm* helper, so protect overrides set zz-analytic --severity Low -n performs a real tenant write. Its Long does not mention -n. That fails the rule this commit wrote: "Every command that inherits -n either honours it or says in its Long what it does not cover" (CLAUDE.md:324). The operator who learns from overrides clear -n that the group previews will be wrong about set.
Why not 🟧: the write is reversible (overrides clear, or set back), and it is consistent with the pre-existing Protect convention on main — grep -n dryRun internal/commands/protect_*.go finds it only in protect_helpers.go and protect_backup.go, so analytic-sets add-analytic (protect_analytic_sets.go:205, unchanged by this PR beyond six lines), exception-sets add-exception and ulf-sets add-filter all mutate under -n today. The three findings that held this PR up were an exit-2 crash on a documented CI mechanism and two file deletions; this one is neither.
The pattern is the finding. Four rounds have each produced one instance of "a root persistent flag and a command disagree", found one at a time by reading. A fifth will follow unless something enumerates it. The cheap version is a table-driven test that walks the command tree and asserts, for every command whose RunE reaches a mutating client call, either a dryRun read or a --dry-run mention in Long — the same shape as the existing TestApplyProGroups_AllCommandsGrouped guard. That belongs in a follow-up, not in round 4 of a 7,500-line PR.
Fixed when: either overrides set honours -n (report the intended override and return), or its Long says -n does not cover it — and preferably a test enumerates the rule instead of a fifth review round finding a fifth instance.
🟩 (2) (documentation, c=100) — CLAUDE.md:255: "refuses the prune — not the run" describes behaviour the command does not have
The new sentence reads: "A manifest that is absent, zero-length, or parses to no tenant beside documents refuses the prune — not the run — for the same reason one run later." Executed (probe P3): runProtectBackup returns an error and the command exits non-zero. Nothing is pruned because nothing runs.
The intended contrast is with the unreadable case, which is refused even under --no-prune — and the rest of the sentence makes that clear. But the literal reading tells an operator (and an agent reading CLAUDE.md as ground truth) that a scheduled protect backup against such a directory succeeds with the prune skipped, when it fails. The in-code comment at :1206-1211 gets this right ("a pruning question, not a provenance-rewrite one") and SKILL.md:52 gets it right too ("the prune is refused until one --no-prune run puts the tenant on record"). Only the CLAUDE.md phrasing overstates. Suggest: "refuses the run, but as a pruning question rather than a provenance one — so --no-prune gets through, unlike the unreadable case."
🟩 (3) (documentation, c=100) — internal/commands/protect_backup.go:1412-1417: the -n contract enumerates the document writes but not the _meta claim it also leaves behind
The Long says "documents are still written … but nothing is deleted". Executed (probe P1) — protect backup -n into a fresh directory leaves [_meta.yaml unified-logging-filters], with tenants=[https://staging.x] and counts=map[unified-logging-filters:2]. So a preview run permanently claims the directory for the running tenant.
This is not a defect, and I chased the ways it could be one. The guard runs before the claim, so -n cannot claim a directory it does not own: probe P2 (-n at another tenant's directory) is refused with the tenant list unchanged, and probe P3 (-n at a no-owner directory) is refused too. -n --no-prune records the claim (P4) — which is exactly what round 3's own suggested fix (if dryRun { noPrune = true }) would have done, so the behaviour is not a deviation from the agreed contract. And a claim on an otherwise-empty directory only ever moves the next run toward a refusal, which is the safe direction.
It is still a lasting on-disk side effect under a flag whose root help says "preview changes without executing", and the Long currently accounts for the documents but not the manifest. One clause — "and the run records this tenant in _meta the way any run does" — closes it.
Flag sweep — `protect backup` and `protect restore` against the root persistent set
Round 3 asked for this in one pass instead of one finding at a time. Done, against internal/commands/root.go:793-813 (19 persistent flags) and both commands' local sets, verified on a binary built at c13f5bc.
Local flags, shadow check.
| Command | Local flags | Collides with a root persistent name? |
|---|---|---|
protect backup |
--output, --format, --resources, --exclude, --no-prune |
--output only — deliberate, and the Long says the -o shorthand is gone |
protect restore |
--input, --resources, --exclude, --include-defaults, --yes |
none |
No new shadow. protect backup --help confirms it: Global Flags lists -n, --dry-run and omits -o, --output entirely, which is the documented consequence.
Advertised-and-read, on backup. -n/--dry-run (now — the fix), --allow-partial-failure (:1450), -q/--quiet (:1641). On restore: -n/--dry-run (:1733, :1837), --allow-partial-failure, -q/--quiet.
JAMF_CLI_ARGS (executed). injectEnvArgs prepends before the subcommand, so the shorthands resolve on the target command's merged flagset:
JAMF_CLI_ARGS='-n' protect backup → parses; fails later on missing creds (exit 2, auth)
JAMF_CLI_ARGS='-n' protect restore → parses; fails later on missing creds (exit 2, auth)
JAMF_CLI_ARGS='-o json' protect backup → "unknown shorthand flag: 'o' in -o" (exit 2)
The third is the documented, deliberate consequence of the --output collision, unchanged by this round and already stated in both the Long and the CLAUDE.md convention. The -n half now works on both commands.
Advertised-but-unread on backup, and why none is a finding. --field, --select, --compact, -w/--wide, --out-file, -v/--verbose all appear in protect backup --help and do nothing there. Every one is a root persistent flag that shapes structured record output or Pro HTTP tracing, and protect backup prints neither — so each is inert on pro backup, config add-profile and every other non-record command on main too. That is a repo-wide property of a flat persistent-flag set, not something this PR introduced or can reasonably fix inside its scope. The class this round was asked to sweep — a shadow, or a flag whose absence of effect is surprising and destructive — is clean on both commands.
One residual instance outside these two commands is finding (1): protect overrides set, new in this PR, inherits -n and neither honours nor documents it.
Review coverage
- Design and architecture: the two structural choices this round are both right, and both are choices rather than the obvious edit. Gating
removeinsideprotectPruneStalerather than gating the call keeps the decision path identical between a preview and a real run, so the reported list is produced by the same code that would do the deleting — the alternative produces an empty list and a silence that reads as "nothing to prune". And putting the no-owner refusal inprotectBackupPruneAllowedrather thanprotectBackupProvenanceUsablecorrectly separates "may this run prune" from "may this run rewrite provenance", which is what makes--no-prunea remedy in one case and a bypass in the other. Both are argued at the site. - Correctness: findings (1), (2). Traced the full new path at head.
protectBackupPruneAllowed:1186-1252— the_, _, errdiscard offoundis correct now that emptytenantURLs()subsumes the absent case, andprotectBackupProvenanceUsablestill runs first for the unreadable one.protectBackupMetaPath:1245-1252resolves across all threeprotectRestoreExtsand falls back to the extensionless stem; the fallback is unreachable from the only caller (a recorded tenant implies a readable manifest), and harmless if it ever were.protectPruneStale:1302-1355— theos.Statcontinuestill precedes the removal, so aremove=falserun reports only files that exist, which is what makes the preview list truthful. Call order at:1475-1516unchanged: read prior → guard → claim → export. - Security: nothing new. No credential path touched;
_metastill0o644carrying a tenant URL.protectBackupMetaPathcomposesdirwith a package constant and a fixed extension list — no caller-controlled segment. Worth naming, since it is a-nquestion: a preview run does write credential-bearing documents0600and prints the sensitive-file warning. That follows from the documented "documents are still written" contract and is the same on a real run, so it is not a regression — but an operator reaching for-nto avoid touching disk will be surprised, and theLongshould arguably say so alongside finding (3)'s clause. - Cross-repo / wire contracts: none changed this round. The
-nsemantics are local;protectPruneStale's new parameter is package-private with two call sites, both updated (grepconfirms no third). - Reliability: the interrupted-run story is now complete in both directions — the claim lands before any document (round 3) and no longer blanks the inventory when it does (finding (4)). A dry-run reports a removal that a real run might then fail on (permission denied at the
os.Remove); that is inherent to any preview and not worth a finding. - Performance: negligible.
protectBackupMetaPathadds at most threeos.Statcalls, and only on the refusal path. - Test coverage: +318 lines and they carry their weight. Six mutations, six caught (table in Scope). The standout is M2 — the second sub-test exists specifically to fail the weaker fix the review suggested, which is a test written against a class of wrong answer rather than against the one defect. The
setDryRunhelper is a real catch the review did not ask for:dryRunis bound to the persistent flag, soTestProtectRestoreUsesTheRootDryRunFlag'sParseFlags("-n")was leakingtrueinto every later test in the package; harmless untilbackupstarted reading it, and now restored by both tests.TestBackupRefusesToPruneWhenNoTenantIsOnRecordcovers all three manifest states plus the empty-directory control plus the full three-run recovery sequence. Residual: nothing pins finding (1)'s command, and no test asserts the-n_metaside effect either way. - Code quality: read every new comment against executed behaviour, which is the standing lesson from round 2.
protectBackupMeta's rewritten doc comment,protectPruneStale'sremoveparagraph, theprotectBackupPruneAllowedzero-tenant block,protectBackupMetaPath's rationale, the claim-carry-forward comment and therunProtectBackupdryRunnote all check out. TheLongparagraph'spro backupprecedent claim was the one worth verifying independently, and it holds:dryRunClient.Do(root.go:704-712) passesGET/HEADthrough, andpro_backup.goreads only, sopro backup -ndoes write its files. - Simplification: one new eight-line helper and one new bool parameter. Both earn their place; nothing to collapse.
- [na] Frontend concerns: no UI, site, or template files in the incremental diff.
- Documentation currency: findings (2), (3). Read all four changed doc surfaces sentence by sentence against executed behaviour.
CLAUDE.md:324's new convention clause is accurate and generalises correctly ("a root persistent flag appears in a command's own--helpwhether the code reads it or not").SKILL.md:52's two new sentences are both accurate. TheLongaddition is accurate but incomplete (finding (3)).CLAUDE.md:255's "refuses the prune — not the run" is the one claim that does not survive execution (finding (2)) — the third round in a row where a doc sentence needed a probe rather than a read, which is itself the argument for keeping these paragraphs shorter. - Scope and decomposition: unchanged and settled three rounds ago.
- Project rules compliance: no
.claude/rules/. Re-checked againstCLAUDE.md— no credential flags added, nothing under*/generated/,protect_*prefix intact,make verify-generatednot needed (no spec or template touched). The PR's own new-nrule is satisfied byprotect backupandprotect restoreand violated byprotect overrides set— finding (1).
Scope of review
Incremental diff 236f75d..c13f5bc (4 files, +420/−33) in the worktree at /tmp/pr-review-Jamf-Concepts-jamf-cli-330, read in full. Baseline is my round-3 review at 236f75d. The new work is exactly one commit, so it bisects cleanly.
Beyond the incremental diff: internal/commands/protect_backup.go:1095-1260 and :1400-1660 at head; internal/commands/root.go:700-740 and :793-813 (dryRunClient, the full persistent-flag set); internal/commands/protect_helpers.go:80-135 (the three confirm* dryRun branches); internal/commands/protect_analytic_overrides.go in full at head (finding (1)); internal/commands/protect_analytic_sets.go:205-255 (the pre-existing add-analytic comparison); internal/commands/pro_backup.go (the -n precedent claim); cmd/jamf-cli/main.go's injectEnvArgs.
Executed: go build ./..., go vet ./internal/..., go test ./internal/... -count=1 — all pass. golangci-lint run ./internal/commands/... reports 0 issues. Flag behaviour and help output checked on a binary built at c13f5bc.
Six mutation checks, each applied to a full copy of the worktree in a scratch directory. The worktree itself was never modified (git status --porcelain empty before and after, verified), and the scratch copy's two mutated files were diffed byte-identical to the shipped ones afterwards.
| Mutation | Caught by |
|---|---|
M1 protectPruneStale(…, !dryRun) → true (the shipped defect) |
TestBackupUnderTheRootDryRunFlagPrunesNothing/the_file_a_real_run_would_prune_survives |
| M2 my round-3 suggested shape — gate the call, not the removal | …/what_it_would_have_pruned_is_reported, on the reporting assertion only |
| M3 no-tenant-on-record refusal short-circuited | TestBackupRefusesToPruneWhenNoTenantIsOnRecord, all three states |
M4 protectBackupMetaPath(dir) → bare protectBackupMetaFile |
TestBackupTenantRefusalNamesTheManifestToEdit |
M5 claim write back to []string{} / map[string]int{} |
TestBackupClaimCarriesThePreviousInventoryForward |
M6 RBAC list initialisers back to nil |
TestRBACExportsCarryEmptyListsInBothFormats (group, user, api-client, JSON) |
These are the same six the author reports, reproduced independently and matching exactly, including M2's single-sub-test failure.
Five behavioural probes in the same scratch copy, throwaway tests, results quoted in the findings: -n into a fresh directory (P1, finding (3)), -n at another tenant's directory (P2, refused, tenant list unchanged), -n at a no-owner directory (P3, refused — also the evidence for finding (2)), -n --no-prune (P4, claim recorded), and the zz-nullable-test false positive (P5, the author's counter-claim confirmed).
Prior feedback since the baseline: one issue comment from @neilmartin83 (2026-08-20T16:04Z) walking through all five findings with its own six mutation checks. Every claim in it was treated as a claim and checked; all hold, including the two that correct my round-3 review — that my suggested -n diff was the weaker shape, and that zz-nullable-test would have tripped the old substring assertion in both formats. No review comments, no other reviewers, nothing unaddressed from anyone else.
Not verified: no live tenant, so the two server-behaviour claims underneath the round-3 group fix remain as reported (that updateGroup refuses any update to a connection-less local access group, and that exceptionSets: [] clears rather than being rejected). Nothing in this round's diff depends on a wire fact I could not resolve in source.
Never-run specialist lanes. No agents dispatched, this round or any prior one — the session cannot spawn subagents, so every lane ran inline. Eligible but never dispatched across all four rounds: security-reviewer, usability-reviewer, performance-reviewer, scope-reviewer, silent-failure-hunter, devil-advocate, test-quality-reviewer. fidelity-reviewer remains inapplicable — no Jira key in the title, branch or description, and no linked issue.
What's done well
- ✅ The fix rejects the reviewer's diff, gives the reason, and ships a test that fails the reviewer's diff.
if dryRun { noPrune = true }skips theprotectPruneStalecall, so nothing is decided and the report is empty — a preview that prints nothing is indistinguishable from "there was nothing to prune". Gatingos.Removeinstead keeps the decision and stops at the deletion. M2 applies my shape and fails exactly one sub-test, which is the sub-test that exists for it. That is a fix round arguing with the review on the evidence and being right. - ✅ The no-owner guard was fixed in the other function, deliberately. Putting it in
protectBackupPruneAllowedrather thanprotectBackupProvenanceUsabledraws the line at "may this run prune" versus "may this run rewrite provenance" — which is what makes--no-prunea genuine remedy here and a bypass there. The test then asserts the remedy works, including that the run after it prunes for real, closing the "fixed it by disabling the feature" hole for the second round running. - ✅ The test-hygiene catch the review missed.
dryRunis bound to the persistent flag, so the existing restore test'sParseFlags("-n")was leakingtrueinto every later test in the package — inert untilbackupstarted reading it, and then a three-sub-test-deep failure.setDryRunfixes both sites. Round 3 introduced the condition for that bug and did not see it. - ✅ Four rounds, and each one arrives with its own falsification evidence — the mutation, the test that catches it, and (this round) two corrections to the reviewer. Twenty-two mutation checks across four rounds, every one reproduced independently and every one matching. That is why this closed in four rounds rather than ten.
- ✅ The
CLAUDE.mdconvention got the general rule, not the instance. "A root persistent flag appears in a command's own--helpwhether the code reads it or not … a documented flag that does nothing is worse than an absent one" is the statement that would have prevented all three prior instances of this class. Finding (1) is what is left to enumerate, not to understand.
🤖 Generated by the pr-review:review skill · re-reviewed head c13f5bc (baseline 236f75d)
Generated by pr-review v1.30.0, a Jamf Claude Code skill
Summary
Adds
protect backupandprotect restore, plusprotect analytics overrides— and fixes seven export/apply bugs found by actually cloning one tenant into another.Important
Breaking output change.
groups,users,api-clientsandexception-setsnow export cross-resource references as names rather than server IDs. Anything that parses those four commands' output needs updating — see Output shape changes. Input stays backwards compatible in both directions; only the emitted shape moved.Jamf Pro has had
backupanddifffor a while. Protect had neither, and no product in this repo had a restore, so cloning or rebuilding a Protect tenant meant a hand-rolled shell loop over fifteenexportcommands in the right order.What's new
protect analytics overrides(list/get/set/clear/export/apply). Jamf publishes analytics centrally — a stock tenant returns ~156, alljamf: true, and the server refusesupdateAnalyticon any of them:What a tenant can change is an overlay: the severity it reports at and the actions it triggers, written by a separate
updateInternalAnalyticmutation. The SDK has exposed it since v0.8.0; nothing in the CLI called it. Since the definitions are identical in every tenant, that overlay is the only part of a Jamf-managed analytic worth capturing — and it was the part being dropped.protect backup/protect restore. Backup writes each object to its own file under a per-resource directory. Restore walks it in dependency order — members before the sets naming them, sets before the plans binding them, roles before groups before users — resolving every reference by name against the target as it goes. Existing objects are updated, absent ones created; nothing is ever deleted. Both take--resources(allowlist) and--exclude(denylist), which compose; restore adds--dry-runand--include-defaults.The
Orderfield inprotectResources()is the correctness argument, so a test asserts the dependency chain rather than the numbers, and every resource must either be restorable or carry a reason it is not.Deliberately not replayed, each reported at runtime: Jamf-managed content, tenant defaults (built-in roles,
Defaultgroup,Default Analytic Set), API clients (the server issues a new secret on create), data forwarding (its response is not its update shape and embeds a tenant-specific IAM ExternalId), and identity provider connections (no create API).Bugs fixed
analytics export | analytics applyfailed for every analyticactions= objects), apply decoded the SDK input (Actions= strings). The pipe was documented in CLAUDE.md and had never workedthreatPreventionStrategy+customEngineConfigplanExporthad drifted fromPlanInputstartup,label,matchReasonlabel, so every export lost onelongDescription,remediationnull; API wants[]unified-logging-filters export | applyfailed for every filterpredicate), apply decoded the SDK input (Filter). Same bug as the analytics row, in the sibling resource — found late, by pointing eachexportat its ownapplyAll seven shared one root cause — assuming the shape you read is the shape you can write — which is now
docs/solutions/logic-errors/response-shape-is-not-input-shape-2026-08-18.md. The resources that export the SDK input type directly had no bugs; every hand-written or community-schema export shape did.The seventh is the interesting one: it was found after the other six were fixed and documented, by running one line of shell —
export | applyfor each resource in turn. Unit tests over the converters passed the whole time, because both directions were wrong in the same way. That probe is now a test (protect_roundtrip_test.go) covering twelve resources in both YAML and JSON, driving each document through both the restore closure and theapplycommand's decoder, since those are different code for six resources and the gap between them is exactly where this bug lived.Output shape changes
Four resources'
exportoutput moved from server IDs to names. This is the fix for the RBAC row above, and it is a breaking change for anything parsing that output:groups exportRoleIDs,ConnectionIDroles:(names),connection:(name)users exportRoleIDs,GroupIDs,ConnectionIDroles:,groups:,connection:(names)api-clients exportRoleIDsroles:(names)exception-sets exportanalyticUuidonlyanalytic:(name); JSON top-level keys goName/Exceptions→name/exceptionsReading is unaffected in both directions:
rbacDocumentUsesIDsroutes an ID-shaped document down the old path, exception sets still accept a uuid-only exception, and JSON keys that were PascalCase still bind becauseencoding/jsonmatches case-insensitively. Tests pin both legacy shapes.Why it had to change: role IDs are small sequential integers, so an ID-shaped document applied to a second tenant did not error — it bound to whatever role held that integer. A silent wrong grant is worse than a broken export.
Verification
Cloned one live tenant into another and diffed the result — the check the postmortem calls definitive, re-run on the current head after review fixes changed the exception-set document shape, the restore order, filename allocation and retention.
26 documents applied, 0 failed, re-run idempotent. 24 of 32 files byte-identical, with all eight differences accounted for:
commsConfig.fqdnus-east-1→eu-central-1); the target keeps its ownanalyticuuidanalytic-overridesconnectionsdata-forwarding, 4 ×api-clientsName→ID rebinding proven explicitly, on objects whose IDs all differ between tenants:
Every set, plan, user and group binding in the target points at the target's own IDs, including the case that motivated the exception-set change: an exception targeting a custom analytic, whose uuid is per-tenant. Fixtures were built to cover the four resources the earlier run left unexercised (
analytics,telemetry,custom-prevent-lists,removable-storage-control-sets), plus two analytic input types, vendor USB rules,CUSTOM_ENGINESthreat prevention with a fullcustomEngineConfig, disabled+tagged logging filters, and a plan binding all of it.Real users were held back rather than replayed — eight of the nine in the source tenant have email alerts enabled, and creating those elsewhere emails real people. Synthetic users covered the path instead, including one in a group with no direct role (the
[]-not-nullcase). Both tenants were returned to their prior state.Wire facts recorded in CLAUDE.md
None of these are derivable from the schema:
BlazingKeyloggerisda360eb3-…in both); custom ones are not. So an exception set'sanalyticUuidis portable only when it points at a Jamf analytic.paramsisAWSJSON!— read back as an object, but the input wants a JSON-encoded string, and it can't be omitted either.0xprefix.eventsis[String]!but validated against an undeclared allowlist;network_connectis valid,network/network_listen/dns_request/process_execare not.Analytic.hashis a revision stamp, not a content hash — unusable as a change-detection or identity key.Also
jamf-backupandjamf-migrateskills learn about Protect. Both declared themselves "Jamf Pro assistant" and would never have found Protect's backup, or its restore. They now lead with the asymmetry: Pro has backup+diff but no restore; Protect has backup+restore but no diff.0600and reported. An HTTP action config's request headers are captured verbatim — the SDK's query selectsheaders { header value }in full — so a backup can contain a bearer token, and thejamf-backupskill tells users to git-init the directory. The run now names which resources those are before you commit the tree. The legacyForwardSentinel.sharedKeyis redacted outright, since data forwarding is never replayed.pro backup, with--allow-partial-failureto downgrade. A backup that exits 0 with a resource missing is indistinguishable from a good one to the job that scheduled it.--resourcesand--excludeboth shell-complete, and--helplists the vocabulary, generated from the resource table so it cannot drift. Previously the only ways to discover a resource name were completion on one of the two flags or passing a wrong value to read the error.insights,config-freezeandconnectionsadded to the backup set. Both writable ones compare before writing — not an optimisation: the API refuses a write of the value already held ("Tenant '…' is not in a change freeze"), so comparing is what makes replay idempotent.Test plan
make test— 29 packages passmake lint— 0 issuesgo vet ./...,go fix ./...(nothing to apply),make fmt(no changes)make verify-generated,make verify-site🤖 Generated with Claude Code