Repository navigation
feat: policy-forced supplementary groups with container_config.group_add (#93) - #94
Merged
Merged
Conversation
container_config.group_add forces HostConfig.GroupAdd, as user forces
User. In create_model:
- ContainerConfig gains groupAdd, an atom for the whole forced list ("" when
unset). The mutator replaces a GroupAdd spelled exactly so with the
policy's list; HostConfigGate allows a non-zero GroupAdd only when it
equals that list, so it does not open up if the mutator ever stops
forcing it.
- groupadd joins the case variants the daemon reads as GroupAdd.
- Policy GROUPS (BEACON with a forced list) joins MODEL_POLICIES, and the
list's atom joins VALUES, so a caller that sends the same list is in REQS.
- Security property daemonGroupsOnlyForced: the daemon reads GroupAdd,
under any spelling, only as the selected policy's list or empty.
daemonSeesOnlyAllowlistedHostConfig also checks the forced list is kept.
- Rows: groupAddForced, groupAddCallerReplaced, groupAddSameListUnchanged,
groupAddPolicyListWithoutGroupAddDenied, groupAddCaseVariantDenied and
groupAddGateExactOnly (the gate alone).
- The state machine's beacon policy forces a list, so the P0 invariant
hostConfigInAllowlist checks the forced value.
No pre-93 instance: before #93 GroupAdd had to be zero, which is the rule
`create` already runs for BEACON, so the old behaviour has no gap to
witness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same rows, by name, in Go, Rust and TypeScript: - HostConfig rows on a new "groups" policy (std plus group_add ["64190", "jwt"]): groupAddForced, groupAddForcedNullHostConfig, groupAddCallerReplaced, groupAddCallerNullReplaced, groupAddSameListUnchanged, groupAddCaseVariantDenied, groupAddNonObjectHostConfigDenied; and groupAddPolicyListWithoutGroupAddDenied on "std" next to the existing groupAddDenied. - groupAddGate* rows run HostConfigGate alone on "groups", as if the mutator stopped forcing the list: only the exact list or a zero value passes; another list, a reordered one, a subset, a superset, a number entry or a bare string are denied. - Load-time rows: GIDs and names load (up to 32 characters, 4294967295); an empty list loads and is unset; 0, 00 and root are refused as the root group; duplicates are refused, also 064190 after 64190; GIDs above uint32, upper case, spaces, a leading dash, 33 characters, "" and an unquoted YAML number are refused. deploy/config/dsp-it-a.yaml forces group_add ['64190', '64191'], and deploy/test.sh gains 10 checks against the real daemon: creates of dsp-it-a without GroupAdd, with the forced list and with another list all give 201 and the daemon reports the forced list on inspect; a lower-case groupadd is denied; dsp-it-b, which has no group_add, still denies GroupAdd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…add (#93) Since #88 a non-empty HostConfig.GroupAdd is always denied, so no policy could give a service the host group it needs to read a shared file (the JWT secret, root:jwt 0640, or a keys directory). In all three: - New optional policy key container_config.group_add: a list of strings. At load time each entry must be a decimal GID that fits in 32 bits or a group name ([a-z_][a-z0-9_-]*$?, at most 32 characters); 0 in any decimal form and root are refused as the root group; duplicates are refused, numeric ones by value (64190 and 064190). Entries must be YAML strings: Go and Rust would read an unquoted 0x10 as "0x10" and TypeScript as 16. An empty list is the same as an unset key. - ContainerConfigMutator overwrites HostConfig.GroupAdd with the list, as user overwrites User. A caller's list is replaced, not denied, so community.docker.docker_container sending the same list stays idempotent. A non-object HostConfig is still left for HostConfigGate to deny. - HostConfigGate allows a non-zero GroupAdd only when it equals the policy's list exactly, in order. Without group_add the rule is unchanged (absent or zero), and the gate does not open up if the mutator ever stops forcing the list. Case variants such as groupadd are still denied. - Rust prefixes policy parse errors with the file name, as the other load errors do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README: group_add in the policy example and a paragraph on what it does and what fails startup; a GroupAdd row in the HostConfig rules table; the mutator row; test counts. spec/README.md: the GROUPS policy, the daemonGroupsOnlyForced property, the #93 rows, and why there is no pre-93 instance. AGENTS.md: counts. config/beacon.yaml: a group_add example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branch adds 8 integration checks, not 10: 108 + 8 = 116, as the CI run on PR #94 reports for Go, Rust and TypeScript. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closes #93.
Since #88 (v0.4.0), a non-empty
HostConfig.GroupAddis always denied, so no policy can give a service a supplementary group. The services that read a host-mounted file through a group (the shared JWT secret,root:jwt 0640; the validator's keystores) run as a fixed non-rootuser:that sets only the primary UID and GID. Their deploy roles pass the host GID withcommunity.docker.docker_container'sgroups:, which becomesHostConfig.GroupAdd, and with v0.5.0 every one of those creates gets a 403.What changes
container_config.group_add: [string]. It works likeuser. At load time, each entry must be a decimal GID that fits in 32 bits or a group name ([a-z_][a-z0-9_-]*$?, at most 32 characters). These fail startup with an error that names the file:0in any decimal form (00) androot(the root group), duplicates (numeric ones by value, so064190after64190), and anything else. An empty list is the same as an unset key.HostConfig.GroupAddwith the list, asuseroverwritesUser. A caller's own list is replaced, not denied, and a caller that sends the same list sees no change, sodocker_containerstays idempotent. A non-objectHostConfigis still left for HostConfigGate to deny.GroupAddonly when it equals the policy's list exactly, in order. Policies withoutgroup_addkeep the Container create: default-deny HostConfig, validate Mounts and the bind read-only mode #88 rule (absent or zero). The exact-match check is defence in depth: if the mutator ever stops forcing the list, the gate does not open up. Case variants (groupadd,groupAdd) are still denied.'64190', not64190). The three YAML libraries disagree on unquoted scalars: Go and Rust read0x10as"0x10"and TypeScript as16. Taking only strings means all three see the same text.create_modelinspec/docker_socket_policy.qnt):ContainerConfig.groupAdd(one atom for the whole list), the mutator and gate rules,groupaddas a case variant, and a new model policyGROUPS. The new security propertydaemonGroupsOnlyForcedsays the daemon readsGroupAdd, under any spelling, only as the selected policy's list or empty.daemonSeesOnlyAllowlistedHostConfigalso checks that the forced list is kept. There are six new rows. The state machine'sbeaconforces a list, so the P0 invarianthostConfigInAllowlistchecks the forced value on every step.groupspolicy.deploy/test.shhas 8 new checks against the real daemon (see Verification).group_add, aGroupAddrow in the HostConfig rules table, the mutator row and the counts.spec/README.md, AGENTS.md counts, and agroup_addexample inconfig/beacon.yaml.Review notes
bump=minor tag=v0.6.0.GroupAddhad to be zero for every policy. That is the rulecreatestill runs forBEACONandTOOLS, and it is strictly more restrictive.daemonGroupsOnlyForcedheld then, so there is no gap to witness. The spec README says this and gives the mutations that do break the property.deploy/config/dsp-it-a.yamlgainsgroup_add: ['64190', '64191']. These are numeric GIDs, so the existing Lifecycle actions and DELETE are allowed on containers that no policy covers #86 start ofdsp-it-astill works without a matching/etc/groupentry.dsp-it-bhas nogroup_addand covers the deny case. No new image prefixes or container names.policy file <name>:, as its other load errors do.Verification
Spec mutation: if the gate accepts any
GroupAdd,createSoundTestfails, along withgroupAddDenied,groupAddPolicyListWithoutGroupAddDeniedandgroupAddGateExactOnly. If the mutator keeps the caller'sGroupAdd,createSoundTestandgroupAddCallerReplacedfail.Before the fix (the test commit, plus only the new
group_addtype field so that the tests compile):groupAddGateForcedListAllowed, 12 load-time rows and the empty-is-unset test fail:test result: FAILED. 156 passed; 4 failed(5 rows failed, plusgroupAddGateForcedListAllowed, 12 load-time rows andtest_manager_empty_group_add_is_unset).# tests 330 / # pass 321 / # fail 8.This branch:
make test-all: Go 133, Rust 160, TS 330 (1 skipped).make lint-allandmake build-allpass.make typecheckpasses.make test-specpasses all 20 runs (create: 50 passing).make verify(BACKEND=typescript):[ok] No violation foundfor both specs.make test-release: 9 passed.Integration: green on CI (GitHub runner, real daemon):
ALL 116 TESTS PASSEDfor Go, Rust and TypeScript. The local OrbStack daemon hung after the machine slept, so there is no local run and no pre-fix integration capture. The 8 new checks:dsp-it-awithoutGroupAdd, with the forced list, and with another list all return 201, and inspect shows"GroupAdd":["64190","64191"].groupaddis denied.dsp-it-bwithGroupAddis denied.They bring the suite to 116 per implementation (108 + 8). The socket suite (
make test-integration-sock, not in CI) is still to run locally.Type of change
Implementation(s) changed
Testing
make test-all)make verify,make test-spec)Checklist
🤖 Generated with Claude Code