Skip to content

feat!: select the create policy by container name and limit lifecycle actions to covered containers (#85, #86) - #90

Merged
abienkowski merged 4 commits into
mainfrom
feat/container-names-routing
Oct 14, 2026
Merged

abienkowski merged 4 commits into
mainfrom
feat/container-names-routing

Conversation

@abienkowski

@abienkowski abienkowski commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Closes #85.
Closes #86.

#85. When several policies allowed one image, the policy applied to a create was effectively random: GetByImage returned the first match while iterating a Go map or Rust HashMap, and TypeScript always took the first-loaded file. Lodestar's beacon node and validator client both run chainsafe/lodestar, so a validator create could get the beacon user and volumes. Policies had no way to tell two services of one image apart.

#86. start, stop, restart, kill, wait, pause, unpause and DELETE /containers/{x} were forwarded for any container, so every caller of a socket could stop or remove every container on the host, operator containers included. The README said "Allowed on known containers"; the code, the tests and the Quint model allowed unknown ones on purpose.

What changes

  • Optional container_names in a policy (Docker name syntax, [a-zA-Z0-9][a-zA-Z0-9_.-]*). A policy covers its container_names, or its service_name when the list is unset, so existing configs keep their lifecycle behaviour for containers named after the service.
  • Create selects exactly one policy. POST /containers/create?name=N uses the one policy whose image prefixes match the image's normalised repository (Image references are not normalized before policy matching (docker.io/library/..., registry ports) #84) and whose container_names contain N (a policy without the list accepts any name). The query goes through the same parser as the image pull (docker pull through the proxy is always denied: fromImage is read from the body, but the Engine API sends it in the query string #83), so a query the daemon would reject is invalid query string and a repeated name is duplicate name query parameter (the daemon would take the first); one leading / is stripped and an invalid name is invalid container name. Zero matches: image X not allowed by any policy (as before), container name N not allowed for image X by any policy, or image X requires a container name for a create without ?name=. More than one: ambiguous policy for image X and name N: a, b. Policies are iterated sorted by service_name everywhere.
  • Load-time validation (startup fails, naming both files): two policies whose prefixes share a namespace (equal, or /-prefix) and where either has no container_names or the lists overlap; a duplicate service_name (before, the later file silently replaced the earlier one); an empty or invalid container_names. Prefixes are compared in their Image references are not normalized before policy matching (docker.io/library/..., registry ports) #84 normalised form, so docker.io/library/alpine and alpine conflict. With this, the runtime ambiguity deny is defence in depth.
  • Pulls with shared images. A pull has no container name and creates no container, so it is allowed when any policy for the image accepts its tag or digest (policies tried in service_name order; the result names the accepting one), and denied with the first one's reason when none does. Rows TestRoutePullSharedImage and peers in all three.
  • Lifecycle actions and DELETE only on covered containers. The router allows a name exactly one policy covers. Any other target (an uncovered name, a full or short ID, or a covered name that is all lowercase hex and so could be an ID prefix) gets the new action ResolveContainer; the handler asks the daemon GET /containers/{x}/json (path-escaped, nothing from the caller forwarded) and forwards only if .Name is covered. Uncovered or 404: 403 container X is not managed by any policy. Any other daemon error: 403 cannot resolve container X: … (fail closed). The resolved name is audited (Go: extra.container / extra.resolved_name; Rust/TS: the audit reason). --readonly still denies first; reads are unchanged.
  • Spec. spec/router.qnt: flag ALLOW_UNCOVERED, outcome resolve, handle = route plus the daemon lookup over a fixed table DAEMON_CONTAINERS (names and IDs), security property lifecycleOnlyCovered, the three allow rows flipped to realNameDeleteDenied / execPrefixNameStartDenied / execPrefixNameDeleteDenied, five new rows, and router_pre86. spec/docker_socket_policy.qnt: containerNames on Policy, selectPolicies, createContainer requires exactly one candidate (beside fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) #92's createAllowed HostConfig guard), P0 invariant policySelectionUnique (beside fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) #92's hostConfigInAllowlist / hostMountsInWhitelist), and four run tests incl. the pre-Policy selection is nondeterministic when several policies allow one image; add container_names routing and deny on ambiguity #85 ambiguity witness. fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) #92's create scenarios in step (beacon-privileged, mount-root, …) are in the beacon policy's containerNames, so they still reach the HostConfig checks; the create_model policies set none. make test-spec runs both new targets.
  • Tests in Go, Rust and TS with the same tables and row names (load-time checks, selection incl. a 50-round stability loop and ambiguity, router coverage, handler resolution against a fake daemon by name / full ID / short ID, 404, 500, audit, and the Policy selection is nondeterministic when several policies allow one image; add container_names routing and deny on ambiguity #85 user-forcing reproduction through the handler), plus 14 integration checks against the real daemon (all versioned paths use /v1.45/, below the CI daemon's 1.48 limit) (see below). Two existing integration rows that expected the daemon's 404 for an unknown container now expect the proxy's 403.
  • Docs: README (policy schema with container_names, endpoint table and prose, Trust model: names are routing, not access control, but covered names bound lifecycle actions; Formal Verification counts), config/beacon.yaml example, spec/README.md, AGENTS.md counts. deploy/config gains dsp-it-a.yaml / dsp-it-b.yaml (the runner's own image, disjoint names, different users); the existing beacon, net-host, net-none and pull-allowed policies are unchanged and load without conflict, since none shares an image namespace. The dsp-it-a / dsp-it-b creates send no HostConfig, so fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) #92's default-deny gate passes them. net-host / net-none containers are named after their service, so the Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87 lifecycle checks stay allowed directly.

Review notes

  • Release: feat!: plus a BREAKING CHANGE: footer. Pre-1.0, so merging cuts v0.5.0 (minor; v0.4.0 is fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) #92). Simulated: git log -z --format=%B v0.4.0..HEAD | scripts/release-version.sh v0.4.0 → bump=minor tag=v0.5.0.
  • Breaking for deployments: containers no policy covers can no longer be started, stopped or removed through the proxy (a missing container is now a 403, not the daemon's 404); configs with two policies on one image namespace must add disjoint container_names or the proxy will not start; a create for an image whose policies all set container_names needs --name.
  • Choices beyond the issues: the hex-name rule (a covered name like cafe is still resolved, because the daemon falls back to an ID-prefix match when no container has that name); the create reads ?name= from the target that RouteRequest / route_request / route already receive after fix: route image pulls on the query string and normalise image references in all three (#83, #84) #91, with no signature change; container_names: [] is a load error rather than "covers nothing"; Go caps the inspect body at 4 MiB (Rust's transport already buffers whole responses).
  • Not covered: the lookup and the action are two daemon requests, so a container renamed or recreated in between by someone with direct daemon access is not caught (rename through the proxy is denied). Load-time validation is not modelled in Quint; the language tests pin it.
  • Integration fixture: the runner cannot reach the host daemon directly, so it uses itself as the uncovered "operator" container (only start, which answers 304, and DELETE without force, which answers 409, are tried on it, so nothing can stop it) and creates dsp-it-a / dsp-it-b from its own image through the proxy. Leftovers from an interrupted run are removed at the start; on a pre-fix proxy the creates that should have been denied are removed right after.
  • make test-spec gains about 1 s (two more words in the router model, one more instance, and the request-handling tests).

Verification

  • Spec mutation: router with ALLOW_UNCOVERED = true fails coveredSoundTest, the three flipped rows and four of the five Lifecycle actions and DELETE are allowed on containers that no policy covers #86 rows (coveredNameStartAllowed passes either way). router_pre86 asserts lifecycleOnlyCovered fails and that DELETE /containers/mycontainer, POST /containers/c0ffee/stop and POST /containers/exec-runner/start were allowUnknown. In docker_socket_policy.qnt, selecting from every image match (the pre-Policy selection is nondeterministic when several policies allow one image; add container_names routing and deny on ambiguity #85 oneOf) fails createUnknownNameDeniedTest and createNamelessDeniedTest.
  • Before the fix (Go unit runs on fd66e22 with this branch's handler and load-time tests: TestHandlerCreateSelectsByName "create ?name=svc-a forwarded User 2222:2222, want 1111:1111", the three lifecycle handler tests, and 7 load-time rows fail; the samples below are from the original base, 3130b0a): Go handler and load-time tests fail at runtime, e.g.
    --- FAIL: TestHandlerCreateSelectsByName (0.00s)
        handler_test.go:712: iteration 0: create ?name=svc-b forwarded User 1111:1111, want 2222:2222
    --- FAIL: TestHandlerLifecycleCoverage/uncovered_full_ID (0.00s)
        handler_test.go:628: POST /containers/9d8c…5544/kill: status 204, want 403 ()
    --- FAIL: TestNewManager_LoadTimeValidation/duplicate_service_name (0.00s)
        manager_test.go:287: NewManager loaded, want error "policies in a.yaml and b.yaml both use service_name \"svc\""
    
    TS: 16 of 72 tests in the three touched files fail (applies the policy selected by the create name: + '1111:1111' - '2222:2222'). Rust and the new Go router tests do not compile against the old API (container_names, route_with_query, Action::ResolveContainer). Integration on the pre-fix Go proxy (origin/main at fd66e22):
    FAIL: POST /v1.45/containers/*/start -> 403 (lifecycle branch, unmanaged) (expected 403 …, got 404: …)
    FAIL: POST /containers/exec-nosuch/start -> 403 (a name, not exec; unmanaged, #86) (expected 403 …, got 404: …)
    FAIL: create ?name=dsp-it-b x5 -> 201 with policy dsp-it-b's user every time (#85) (expected 5, got 0)
    FAIL: create ?name=dsp-it-c -> 403 (no policy accepts the name, #85) (expected 403 …, got 201: …)
    FAIL: create ?name=dsp-it-a&name=dsp-it-c -> 403 (duplicate name, #85) (expected 403 …, got 201: …)
    FAIL: create without ?name= -> 403 (both policies set container_names, #85) (expected 403 …, got 201: …)
    FAIL: POST /containers/<runner name>/start -> 403 (uncovered, #86) (expected 403 …, got 304: )
    FAIL: POST /containers/<runner full ID>/start -> 403 (uncovered, #86) (expected 403 …, got 304: )
    FAIL: DELETE /v1.45/containers/<runner short ID> -> 403 (uncovered, #86) (expected 403 …, got 409: …container is running…)
    FAIL: DELETE /containers/no-such-container -> 403 (daemon 404, #86) (expected 403 …, got 404: …)
    98 PASSED, 10 FAILED
    
    This run is current origin/main (fd66e22) with this branch's deploy/. The Policy selection is nondeterministic when several policies allow one image; add container_names routing and deny on ambiguity #85 rounds depend on map order: earlier pre-fix runs got 4/5 and 1/5, and pre-fix Rust picked one policy for the whole process (dsp-it-a 0/5).
  • This branch (rebased on fd66e22): make test-all Go 131, Rust 158, TS 312 (1 skipped); make lint-all, make build-all, make typecheck, make test-spec (20 runs), make verify BACKEND=typescript, make test-release pass; integration ALL 108 TESTS PASSED for Go, Rust and TS.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Implementation(s) changed

  • Go
  • Rust
  • TypeScript
  • Quint specification
  • CI / infrastructure

Testing

  • Unit tests pass (make test-all)
  • Integration tests pass (108 per implementation; socket suite not run)
  • Quint verification passes (make verify, make test-spec)
  • New tests added for the change

Checklist

  • I have read CONTRIBUTING.md
  • My code follows the project's coding style
  • I have updated documentation as needed

🤖 Generated with Claude Code

@abienkowski abienkowski added Status: Break Change Added to a PR or issue that would cause a breaking change Type: Bug Added to issues and PRs if they are addressing a bug labels Oct 14, 2026
abienkowski and others added 4 commits October 14, 2026 11:27
…#85, #86)

docker_socket_policy.qnt: Policy gets containerNames (empty = unset).
createContainer applies the one policy that selectPolicies returns for the
image and name, and is disabled for zero or several. New P0 invariant
policySelectionUnique and run tests: pre85AmbiguousTest (without
containerNames both lodestar policies match), createSelectsByNameTest,
createUnknownNameDeniedTest and createNamelessDeniedTest. Selecting from
every image match instead fails the last two.

router.qnt: flag ALLOW_UNCOVERED for the behaviour before #86. The
lifecycle branch allows a covered name (allowKnown) and sends any other
target to the daemon lookup (resolve); handle completes it with the table
DAEMON_CONTAINERS (names and IDs to container names). Security property
lifecycleOnlyCovered: no lifecycle action or DELETE is forwarded for an
existing container outside every policy's covered names, by name or ID.
realNameDeleteAllowed, execPrefixNameStartAllowed and
execPrefixNameDeleteAllowed flip to *Denied; five #86 rows; router_pre86
witnesses the old gap. pre48 and pre53 now see resolve instead of
allowUnknown.

Makefile test-spec runs router_pre86 and the docker_socket_policy tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
, #86)

The same tables in Go, Rust and TypeScript, with the spec row names:

- Load-time checks: conflicting policies (shared image namespace, and one
  without container_names or overlapping names), duplicate service_name,
  invalid or empty container_names; disjoint names and sibling prefixes load.
- Create selection (#85): ?name= picks the one policy matching image and
  name, a leading slash is stripped, an unaccepted name or a nameless create
  is denied, an invalid name is denied, ambiguity is denied naming both
  policies, and the choice is the same over 50 rounds. Through the handler,
  two policies for one image force their own users on every create.
- Lifecycle coverage (#86): a covered name is allowed directly; an uncovered
  name, an ID or a hex-looking covered name is resolved. Through the handler,
  a covered container by name, full ID and short ID is forwarded after one
  GET /containers/{id}/json; an operator container by name or ID and a
  daemon 404 are denied; a daemon 500 fails closed; the resolved name is
  audited. realNameDeleteAllowed and execPrefixName*Allowed become *Denied.
- deploy/test.sh: two policies for the runner's own image with different
  container_names and users, five create rounds each, and the deny/allow
  checks against the real daemon, the runner itself standing for an
  uncovered operator container. deploy/config gains dsp-it-a and dsp-it-b.

- Pulls with shared images: two policies for one image with different tag
  patterns; a pull is allowed when either accepts the tag (50 rounds).

These fail on the code before the fix (main at fd66e22): the Go handler
tests (TestHandlerCreateSelectsByName "create ?name=svc-a forwarded User
2222:2222, want 1111:1111", TestHandlerLifecycleCoverage,
TestHandlerLifecycleResolveFailsClosed, TestHandlerLifecycleAuditsResolvedName)
and 7 load-time rows at runtime; the new router tests and the Rust tests at
compile time (container_names, ResolveContainer, select_for_create); and
10 of the 108 integration checks on Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… actions to covered containers (#85, #86)

create was whichever came first in map order (Go, Rust: random per request
or per process) or load order (TypeScript). Policies get an optional
container_names list. POST /containers/create?name=N uses the one policy
whose image prefixes match and whose container_names contain N (a policy
without container_names accepts any name); none is denied as before or
with "container name N not allowed for image X by any policy" / "image X
requires a container name", and more than one with "ambiguous policy for
image X and name N: a, b". Policies are iterated sorted by service_name.
At load, two policies whose prefixes share a namespace and that could both
accept one name (either has no container_names, or the lists overlap) fail
startup naming both files, as does a duplicate service_name, which used to
replace the first file silently, or an invalid or empty container_names.
Images and prefixes are compared in the #84 normalised form, the conflict
check included, and ?name= is read with the same query parser as the pull
(a malformed query is "invalid query string"). A pull has no name and
creates no container, so it is allowed when any policy for the image
accepts its tag or digest, and denied with the first one's reason.

forwarded for any container. A policy now covers its container_names, or
its service_name when unset. The router allows a name exactly one policy
covers; any other target (an uncovered name, a full or short ID, or a
covered name that is all lowercase hex and so could be an ID prefix) is
resolved by the handler with GET /containers/{x}/json. A covered .Name is
forwarded; an uncovered name or a 404 is denied with "container X is not
managed by any policy", and any other daemon error with "cannot resolve
container X: ...". The resolved name is audited. --readonly still denies
first; reads are unchanged.

Docs: README policy schema, endpoint table and Trust model (names are
routing, not access control; covered names bound lifecycle actions),
config/beacon.yaml example, AGENTS.md counts.

BREAKING CHANGE: lifecycle actions and DELETE on a container that no
policy covers are denied with 403 (a missing container is now a 403, not
the daemon's 404). Policies whose image prefixes share a namespace must set
disjoint container_names, and service_name must be unique, or the proxy
refuses to start. A create of an image whose policies all set
container_names needs a matching ?name=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eters (#85)

The daemon reads the create name with Go's r.Form.Get, which returns the
first of a repeated parameter. Selecting a policy on one name while the
daemon could name the container after another is avoidable ambiguity, so a
create with more than one name parameter (after decoding the key, so
n%61me counts) is denied with "duplicate name query parameter", the same
rule and wording the image pull uses for fromImage, tag and fromSrc (#83).
A query the daemon would reject (a bad escape or a semicolon) is "invalid
query string", from the shared parser. Rows for encoded names (%62eacon,
validator%2D2), duplicates and invalid queries in Go, Rust and TypeScript,
an integration check for ?name=dsp-it-a&name=dsp-it-c, and the test counts
in AGENTS.md and README.md (108 integration checks per implementation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abienkowski
abienkowski force-pushed the feat/container-names-routing branch from e7fd772 to 1111812 Compare October 14, 2026 15:45
@abienkowski
abienkowski merged commit 0f0dba2 into main Oct 14, 2026
7 checks passed
@abienkowski
abienkowski deleted the feat/container-names-routing branch October 14, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Break Change Added to a PR or issue that would cause a breaking change Type: Bug Added to issues and PRs if they are addressing a bug

Projects

None yet

1 participant