Admission control
On this page
A local, scriptable guardrail on the docker API. Skrog already sits in the
request path — every docker call crosses the named-pipe bridge — so it can
refuse a container this machine’s owner has ruled out, before the engine ever
sees it.
skrog policy show # the rules in effect
skrog policy check # validate the file without applying it
skrog policy test create-body.json # judge one request, and say why
Two layers, and only one of them is yours
Rules come from two files:
| Where | Who can write it | |
|---|---|---|
| Machine | %ProgramData%\skrog\policy.yaml | must be owned by Administrators or SYSTEM, or it is refused — see what it is not |
| You | policy.yaml in the state dir (skrog policy show prints the path) | you |
They merge with one rule: the user layer may only tighten. You can forbid more than the machine does. You cannot permit anything it forbids.
$ skrog policy show
machine rules: C:\ProgramData\skrog\policy.yaml (deployed machine-wide; you cannot loosen these)
deny --privileged
bind mounts only from: C:\work
your rules: C:\Users\you\AppData\Local\Skrog\policy.yaml
images must be pinned by digest
in effect (machine rules, tightened by yours):
deny --privileged
bind mounts only from: C:\work
images must be pinned by digest
Most machines have no machine file, and on those nothing above changes: one file, one layer, as before.
How the merge works
- Deny rules OR. A machine
deny-privileged: truecannot be un-denied. deny-capabilitiesunions. Both lists are forbidden.- Allow-lists intersect, which is the subtle one. An allow-list is a permission, so tightening means keeping fewer entries: a user entry survives only if the machine layer already permitted it.
That last point is worth an example, because “intersect” is not quite set
intersection. With machine allow-bind-sources: [C:\work]:
| Your file says | In effect | Why |
|---|---|---|
| nothing | C:\work | the machine’s |
C:\work\proj | C:\work\proj | it is under a machine root, so it was already permitted |
D:\other | C:\work | nothing you asked for was permitted, so you get the machine’s list |
A tightening step never produces an empty allow-list, because empty means “no restriction” — the one value that grants more than it looks like.
What it is not
Not a security boundary against a local ADMINISTRATOR. They can edit the machine file, or uninstall Skrog. Nothing here survives someone who owns the box outright.
Not a boundary against a standard user either. This section used to claim
the opposite — that a standard user “cannot write to %ProgramData%\skrog,
cannot loosen what is there, and cannot make skrog policy show lie about
it.” That was wrong on every clause, and it is the kind of wrong that matters,
because it is the sentence a security team would have relied on:
The directory is not administrator-only by default.
C:\ProgramDataships withBUILTIN\Users:(CI)(WD,AD)andCREATOR OWNER:(OI)(CI)(IO)(F), so on any machine where an administrator has not already createdProgramData\skrog, a standard user can create it first and own it outright.Skrog checks the owner now and refuses the file if it fails. A machine file not owned by
AdministratorsorSYSTEMis not merged, andskrog policy showsays so and why:machine rules: C:\ProgramData\skrog\policy.yaml REFUSED ignored because it is owned by CONTOSO\alice, not by Administrators or SYSTEM.The owner, not the full DACL. The hole above is an ownership one —
CREATOR OWNERgrants Full Control because the user owns the object — so the owner answers it. A DACL walk would also catch “an administrator created it and then granted Users write”, which is a misconfiguration rather than a bypass, and a hand-written ACE walker that gets an edge case wrong fails a correctly deployed fleet silently.The location can be redirected.
SKROG_MACHINE_POLICY_DIRoverrides it, and the supervisor runs as the ordinary user, who owns their own environment block.setxis enough.This is still true, and it is now reported rather than silent. Pointing the variable at a directory with no
policy.yamlused to be indistinguishable from a machine that never had fleet policy;skrog policy shownow says which one you are looking at:machine rules: none found SKROG_MACHINE_POLICY_DIR redirects the machine layer to C:\Users\me\empty, which holds no policy.yaml.A redirected location that does hold a trusted file is legitimate — a fleet may keep
ProgramDataelsewhere — and is reported too, because the variable is also the cheapest way to retire the layer and a reader deserves to know which they are looking at.The gate is not the only route to the engine. The distro is registered in the user’s own WSL installation, so
wsl -d <distro> -u rootreaches/var/run/docker.sockwith nothing in the way.skrog proxy --no-path-translationserves the pipe with the HTTP layer, gate included, switched off.skrog wsl-integrateshares the socket at mode0666.
What the machine layer honestly is: tamper-evident fleet configuration,
enforced at the pipe. It stops a user from casually loosening the rules by
editing their own policy.yaml — which is the realistic accident on a managed
laptop — and skrog policy show tells you what is in force. It does not stop
someone who sets out to get around it. Treat it as configuration management,
not as access control. The tracking issue for closing the gaps above is
#418.
Not a rule language. No Rego, no expressions. The vocabulary is small and fixed so a reader can tell at a glance what is forbidden — which is most of the value of writing a policy down.
The rules
They live in policy.yaml in the state dir (skrog policy show prints the
path). A missing file means no rules, which is the default state of a machine
nobody has configured.
deny-privileged: true # refuse --privileged
deny-added-capabilities: true # refuse any --cap-add
deny-capabilities: [SYS_ADMIN] # ...or only these
deny-host-namespaces: true # refuse --network/--pid/--ipc/--uts=host
allow-bind-sources: # bind mounts may only come from here
- C:\work
allow-registries: # images may only come from here
- registry.example.com
- "*.internal"
require-digest: true # images must be pinned by digest
deny-unattributable-builds: true # refuse `docker build` while the above is set
deny-unattributable-images: true # ...and an image this machine has no record of
Changing the file takes effect on the next container create — no restart, nothing to reload. Creating the file for the first time works the same way, and deleting it removes every rule.
An earlier draft read the file once when the supervisor started and told you to run
skrog restart. That was wrong twice:restartbounces the engine, not the supervisor that holds the rules, so the advice did not work even when followed — and a policy file written after the supervisor started installed no rules at all. Both were found by running a realdocker run --privilegedagainst a machine that had just been told to deny it, and watching it succeed.
Notes that matter in practice
deny-capabilities is spelling-insensitive. SYS_ADMIN, sys_admin and
CAP_SYS_ADMIN are the same capability; a rule that only caught one spelling
would be trivially bypassed.
allow-bind-sources matches path prefixes at a boundary. C:\work allows
C:\work\proj but not C:\workshop. Case and separators do not matter, so
c:/work is the same root. Named volumes are not binds — -v myvol:/data
carries only a name at create time, so this rule does not apply to it.
But a volume that names a host path is judged as one. A
local-driver volume can point at a directory:docker volume create -d local -o type=none -o o=bind -o device=/mnt/c/secrets esc
POST /volumes/createwas not judged at all until #419, so that volume — and any container later mounting it — reached a directory the rule would have refused. The container create that follows carries only the volume’s name, and a name is not a path, so nothing downstream could catch it either.It is judged now. The
deviceis a guest path, so it is mapped back from/mnt/<drive>/...to Windows form before being compared against the allowed roots. Two consequences worth knowing:
- A device that is not under a Windows drive is refused —
device=/,/etc,/var/lib/docker. They are under no allowed root, and refusing is the point of the rule.- A third-party volume driver is refused while this rule is in force, because its options are its own vocabulary and Skrog cannot tell whether they name a host path. Saying “checked” would be a lie. The
localdriver — the default, and whatdocker volume createuses unless told otherwise — is unaffected.
allow-registries blocks Docker Hub unless you list it. Docker’s own rule
is that the first component of an image reference is a registry only if it
looks like a host, so ubuntu and library/ubuntu are both docker.io. A
bare hostname in the allowlist matches that host on any port; write
host:5000 if you mean only that port. A leading *. matches a whole domain.
require-digest refuses anything without @sha256: — including
ubuntu:24.04, because a tag can move.
Which calls each rule judges
The image rules apply at pull and push, not only at container create. That
matters: gating only create would stop a blocked image running while still
letting it be fetched onto the machine, which is not what either rule says.
| rule | judged on |
|---|---|
deny-privileged, deny-added-capabilities, deny-capabilities, deny-host-namespaces, allow-bind-sources | create / run |
allow-registries | create / run, pull, push |
require-digest | create / run, pull |
deny-unattributable-images | create / run |
require-digest does not apply to a push. It exists to stop unpinned images
being consumed; a push publishes something you just built, and requiring a
digest there would refuse every ordinary docker push app:v1.
This is a behaviour change if you already use
allow-registriesorrequire-digest. Adocker pullthat worked before is now refused on both backends, at the pull rather than at the container create. That is the rule doing what it always said, but it will look new.
What allow-registries does not stop
A build. A Dockerfile’s FROM and any RUN can reach any registry, and at
the pipe a BuildKit build is an opaque gRPC stream, so a build cannot be
attributed to a registry in advance. policy.yaml lets builds through rather
than refusing them.
That is a deliberate default, not an oversight. An administrator’s deployed
WSL policy does refuse builds while WSLContainerRegistryAllowlist is
active — but that policy arrives by GPO against a user who cannot edit it,
where failing closed is the only coherent answer. policy.yaml is your own
file: refusing every build on a machine that merely lists its registries would
break working setups to close a hole its author can walk around by editing one
line.
If you want the strict reading, ask for it:
allow-registries:
- registry.example.com
deny-unattributable-builds: true
docker build is then refused outright, with the same reasoning the
administrator’s policy applies by default — reached here because you chose it
rather than because we assumed it.
The rule is inert without allow-registries, and skrog policy show says
so rather than letting you believe otherwise:
deny unattributable builds — INERT: it needs allow-registries to bite
Refusing every build on a machine with no registry restriction would be closing a hole that is not open.
Worth knowing if you deploy a machine layer:
the machine can set deny-unattributable-builds while a user’s own file
supplies the allow-registries that makes it bite. That is the intended
outcome — the administrator said “no unattributable builds where images are
restricted”, and they are.
Swarm services. POST /services/create, /services/{id}/update and /swarm/init run an image
from a TaskSpec this gate does not parse, so they cannot be attributed to a
registry. They are refused only when deny-unattributable-builds is on — which
is off by default. Refusing beats parsing a TaskSpec and getting it subtly
wrong, which is how two earlier bypasses happened.
Plugins used to be in this list, and should not have been (#420).
docker plugin installnames its registry in the request, so it is attributable — and because it was lumped in with swarm it inherited the permissive build default, so a plainallow-registriesletdocker plugin install evil.example.com/pthrough. A plugin gets host device and mount access where an image gets a container, which made it a worse hole than the build one the default was chosen to tolerate.
allow-registriesnow applies to plugins, with no opt-in, exactly as it does todocker pull— includingrequire-digestif you have set it. A plugin from a listed registry installs as before. A plugin pull that somehow names no registry keeps the old conservative treatment rather than passing unjudged.
A registry mirror used to be in this list, and is not any more (#421).
skrog cache enable --upstream <url>wiresregistry-mirrorsinto the engine, and dockerd applies those to every unpinned Docker Hub pull without changing the reference — so a mirror aimed anywhere served that host’s content under an allowed name, andallow-registriespassed because the reference was never the thing in question.The upstream is now judged against the effective allowlist, and
http://is refused unless--insecureis passed. This page argued for two releases that it could not happen, on the ground that “a mirror changes where bytes come from, not which image was asked for”. The premise is true and the conclusion did not follow: where the bytes come from is what a registry allowlist is for. See registry-cache.md.
The network. This is admission control at the Docker API. A running
container can reach any registry it likes, and docker load plus docker tag
will launder an image past a reference-based rule
(#343). deny-unattributable-images
narrows that last one — see below — but only to the extent that the request
comes through the bridge at all. The rules are a guardrail against mistakes,
which is the claim this page has always made.
Images this machine has no record of
allow-registries judges a reference, and a reference is a label anyone can
reattach. docker load a tarball, docker tag it registry.example.com/app:1,
and every reference-based rule passes on bytes that never came from
registry.example.com (#343).
With deny-unattributable-images set, Skrog keeps a record of where each image
came from and refuses to run one it has no record of:
allow-registries:
- registry.example.com
deny-unattributable-images: true
$ docker run registry.example.com/app:1
docker: Error response from daemon: policy refuses registry.example.com/app:1:
this machine has no record of where it came from. An image that was built
locally, loaded from a tarball or imported outside Skrog carries no provenance,
and deny-unattributable-images is set. Pull it from an allowed registry
(registry.example.com), or clear the rule
The record is written when a pull Skrog allowed completes, keyed by image ID rather than by reference — so retagging does not create provenance, which is the whole point.
What it does and does not buy you. This is drift protection, not a
boundary. Anyone who can run wsl -d skrog-engine -u root docker load talks to
the engine directly, and nothing at the pipe sees it
(#418). What it catches is the
ordinary way images get laundered by accident: a tarball copied off a laptop, a
docker save from a machine with different rules, an image left behind by a
setup nobody remembers.
Images you already had are trusted. The first time a Skrog bridge starts
with this feature present it records every image already on the machine as
pre-existing, once. Without that, turning the rule on would refuse the entire
existing cache. skrog policy show reports the split so the trust is visible
rather than implied:
image provenance: 41 image(s) recorded — 12 pulled, 29 pre-existing
It is opt-in and inert without allow-registries, exactly like
deny-unattributable-builds, and skrog policy show says so:
deny unattributable images — INERT: it needs allow-registries to bite
It fails open. If the engine cannot be reached to resolve a reference, or the record cannot be read, the request is allowed. A container that will not start because a lookup timed out is a worse outcome than an unattributed image running on a cooperating machine — and unlike the other rules, this one answers a question that has a legitimate “cannot say”.
It refuses your own builds too, and that is the catch. The build itself is
not judged — docker build runs — but the image it produces has no recorded
origin, so the docker run that follows is refused:
$ docker build -t app:1 .
=> naming to docker.io/library/app:1 done
$ docker run --rm app:1
docker: Error response from daemon: policy refuses app:1: this machine has no
record of where it came from. ...
At the pipe a BuildKit build is an opaque gRPC stream, so there is nothing to
attribute it to — the same reason allow-registries lets builds through. This
rule is therefore for machines that consume images, not ones that build
them: a CI runner or a locked-down workstation that pulls from one registry and
runs what it pulled. On a developer machine that builds and runs its own
images, leave it off.
(If what you want is builds refused, that is deny-unattributable-builds —
a different rule with a different answer.)
What a denial looks like
The bridge answers 403 with the reason, and the docker CLI prints it verbatim:
$ docker run --privileged ubuntu
docker: Error response from daemon: policy denies --privileged: it turns off
container isolation wholesale
403 rather than 400 is deliberate: the request is well-formed, this machine simply will not run it. Every denial is also recorded by the audit log when it is enabled.
Testing a rule set before trusting it
skrog policy test judges a container-create body without running anything,
and exits 13 when the rules refuse it — so it can gate a script.
$ skrog policy test --rules policy.yaml request.json
DENIED by allow-bind-sources
policy does not allow bind mounts from C:/secrets (allowed: C:\work)
$ skrog policy test --json --rules policy.yaml request.json
{
"denied": true,
"rule": "allow-bind-sources",
"reason": "policy does not allow bind mounts from C:/secrets (allowed: C:\\work)"
}
The body is the JSON the docker CLI POSTs to /containers/create.
The audit log will not give you one, and this page used to say
it would: an audit event is derived from the request line and query string
only, never the body — deliberately, so credentials and payloads never
reach the log. A container-create event carries the name (a query field) and
not the image or the mounts (body fields).
Hand-write the handful of fields your rule cares about; a create body is
mostly optional, and policy test judges what is there. docker inspect on a
container that already exists is the other source.
Failure direction
A rules file that will not parse is an error, not an empty policy. The supervisor logs it loudly and runs with admission control off rather than silently enforcing nothing while looking enforced — a guardrail that quietly fails open is worse than none, because it is believed.
Unknown keys are refused for the same reason: a misspelled rule that silently does nothing looks exactly like one that works.
$ skrog policy check
skrog: policy: yaml: unmarshal errors:
line 1: field deny-priviliged not found in type policy.Rules
Run skrog policy check after editing, before restarting.
Deploying the machine layer
It is one file, so anything that puts a file on a machine will do: Intune, an
Ansible task, a Packer provisioner, Group Policy Preferences, or a line in
your golden-image script. contrib/ already has
Ansible and Packer examples to hang it off.
# elevated
$dir = "$env:ProgramData\skrog"
New-Item -ItemType Directory -Force $dir | Out-Null
# Set the ACL explicitly. ProgramData's default gives Users (CI)(WD,AD) and
# CREATOR OWNER full control of what they create, so a directory a standard
# user created first is NOT administrator-only. Skrog refuses a machine policy
# file whose owner is not Administrators or SYSTEM (#418), so the wrong ACL now
# means the layer does not load rather than the user's own rules wearing its
# authority -- but deploying this before anyone runs skrog is still how you get
# a machine layer that works rather than one that is correctly refused.
icacls $dir /inheritance:r `
/grant "*S-1-5-18:(OI)(CI)F" `
/grant "*S-1-5-32-544:(OI)(CI)F" `
/grant "*S-1-5-32-545:(OI)(CI)RX" | Out-Null
$acl = Get-Acl $dir
$acl.SetOwner([System.Security.Principal.SecurityIdentifier]"S-1-5-32-544")
Set-Acl $dir $acl
Set-Content "$dir\policy.yaml" @'
deny-privileged: true
deny-host-namespaces: true
allow-registries:
- registry.example.com
'@
The SIDs are used rather than names so the snippet works on a non-English
Windows: S-1-5-18 is SYSTEM, S-1-5-32-544 Administrators, S-1-5-32-545
Users (read + execute, which is what every user needs to have the rules
applied to them).
Check it before you ship it to a fleet — skrog policy check validates a file,
and skrog policy test judges a real request against the effective rules:
echo '{"Image":"ubuntu","HostConfig":{"Privileged":true}}' | skrog policy test -
# DENIED by deny-privileged
A machine file that does not parse is an error, not an empty layer: a typo in a deployment must not quietly turn into an unenforced machine. The supervisor keeps the rules that were working and says so.
Not yet: ADMX
There is no Group Policy template, so this is not manageable from gpedit
alongside other products yet. The file is the deployable mechanism today, and
the registry/ADMX route is tracked separately — writing an ADMX I cannot test
against a real domain would be worse than not shipping one.
Scope today
Judged: POST /containers/create (where --privileged, capabilities,
namespaces, binds and the image reference all arrive), POST /images/create
(pull) and POST /images/{name}/push — see the rule table above for which
rule applies where. With deny-unattributable-builds set, the build endpoints
(/build, /session, /grpc) and the other calls that carry no attributable
image reference are refused too.
Also judged: POST /volumes/create, when allow-bind-sources is set — a
local-driver volume can name a host path through its driver options, and the
container create that follows carries only the volume’s name
(#419).
Also judged: POST /plugins/pull and /plugins/{name}/upgrade, against
allow-registries, because a plugin names the registry it comes from
(#420).
Also judged: POST /containers/create a second time, against the provenance
record, when deny-unattributable-images is set
(#343). The record itself is
written after a successful POST /images/create, and lives in
image-provenance.jsonl in the state dir.
Not judged: the swarm and service endpoints, which run an image from a
TaskSpec this gate does not parse and so are refused only when
deny-unattributable-builds is on.
Every listener that serves the engine applies all of this — the supervisor’s
pipe, skrog serve and skrog proxy — for the reason
#257 gives: a rule that stopped
at one listener would not be the rule the operator wrote.
Resource caps on an unset container — the one mutating rule in the original proposal — are deliberately not implemented: mutating a user’s request silently deserves its own review, and every rule here refuses rather than edits.