Status: normative design for the stable v0 implementation Audience: maintainers, implementers, security reviewers, and workflow authors Last reviewed: 2026-07-15
Fence is a small, source-auditable Rust agent that establishes an
outbound-network and ordinary privilege-bypass boundary for a narrowly
supported GitHub-hosted Linux runner. This document defines the normative v0
behavior and evidence contracts. The current security claims, trust
assumptions, abuse paths, and residual risks are defined in
threat-model.md. Implementation chronology is kept in
history.md, not in this specification.
The released agent and root Action adopt schema changes atomically from one reviewed source-and-version pull request. The protected main branch contains wrapper source but intentionally omits the production binary and manifest. After merge, release automation builds from source commit M and creates signed one-parent distribution commit D, which adds only the agent binary and schema-4 action/bundle-manifest.json; the manifest identifies the bundled agent while wrapper schema constants define the evidence contract accepted by the protected post hook.
Fence publishes a stable v0 Linux x64 release. The current
repository contains a hermetic Rust CLI that validates and freezes policy,
renders a deterministic native nftables ruleset preview, and exposes a
versioned hosted-runner fingerprint reference with status
accepted_reference_not_checked. When platform_profile is omitted or
explicitly set to github_hosted_workflow_bootstrap_v5, the planner selects the one
supported v0 descriptor. Every other profile value is rejected before
mutation.
On a matching GitHub-hosted ubuntu-24.04 x64 runner, public Linux run
activates standard block, explicit degraded unsafe_preserve, or
observation-only audit when it executes as root inside the matching
transient fence-<invocation-id>.service with a pinned root-owned
configuration. Block lifecycles apply and verify host network policy, disable
measured passwordless sudo, write production readiness only after required
verification, remain resident, and never restore access after readiness.
Activation also pins the reviewed privileged executables, proves the runner's
effective access to those executables, their ancestors, and sudo policy, and
matches a bounded local root-control inventory. Standard block also disables
container control paths. Degraded
unsafe_preserve preserves Docker/containerd and states that this invalidates
the ordinary containment claim. Audit installs owned non-blocking observation
rules and local DNS mediation while preserving passwordless sudo,
Docker/containerd access, and arbitrary outbound traffic. It emits
observation-only readiness and never claims containment. The root Action
wrapper carries an attested Linux release binary and delegates policy
authority to the agent.
Compatibility research converged on a bounded DNS-mediated realization for github_hosted_workflow_bootstrap_v5: seven broad-compatible exact bootstrap roots, one optional exact hosted-runner watchdog endpoint, six exact reporting compatibility names, at most eight previously unseen *.actions.githubusercontent.com names with at most two prefix labels, at most eight previously unseen single-label *.githubapp.com names, canonical A and AAAA upstream questions, bounded TTL-derived CNAME authorizations, and TCP 443 address materialization. Host DNS goes directly to Fence through a reviewed read-only resolver mount so the original caller remains attributable. Fence pins the unique reviewed Runner.Worker process and, by default, authorizes at most four exact dynamic results-storage accounts only when requested by that process. The block-only allow_github_artifacts: true option explicitly extends the same four-account lifetime budget to host DNS sockets uniquely attributed to bounded, runner-UID-matching descendants of the pinned worker; it does not authenticate an artifact action or storage-account ownership. One selected-profile runtime scenario and three quiet terminal-job replicas run behind the stable required integration aggregate, alongside namespace-isolated native network proof, lockdown scenarios, and packaged production-shaped standard block, degraded block, bounded user-wildcard Docker, and audit services.
User hostname policy may contain exact names or exactly one- and two-label
leading wildcard patterns. Wildcards are user policy rather than platform
compatibility: every * matches one nonempty label, all patterns share eight
lifetime concrete-name authorizations, and concrete names materialize lazily
without delaying readiness. Explicit user patterns remain available when
disable_broad_github_domains removes the separate platform-origin broad
compatibility classes.
The approved DNS classes and HTTPS channels remain usable by later code for egress. DNS timing and count, bounded query-label space, CNAME delegation, and resolved address-plus-port grants are explicit limitations. The default compatibility boundary now includes exact github.com, api.github.com, release-assets.githubusercontent.com, and hosted-compute-watchdog-prod-eus-01.githubapp.com HTTPS channels plus a bounded single-label *.githubapp.com class so Fence can run before repository checkout and common release-asset setup steps without suppressing observed hosted-runner service traffic. Post-ready codeload action downloads remain outside the default compatibility boundary. Results storage includes the five exact reviewed productionresultssa19.blob.core.windows.net, productionresultssa13.blob.core.windows.net, productionresultssa9.blob.core.windows.net, productionresultssa15.blob.core.windows.net, and productionresultssa17.blob.core.windows.net static compatibility accounts. Every other productionresultssa<1-to-5-decimal-digits>.blob.core.windows.net name requires a DNS request attributed to the pinned runner process unless the workflow explicitly enables allow_github_artifacts: true in block mode. Opt-in allows a uniquely attributed, runner-UID-matching descendant of the pinned worker to request an exact matching account for artifact, Pages, or cache compatibility, without increasing the shared four-account cap, permitting another port, or trusting hostname grammar or process ancestry as evidence of official GitHub ownership or upload intent. Any authorized account is an intentional workflow-accessible data-egress channel.
Workflows that do not need those broad GitHub compatibility roots can set disable_broad_github_domains: true. That opt-out keeps the core Actions status/finalization endpoints available, but removes github.com, api.github.com, release-assets.githubusercontent.com, and hosted-compute-watchdog-prod-eus-01.githubapp.com from the prehydrated HTTPS materialization set and disables new *.githubapp.com authorizations. The core reporting roots, exact receiver compatibility name, and five exact results-storage compatibility accounts remain available.
The first protected release is deliberately narrow:
GitHub-hosted ubuntu-24.04 x64 runner
host execution, not a job container
native nftables dual-stack enforcement
root-owned transient systemd service
x86_64-unknown-linux-gnu binary named fence
This boundary makes the security claim reviewable. Broader platform coverage may be investigated later, but it is not implied by portable tests, retained cross-build tooling, or the ability to compile a binary elsewhere.
Fence v0 is governed by the following requirements:
- Security-critical behavior is local, source-owned, and inspectable.
- Required enforcement fails closed; it never silently becomes observation.
- Network policy, platform-profile policy, and report contents are explicit.
- No privileged binary, policy, blocklist, or reporting instruction is fetched from a remote control plane while a job runs.
- A ready boundary is not later unlocked for convenience on an ephemeral protected runner.
- Routine repository builds, tests, linting, and packaging remain offline after inputs have been deliberately prepared.
- Claims describe precisely what is enforced, what remains possible, and what is unsupported or not proved by current evidence.
A hermetic build answers one important supply-chain question: can the expected project be built and tested from explicitly prepared, pinned, committed inputs without reaching the network? It does not answer a second question: what may a compromised build step, dependency, plugin, or action do while executing in a credential-bearing CI job?
A workflow can use pinned actions and vendored crates while later running code that attempts to:
- transmit credentials or source material;
- download additional unreviewed code;
- alter release output after a reviewed build phase;
- use broad runner privileges to bypass intended controls; or
- hide the attempt among ordinary platform traffic.
Fence is aimed at that second boundary. It is not a replacement for dependency pinning, source review, artifact attestations, least-privilege tokens, or isolated credentials. It is intended to complement them.
On a hosted runner, a later untrusted process that can immediately regain root-equivalent capabilities may be able to modify firewall state, use a container runtime to regain host authority, or otherwise undermine the boundary. Protected block mode therefore combines verified network policy with removal of ordinary passwordless-sudo and container-control paths.
Audit mode serves a different purpose: it reveals would-be policy violations without claiming containment. It does not remove the privileges that would be necessary for a containment assertion.
The first supported protected environment is exactly:
| Property | Required v0 value |
|---|---|
| Execution provider | GitHub-hosted runner |
| Image label | ubuntu-24.04 |
| Architecture | x86_64 |
| Binary target | x86_64-unknown-linux-gnu |
| Invocation location | Host job, not job-container execution |
| Firewall backend | Native nftables through nft |
| Address families | IPv4 and IPv6 together |
| Agent lifecycle | Root-owned transient systemd service |
Fence must inspect required capabilities before any security-state mutation. If the environment cannot satisfy the selected mode's required capabilities, the invocation fails before reporting ready.
The first protected release does not claim support for:
- macOS or Windows runners;
- Linux ARM runners;
- self-hosted or long-lived runner machines;
- job-container execution as the protected host boundary;
- Kubernetes-backed runner controllers;
- native platform targets other than Ubuntu 24.04 x64;
- remote policy, remote report upload, or runtime agent downloads;
- TLS, HTTP, repository, or package semantic filtering;
- guaranteed process attribution or process isolation for network findings; or
- sandbox, virtual-machine, kernel, or platform compromise containment.
The repository may retain prepared tooling or portable tests relevant to future platform experiments. That retention does not establish support.
Fence is designed to reduce exposure of:
- credentials made available to later workflow steps;
- checked-out source and generated artifacts present during a constrained job;
- the integrity of an intended narrow-network or offline build phase; and
- local evidence about effective egress policy and observed denied attempts.
Fence cannot itself protect:
- a secret intentionally sent to an allowed endpoint;
- an artifact exfiltrated through an allowed service;
- a credential provided to code before Fence is ready;
- malicious platform behavior or kernel compromise; or
- execution outside the declared supported runner and mode contract.
After Fence reports ready, assume later workflow code can:
- run arbitrary unprivileged subprocesses;
- inspect workspace-readable files and inherited environment variables;
- attempt new TCP and UDP network connections;
- attempt to invoke passwordless
sudo; - attempt to access Docker or containerd interfaces;
- read runner-readable Fence reports;
- attempt to alter Fence-owned files or firewall state; and
- remain alive until normal workflow finalization.
In ordinary protected block mode, do not assume that later code already has root or kernel authority. If the selected configuration deliberately preserves a root-equivalent path, the report and documentation must say that the normal containment claim is degraded.
After implementation and the required hosted tests pass, Fence may state that, for the supported runner and standard protected block configuration:
- an explicit effective outbound policy is applied before later steps;
- new outbound traffic outside that policy is rejected;
- IPv4 and IPv6 enforcement are installed and verified together;
- ordinary passwordless-sudo access is unavailable after readiness;
- ordinary Docker/containerd control is unavailable after readiness;
- the policy remains installed through runner teardown rather than being restored during post-job behavior;
- bounded local evidence is recorded without telemetry; and
- setup cannot report protection when required controls failed to apply.
Fence is not:
- a complete sandbox;
- a kernel or virtualization boundary;
- semantic authorization for data sent to an allowed endpoint;
- proof that no data left through an allowed channel;
- protection for execution before readiness;
- protection for arbitrary runner classes; or
- proof of compliance with a broader supply-chain framework by itself.
block is the only v0 containment mode. With
container_policy: "disable", the resident root-owned agent must:
- Validate configuration, limits, and platform capabilities.
- Capture the reviewed privileged executables and verify effective runner access plus the accepted local root-control inventory.
- Resolve and freeze hostname policy before mutation.
- Install and verify an effective dual-stack nftables policy.
- Start bounded local finding collection for rejected traffic.
- Remove ordinary passwordless-sudo access.
- Remove ordinary Docker/containerd control paths.
- Verify required lockdown and post-lockdown local-control state.
- Write a ready state and bounded local report.
- Keep verifying owned state while the runner remains alive.
- Never restore network or ordinary privilege paths after readiness.
If any required pre-ready step fails, block fails rather than downgrading to
audit.
A workflow may explicitly choose:
{
"mode": "block",
"container_policy": "unsafe_preserve"
}In this degraded configuration, Fence still applies and verifies network policy and disables passwordless sudo, but it intentionally preserves Docker/containerd access. Because ordinary container control is generally a root-equivalent bypass path, this configuration must:
- use an assurance label distinct from protected block mode;
- state in reports and documentation that containment is degraded;
- never be described as preventing firewall bypass by later code; and
- remain an explicit opt-in rather than an automatic compatibility fallback.
This mode exists for experimentation and migration. It is not the normal protection posture.
audit is a resident local observation mode. It must:
- validate the same policy input and fixed resource limits;
- resolve and freeze the same requested destination plan;
- install only Fence-owned non-blocking observation/logging rules;
- route host and Docker DNS through a local root-resident mediator that forwards queries without name authorization and retains bounded evidence;
- record bounded metadata for flows that would have been rejected by
block; - suggest bounded hostname and explicit IPv4/IPv6 allowlist entries for observed would-block destinations;
- preserve sudo and container access; and
- state clearly that no containment boundary was established.
Audit is not passive in the sense of making zero host changes: it adds owned observation rules and runs an agent. It is non-blocking and non-locking, not non-mutating.
These transitions are prohibited:
| Requested behavior | Failure | Prohibited result |
|---|---|---|
Protected block |
Cannot apply network policy | Continue as audit |
Protected block |
Cannot disable sudo | Report ready anyway |
Protected block |
Cannot disable container access | Implicitly preserve Docker |
| Either mode | Cannot validate report/state limits | Emit unbounded output |
| Either mode | Cannot satisfy platform requirements | Pretend support |
The expected implementation lifecycle is:
trusted launcher
-> write strict root-owned JSON configuration
-> start transient systemd service as root
fence run --config <root-owned-path>
-> validate configuration, trusted host paths, and local controls
-> resolve and freeze policy
-> apply selected mode behavior
-> verify installed state
-> write ready/report state
-> remain resident and observe/verify until VM teardown
The root GitHub Action wrapper performs the trusted-launcher work, waits for readiness, and renders bounded local evidence. The agent remains the enforcement authority; the wrapper is not.
Before launching the service, the wrapper copies its fixed executable runtime
file set and bundled agent into a root-owned launcher directory, verifies the
copy against a bounded SHA-256 integrity record, and protects every ancestor
of the registered Action directory that the runner can rename with a writable
self-bind mount. It then bind-mounts the protected copy read-only, nodev, and
nosuid over the registered Action code directory. The integrity record pins
the guarded paths by device and inode. The post hook verifies every exact
guard mount, path identity, read-only runtime mount, and runtime digest before
it trusts final evidence. Writable ancestor guards preserve ordinary access
for the workspace and already-loaded Actions; they prevent pathname
replacement without making those trees read-only. Setup failure before
readiness removes provisional launcher state only when no resident service
remains active; after readiness, the protected runtime and path guards are
never unmounted or restored before runner teardown.
Before readiness, Fence must either establish the complete required boundary or fail. If a failure occurs after Fence has created provisional state, it may remove only state it can conclusively attribute to the same invocation.
Pre-ready rollback is not a restore mechanism for untrusted later workflow steps. No downstream code has received a ready protection assertion at that point.
After readiness:
- do not restore outbound network access;
- do not restore passwordless sudo;
- do not restore Docker/containerd access;
- do not expose unlock, flush, disable, add-rule, or restore commands;
- do not accept remote or later-step policy updates; and
- do not make ordinary finalization traffic invisible through a broad unlock.
The protected runner is ephemeral. Teardown of the runner, not a privileged post-job restore path, ends enforcement.
The initial protected lifecycle must re-verify owned network, sudo-lockdown, container-lockdown, trusted executable, and local root-control state on a fixed five-second local interval. A verification failure after readiness is evidence of critical drift; it is not permission to restore or silently weaken policy.
A resident agent that detects post-ready tamper or verification drift must:
- record a critical local finding;
- preserve the last verified and observed state where feasible;
- avoid silently reporting healthy protection; and
- avoid destructive attempts to terminate unrelated runner processes.
Setup-time failure is job-failing immediately. The root Action post hook reads the bounded local report and fails the job when critical resident findings are present. The post hook must never stop the resident service or restore access.
The first implemented binary is named fence and exposes only:
fence --version
fence check-support
fence render-plan --config <path>
fence run --config <root-owned-path>
Command behavior:
| Command | Purpose | Security-state mutation |
|---|---|---|
--version |
Identify binary build/version metadata. | None |
check-support |
Inspect host identity and native-backend presence read-only, and report that activation-time fingerprint and readiness checks are still required. | None |
render-plan --config |
Validate JSON, resolve hostnames under bounds, and render the exact frozen policy plus native-ruleset preview. | None |
run --config |
Reject ordinary direct execution before config read; from the matching trusted root transient service, execute a supported standard block, explicit degraded block, or audit observation lifecycle from pinned root-owned input. | Yes, only from the validated trusted-launcher path |
There are no v0 commands to restore, unlock, disable, flush, update, or interactively administer a running agent.
The current v0 binary emits versioned JSON only. Successful command results are written to
standard output; structured failures are written to standard error. The
response envelope contains schema_version, command, status,
fence_version, and exactly one of data or error. Command-shape failures
exit 2; policy, resolution, unsupported, and not-implemented failures exit
1.
Successful render-plan data additionally contains:
policy_hash_schema_version: 9andpolicy_hashfor normalized logical policy, including fixed implicit IPv6 control rules and the selected versioned DNS-mediated compatibility descriptor;ruleset_hashfor the exact rendered native base-ruleset preview before any runtime trusted-launcher TTL-derived replacement; andnetwork_enforcement_preview, including fixed owned objects, hook priority, NFLOG constants, and the generated ruleset text.
These values are planning evidence only. They do not show that any rule has been applied or verified.
Successful check-support data additionally contains
hosted_runner_fingerprint with schema version 3, the fixed intended
protected target, the observation method, and status
accepted_reference_not_checked. A reviewed ubuntu-24.04 integration
observation pins the single supported host shape. The read-only probe does not
execute the destructive activation-time fingerprint comparison, and its
response is not evidence that protection is active.
The security-critical agent consumes and produces JSON. The implementation
must use exact-pinned, vendored serde and serde_json dependencies with
strict unknown-field rejection, rather than manually implementing a new
configuration parser or JSON serializer.
A representative v0 configuration is:
{
"schema_version": 1,
"mode": "block",
"invocation_id": "build-0123456789abcdef",
"container_policy": "disable",
"allowlist": [
{
"destination_type": "hostname",
"destination": "example.com",
"protocol": "tcp",
"port": 443
},
{
"destination_type": "cidr",
"destination": "192.0.2.0/24",
"protocol": "udp",
"port": 123
}
]
}Top-level fields:
| Field | Requirement |
|---|---|
schema_version |
Required integer; v0 accepts exactly 1. |
mode |
Required enum: "block" or "audit"; omission is invalid. |
invocation_id |
Required reportable non-secret slug: 1 through 64 lowercase ASCII letters or digits with internal hyphens only, used for reports and derived runtime paths, not kernel object identifiers. |
platform_profile |
Optional. Omission and explicit "github_hosted_workflow_bootstrap_v5" both select the one supported v0 descriptor. Every other value is rejected before mutation. |
container_policy |
For block, omitted means "disable" and "unsafe_preserve" is explicit degraded behavior; audit cannot request protection-bearing lockdown. |
disable_broad_github_domains |
Optional boolean, default false. When true, the runtime descriptor removes github.com, api.github.com, release-assets.githubusercontent.com, hosted-compute-watchdog-prod-eus-01.githubapp.com, and new *.githubapp.com authorizations while retaining core Actions status/finalization endpoints and exact reporting compatibility names. |
allow_github_artifacts |
Optional boolean, default false; valid only in block mode when true. Permits host DNS requests uniquely attributed to runner-UID-matching descendants of the pinned Runner.Worker for exact dynamic results-storage accounts on TCP port 443 within the existing shared four-account lifetime cap. Artifact uploads are an explicit data-egress channel; neither account-name grammar nor process attribution proves official GitHub ownership or artifact-upload intent. |
allowlist |
Required array; it may be empty to declare zero user egress. |
Allowlist entry fields:
| Field | Accepted values |
|---|---|
destination_type |
"hostname", "ip", or "cidr" |
destination |
A validated exact hostname, bounded leading-wildcard hostname pattern, literal IPv4/IPv6 address, or explicit IPv4/IPv6 network matching its type |
protocol |
"tcp" or "udp" |
port |
Required integer from 1 through 65535 |
Fence rejects configuration before mutation when it contains:
- missing required fields or unknown fields;
- a schema version other than
1; - a missing or unknown mode;
- a malformed, empty, unbounded, or secret-bearing invocation identifier;
- malformed wildcard hostnames, including bare, embedded, trailing, or more than two leading whole-label wildcards;
- wildcard hostname suffixes with fewer than two concrete labels;
- URL schemes, userinfo, paths, queries, or fragments in destinations;
- resolver numeric-address hostname spellings, such as abbreviated or radix-prefixed IPv4 aliases;
- an exact user hostname matching the non-static runner-authorized results-storage class;
- a destination that does not match its declared type;
- portless or protocol-less allowlist entries;
- zero or out-of-range ports;
- malformed or ambiguous IPv6 values;
- an unrecognized platform profile;
- a protection-bearing container option in audit mode;
- a malformed
allow_github_artifactsboolean orallow_github_artifacts: truein audit mode; - any policy source URL or report upload destination; or
- input exceeding fixed v0 bounds.
The first implementation uses source-reviewed constants rather than user-tunable resource limits:
| Resource | v0 maximum |
|---|---|
| Raw JSON configuration input | 256 KiB |
| User allowlist entries | 64 |
| Resolved addresses per hostname | 32 |
| Expanded effective rule entries | 1024 |
| Total DNS planning budget | 30 seconds |
| Single hostname resolution budget | 5 seconds |
| Hosted workflow-bootstrap dynamic Actions-suffix authorizations | 8 lifetime names |
| Hosted workflow-bootstrap dynamic suffix labels before the documented suffix | 2 |
| User wildcard-hostname authorizations shared across all patterns | 8 lifetime names |
| User wildcard-hostname leading labels | 2 exact labels |
| Hosted workflow-bootstrap derived CNAME authorizations | 32 |
| Hosted workflow-bootstrap derived CNAME depth | 4 |
| Hosted workflow-bootstrap observed DNS TTL | 300 seconds |
| Sampled connection findings retained | 1024 |
| Serialized local report size | 4 MiB |
| Public post-job network activity rows | 20 |
| Prefixed structured post-job report | 16 KiB |
Planning uses the host system resolver behind an injectable interface, allows at most one outstanding hostname query, and applies the per-host timeout inside the total DNS budget. If limits are exceeded, Fence fails before mutation; it does not truncate effective policy. Findings may be capped after readiness, provided reports expose truncation and retain aggregate counters.
Configuration does not accept ready, state, or report output paths. Fence
validates invocation_id and derives its files under a fixed root-owned
runtime directory, for example:
/run/fence/<invocation-id>/
config.json
ready.json
report.json
state.json
Requirements:
- the directory and mutable state are created/owned by root;
- later workflow code cannot write the ready, state, or report files;
- ready/report output is readable by the runner user for local rendering;
- file creation resists link/path traversal and cross-invocation confusion;
- runtime contents are not credentials or an unlock capability; and
- nftables ownership uses fixed source-defined identifiers and never interpolates the invocation identifier into kernel object names.
User allowlist entries are explicit transport permissions. For example:
{
"destination_type": "hostname",
"destination": "example.com",
"protocol": "tcp",
"port": 443
}allows TCP connections to the frozen resolved addresses for example.com on
port 443. It does not constrain:
- HTTP paths or methods;
- TLS SNI or certificate identity;
- request or response content;
- package, organization, repository, or artifact identity; or
- abuse of an allowed endpoint as an exfiltration channel.
A hostname destination may instead use exactly one or two leading whole-label wildcards:
{
"destination_type": "hostname",
"destination": "*.docker.io",
"protocol": "tcp",
"port": 443
}*.docker.io matches auth.docker.io but not docker.io or
a.auth.docker.io; *.*.docker.io matches a.b.docker.io and no other label
depth. Fence rejects embedded stars, three or more wildcard labels, trailing
dots, non-ASCII input, and suffixes such as *.com with fewer than two
concrete labels. Fence does not carry a public-suffix database: syntactically
valid shared suffixes such as *.co.uk or *.github.io are explicit broad
user permissions, not proof that the workflow author controls the suffix.
*.docker.io can cover both auth.docker.io and registry-1.docker.io, but it
does not preserve Docker by itself. A Docker-using block-mode workflow must
separately select container_policy: unsafe_preserve and accept the degraded
assurance status. The pattern also does not guarantee a complete image pull:
layer, CDN, or storage traffic may use unrelated domains that require separate
explicit policy.
All user wildcard patterns share eight lifetime concrete-name authorizations per invocation. Repeated A and AAAA questions for one concrete name use one slot. Failed, empty, and NXDOMAIN lookups still consume the slot because the attacker-controlled query name is itself a DNS data channel. CNAME descendants use the separate existing derived-authorization bounds. The ninth matching name receives a minimal REFUSED containing only the original question and does not gain wildcard-derived transports.
CIDR allowlist entries are broader by construction and must be highlighted in the effective-policy report. A CIDR entry always names its protocol and port.
An empty user policy is valid. Protected block mode then allows no
user-declared new outbound channel after readiness. The selected
github_hosted_workflow_bootstrap_v5 compatibility descriptor, loopback,
established/related behavior, and fixed reported ICMPv6 control traffic remain
separate explicit backend contributions.
render-plan resolves hostname allowlist entries to produce a bounded preview.
Protected activation instead builds one logical hostname policy and delegates
all production resolution to the root-resident DNS mediator:
- Parse and validate the entire JSON document.
- Merge exact platform and user hostnames separately from sorted user wildcard patterns, preserving every complete protocol/port set.
- Compute
policy_hash_schema_version: 9and the logical-policy hash as lowercase SHA-256 over compact canonical JSON containing that schema version, mode, normalized container policy, normalized platform profile, the selected DNS-mediated compatibility descriptor, sorted user hostname destinations (including canonical wildcard patterns), IP and CIDR entries with protocol and port, and fixed implicit IPv6 rules. - In
render-plan, resolve exact hostnames under the existing limits, retain wildcard patterns as logical runtime policy without resolving them, report the concrete base preview, and stop without mutation. - In protected
run, prehydrate every required exact platform hostname and exact user hostname before firewall activation, materialize every configured transport, and record the active address realization separately from the logical hash. A required hostname retries only transient or addresslessA/AAAArounds, at most three attempts within one shared ten-second startup deadline. Integrity-invalid responses fail immediately, and post-ready refresh remains single-round. User wildcard patterns never prehydrate or delay readiness; matching concrete names materialize on demand. After required roots complete, the optional hosted-runner watchdog receives one bounded round before readiness but does not make readiness fail on a transient empty answer.
After readiness, platform roots refresh every five seconds. Exact user roots refresh
from bounded observed TTLs, no later than sixty seconds and before their active
authorization expires. A wildcard-matched name remains admitted for the
invocation lifetime while its materialized rules retain only the bounded
observed TTL plus refresh overlap. CNAME descendants inherit the complete
transport set of their exact or admitted wildcard source hostname. Address
rotation changes the active ruleset_hash without changing the logical
policy_hash.
Every retained CNAME authorization is derived from one response-local chain
rooted at the echoed question. The chain must be linear, acyclic, within the
existing depth and capacity bounds, and terminate at every returned A or
AAAA owner. Duplicate identical edges and terminal address records use the
minimum TTL. A valid zero-TTL CNAME edge receives a one-second effective
lineage and materialization lifetime, matching a zero-TTL terminal address. An
address record whose family differs from the echoed question fails validation.
A fully rooted CNAME response with no address records is treated as
address-family NODATA and retains no derived authorization. Conflicting,
unrelated, unused, or out-of-section CNAME edges and unrelated address owners
fail the entire block-mode response without mutating authorization state. A valid chain keeps
the queried root's complete origins and transports even when an intermediate
name is independently authorized.
Derived authorization and address lifetimes use the minimum remaining root,
edge, and address TTL. Block mode commits new derived authorization only after
the corresponding address materialization succeeds; audit may forward an
invalid response but never retains authorization from it.
The single resident firewall owner coordinates block-mode response transactions. It rechecks queued responses in arrival order against a cloned authorization state at one owner-selected instant, excludes stale or over-capacity responses before rendering policy, and applies and structurally verifies only the accepted candidate. The owner publishes the candidate authorization state and active materializations together after verification, then reports success to the DNS worker. Materialization expiry is captured at response validation time and is not restarted while a request waits in the owner queue. A transaction that introduces no new rule still requires structural verification before its authorization state can be published.
The policy hash intentionally excludes invocation identifiers, derived paths,
support observations, timing data, apply/report status, pinned process IDs,
runtime-authorized results-storage hostnames, and transient resolved addresses
so identical configured policy produces the same digest in separate
invocations. The source-defined results-storage grammar, account cap, and
authorization origin are included through the selected descriptor. Fence also
renders ruleset_hash, the lowercase SHA-256 of the exact deterministic native
base-ruleset preview, to distinguish policy identity from backend realization.
For the selected hosted workflow-bootstrap descriptor, the preview does not
claim to include runtime TTL-derived replacement rules.
Fence does not implicitly permit workflow-controlled DNS traffic after readiness. If a workflow needs a resolver after the boundary is established, the workflow author must express the resolver address, protocol, and port as an allowlist entry and accept that DNS is an available data channel.
The selected github_hosted_workflow_bootstrap_v5 descriptor is a narrow exception: its trusted-launcher realization bind-mounts a deterministic root-owned resolver file read-only over the reviewed hosted-runner resolver target. Host processes therefore query the root-resident mediator directly, Docker keeps a separate local route, and upstream UDP DNS is permitted only from the mediator. Local TCP DNS queries are translated to that same bounded UDP upstream path; the protected firewall never grants upstream TCP DNS access.
The approved query set, DNS timing and count, bounded query-label space, CNAME
descendants, and resolved HTTPS addresses remain disclosed egress limitations.
The fixed upstream DNS tuple is classified against the current socket policy before generic established/related acceptance.
In block mode, an approved A or AAAA answer that introduces an address is
not returned to the workflow until every matching configured transport has a
corresponding owned nftables rule applied and verified. Address-bearing
responses are atomic at the policy boundary: every returned address and
configured transport must be materializable before Fence releases any answer.
Approved zero-TTL addresses use a one-second materialization lifetime, and
valid zero-TTL CNAME edges use a one-second effective lineage lifetime, before
the existing thirty-second refresh overlap. DNS listeners submit bounded
address sets to the single resident firewall owner and wait for its explicit
result. A malformed, structurally incomplete, or response-lineage-invalid
response, partial coverage, queue rejection, or service disconnection returns
a retryable DNS SERVFAIL without answer records instead of exposing an
address that the active firewall would reject. Rejections are counted in
bounded DNS evidence as warnings;
failure to apply or verify owned rules remains a critical finding. Audit mode
continues forwarding answers without this gate because it does not enforce
network restrictions. DNS evidence records saturating
materialization_batch_count, materialization_request_rejections, and
materialization_update_max_milliseconds values without retaining raw DNS
messages or packet bytes.
Normal hosted workflow bootstrapping and finalization require narrow network paths after readiness. Any built-in profile for such traffic must meet all of these gates before release:
- hosted lifecycle tests identify a stable minimal set of GitHub status and finalization endpoints and prove each endpoint is necessary for measured finalization;
- Azure platform exceptions are separately bounded and documented as compatibility tradeoffs;
- profile contents live in reviewed source and have a versioned identifier;
- the local report identifies the selected profile and its frozen addresses;
- documentation states that later untrusted code can also use those permitted endpoints as egress channels.
Required hosted evidence proves the following bounded descriptor as the v0 default:
github_hosted_workflow_bootstrap_v5
root-resident mediator upstream UDP DNS to 168.63.129.16:53
root-owned Azure WireServer service traffic:
exact destination 168.63.129.16
TCP 80 and 32526 only
meta skuid 0 only
not a workflow, container-forwarded, hostname, CIDR, or user allowance
shared Azure instance metadata service traffic:
exact destination 169.254.169.254
TCP 80 only
host and container-forwarded traffic
not a hostname, CIDR, or user allowance
required exact bootstrap roots:
github.com
api.github.com
release-assets.githubusercontent.com
vstoken.actions.githubusercontent.com
pipelines.actions.githubusercontent.com
payload.pipelines.actions.githubusercontent.com
results-receiver.actions.githubusercontent.com
optional exact hosted-runner compatibility:
hosted-compute-watchdog-prod-eus-01.githubapp.com
exact compatibility name:
actions-results-receiver-production.githubapp.com
exact results-storage compatibility names:
productionresultssa19.blob.core.windows.net
productionresultssa13.blob.core.windows.net
productionresultssa9.blob.core.windows.net
productionresultssa15.blob.core.windows.net
productionresultssa17.blob.core.windows.net
bounded dynamic class:
*.actions.githubusercontent.com
at most 8 unique lifetime names
at most 2 prefix labels
broad-compatible bounded dynamic class:
*.githubapp.com
at most 8 unique lifetime names
exactly 1 prefix label
disabled by disable_broad_github_domains
runner-authorized results storage:
productionresultssa<1-to-5-decimal-digits>.blob.core.windows.net
host A and AAAA queries only
default: request socket must belong to the pinned Runner.Worker identity
allow_github_artifacts: uniquely owned host sockets from bounded pinned-worker descendants also accepted
at most 4 unique lifetime accounts shared across both origins
materialize TCP 443 only
canonical A and AAAA forwarding only
at most 32 derived CNAME authorizations
derived CNAME depth at most 4
observed TTL at most 300 seconds
prehydrate exact platform and user roots before readiness with at most 3 transient rounds per required hostname under one shared 10-second deadline and the fixed 300-second TTL bound
refresh platform roots every 5 seconds
refresh user roots from observed TTL, at least once every 60 seconds
retain address grants for TTL plus 30 seconds
treat an approved zero-TTL address or CNAME edge as a 1-second grant before the overlap
materialize every configured user transport; dynamic platform names remain TCP 443
release address-bearing DNS answers only after complete verified materialization
return retryable SERVFAIL when bounded materialization work cannot complete
Omission of platform_profile selects this descriptor. Explicit
"github_hosted_workflow_bootstrap_v5" is equivalent. Every other value is rejected
before mutation. The planner reports the base ruleset before runtime
materialization; the trusted launcher activates the bounded DNS mediator,
applies TTL-derived replacement rules, and records the active realization.
The optional disable_broad_github_domains flag keeps the descriptor version
and schema unchanged while selecting the four core Actions bootstrap roots
instead of the seven-root broad-compatible default and disabling new
*.githubapp.com authorizations. Exact reporting compatibility names remain.
The root-only WireServer permissions and shared instance-metadata permission also remain because the option controls GitHub compatibility channels, not Azure platform services.
Before DNS routing changes, Fence requires exactly one Runner.Worker owned by the fixed runner principal with reviewed Runner.Listener ancestry. It pins the PID, process start time, and executable device/inode. Every dynamic results-storage authorization revalidates that identity and attributes the host DNS client socket through bounded /proc inspection. By default, only the pinned worker can authorize an account. With block-only allow_github_artifacts: true, a uniquely owned host socket belonging to a bounded, runner-UID-matching descendant of that same pinned worker may also authorize an exact account under distinct opt_in_github_artifact_dns provenance and the shared four-account lifetime cap. Fence rechecks the descendant's identity, start time, bounded ancestry, and unique socket ownership before accepting the request. PID reuse, executable drift, ancestry drift, ambiguous ownership, Docker-originated queries, wrong-UID requests, and exhausted capacity fail closed in both configurations. The opt-in authenticates the local socket and ancestry, not an official action, GitHub account ownership, a signed URL, or encrypted upload contents. Exact or wildcard user hostnames still cannot derive a non-static results-storage account through a CNAME, including during bootstrap prehydration. The five source-defined exact static compatibility roots do not require process attribution. A trusted attribution or account-cap failure returns a minimal SERVFAIL; an untrusted request receives a minimal REFUSED containing only the original question without forwarding.
Authorized results-storage hostnames, exact pinned-runner or opt_in_github_artifact_dns origin, counts, attribution failures, rejections, truncation, and the selected allow_github_artifacts setting are recorded in bounded DNS evidence. Dynamic
hostnames and process IDs do not enter the logical policy hash. Active concrete
addresses remain represented by the changing ruleset_hash. Once an account
is authorized, its resolved TCP 443 addresses are available to later workflow
code and remain a disclosed channel; Fence does not inspect TLS, credentials,
or signed URL semantics.
Earlier compatibility experiments established that fixed-host guesses were insufficient and that wildcard or unbounded storage-service broadening was not acceptable as a production answer. Those superseded profiles are no longer accepted by the public configuration parser or planner. The selected descriptor above is the only v0 profile.
Fence v0 implements native nftables through the nft command and uses an
inet ruleset so IPv4 and IPv6 behavior can be expressed, installed, and
verified together. It does not make its first protection contract depend on
legacy compatibility frontends.
The implementation must prove the required nft capabilities on the supported
runner image before mutation and fail unsupported rather than selecting a
weaker backend silently.
Fence owns a fixed singleton table. Invocation identifiers remain reportable metadata and runtime-path inputs; they are not rendered into kernel object identifiers. The v0 logical shape is:
table inet fence_v0
chain fence_output
hook output priority 10; policy accept
chain fence_forward
hook forward priority 10; policy accept
chain fence_classify
chain fence_violation
The owned-object constants are:
| Constant | v0 value |
|---|---|
| Table family and name | inet fence_v0 |
| Hook chains | fence_output, fence_forward |
| Regular chains | fence_classify, fence_violation |
| Hook priority | numeric 10 |
| NFLOG group | 4242 |
| NFLOG prefixes | fence-v0-block, fence-v0-audit |
| NFLOG packet-prefix bound | 64 bytes |
| NFLOG sampling | 100 events/second with burst 100 |
Privileged hosted tests prove the fixed priority on the supported image. A future failure requires a reviewed source-constant change; Fence does not select a priority dynamically. The ownership invariants are:
- Fence owns its own table and rules;
- only one active Fence lifecycle may own the fixed table on a runner;
- a preexisting
inet fence_v0table is a hard conflict, never overwritten; - no user input is interpolated as arbitrary nft syntax;
- user-defined allowlist entries become typed generated rules only;
- output and any relevant forwarded/container path are handled explicitly;
- rules are installed atomically from a generated ruleset; and
- active structured state is verified before readiness.
The effective block plan must include:
- Necessary loopback behavior.
- Established/related return traffic.
- Fixed reported outbound ICMPv6 router solicitation, neighbor solicitation,
and neighbor advertisement rules, requiring hop limit
255. - Effective TCP and UDP user/profile allowlist entries.
- Explicit treatment of traffic paths relevant during container lockdown.
- Counted and bounded logged rejection of undeclared new traffic.
- No implicit runtime DNS allowlist entry.
- Dual-stack state in one verified logical ruleset.
Unauthorized new connections should fail promptly through reject behavior, rather than hanging builds through silent drop behavior.
Audit installs owned non-blocking observation rules corresponding to what the block plan would reject. It must not:
- make rejection rules terminal;
- remove sudo or container access;
- declare readiness as containment; or
- feed observations into remote services.
The report should identify would-block decisions using the same normalized policy representation as block mode so audit evidence can inform a future explicit block policy.
Fence uses an owned NFLOG path for connection metadata findings. The selected
NFLOG interface may transiently deliver at most a 64-byte packet prefix so
Fence can parse ordinary IP and TCP/UDP endpoint fields. That bound is not a
semantic guarantee that the bytes contain headers only: a short packet may
include early application bytes in memory. Fence must immediately reduce the
prefix to permitted metadata, discard raw bytes, and never retain or serialize
packet bytes. It must not scrape unbounded global system logs.
Fence implements that reader using Linux-target-only, exact-pinned
netlink-sys = { version = "=0.8.8", default-features = false } for the
synchronous socket boundary. Fence owns a narrow safe-Rust serializer for the
three fixed NFLOG configuration requests and parses bounded findings locally.
This avoids a general packet framework, an async runtime, and first-party
unsafe or FFI code. The Linux-only dependency remains excluded from portable
macOS builds because Linux netlink has no macOS equivalent.
Fence also declares Linux-target-only, exact-pinned libc = "=0.2.186" so
runtime files can use the platform O_NOFOLLOW and
O_CLOEXEC open flags and the NFLOG serializer can use Linux UAPI constants
without first-party unsafe code. Making it direct records the explicit
runtime-safety and wire-contract input.
A retained finding may contain:
- bounded timestamp;
- selected mode and assurance status;
- actual verdict or would-block classification;
- address family;
- transport protocol;
- remote address and port;
- matching rule class or no-match reason; and
- aggregate counter references; and
- optional bounded local attribution: attribution status, actor class, process ID, executable basename, and at most four parent executable basenames.
Local attribution is best effort and uses bounded snapshots of Linux socket and process metadata. Unconnected host UDP sockets with wildcard local or remote endpoints can be correlated when the matching socket has one observable owner. Forwarded or container UDP traffic is not matched against the host socket table, so it cannot be linked to an unrelated host program. Short-lived processes, shared sockets, namespace boundaries, or scan limits may produce not_found, ambiguous, or scan_limit_exceeded rather than a guessed owner. Attribution remains local; Fence does not upload it or install global audit rules.
A retained finding must not contain:
- packet prefix bytes or application payload;
- environment values;
- credentials;
- arbitrary process arguments;
- full executable paths, working directories, or process environments;
- arbitrary file content; or
- remotely provided annotations.
After the finding sample cap is reached, Fence records aggregate counters and an explicit truncation marker without increasing the report beyond its size bound.
Command success is not proof that a boundary exists. Before block readiness, Fence must parse structured active nftables state and verify:
- exactly the expected owned table and chains exist;
- required hooks and reviewed priorities are present;
- expected allowlist and rejection rules appear once and in deterministic order;
- both address families are represented under the verified plan;
- no temporary setup allowlist entry remains active;
- logging/counter rule state matches selected mode; and
- any container-path rule required during lockdown is installed as designed.
Audit mode verifies the corresponding non-blocking observation state before it reports active observation.
Fence v0 supports one measured GitHub-hosted ubuntu-24.04 x64 runner shape,
not a generalized Linux-host mutation strategy. The
script/observe-hosted-runner integration step records bounded facts used to
review that shape, while protected activation compares its accepted live-host
facts before mutation. Three same-image schema-4 observations established
the structured trusted-path, permission-ancestor, and closed local-control
values accepted by production fingerprint schema 3. The fixed Azure-agent
identity remains observation evidence rather than an activation input.
The observation is limited to the facts required to design lockdown:
- OS and architecture plus the expected runner principal and group names;
- canonical target, file type, owner/group classification, mode, device/inode
identity, executable state, and runner writability for fixed native
nft,systemd-run,systemctl, Docker, and other trusted command paths; - the same bounded metadata and runner-writability result for observed sudo policy sources, without emitting their contents;
- metadata for the fixed command and sudo-policy ancestor directories plus a create-and-delete result from one exclusive empty synthetic runner file;
- state of fixed Docker/containerd service and socket unit names;
- metadata for fixed Docker/containerd socket paths;
- an aggregate count of existing Docker workloads; and
- reviewed names or domain-separated name hashes, profile-specific source SHA-256 digests, and reduced direct-principal or runner-group
NOPASSWDmarker classifications of sudo policy sources; exact sources hash the complete file, while90-cloud-init-usersrequires exactly one generated cloud-init version/timestamp header and hashes every remaining byte with thefence-cloud-init-sudo-body-v1domain; - reviewed root
dockerdandcontainerdprocess identities; and - a bounded inventory of runner-reachable root Unix stream/seqpacket listeners and root TCP listeners, with TCP addresses reduced to family, wildcard/loopback/other-local class, and port.
Schema 4 reports owners as root, runner, or other, and groups as
root, runner_primary, runner_member, or other. Fixed command and ancestor
canonical targets are emitted only when they exactly match the reviewed path;
all other targets use unreviewed_canonical_target.
Runner access is a boolean or null when it cannot be observed. Each synthetic
directory probe reports only created_and_removed, created_and_root_removed,
denied, or unavailable; the random probe name is never emitted.
The serialized host identity is limited to the exact reviewed Ubuntu release, architecture, runner principal, and runner groups. Unknown runner groups and sudo source names use domain-separated SHA-256 identities so multiplicity is retained without disclosing the raw value. Raw group names remain private to the collector for effective identity and sudo-marker classification. The resolver target, sudo source targets, fixed-unit states, and container-socket owner/group names are emitted only as exact reviewed values or fixed unreviewed classifications. Serialized dynamic strings are printable ASCII; filesystem surrogates and other non-UTF-8 names are never emitted directly.
The local-control candidate tries at most three times to obtain two matching security-relevant private snapshots fifty milliseconds apart. Excluded inaccessible Unix listener identities and their evidence-only count do not affect stability; acquisition failures, bounds, unresolved listeners, retained root container processes, reachable Unix listeners, root TCP listeners, and their private owner identities still must match. It records explicit stable, unstable, bounds_exceeded, or unavailable status rather than silently accepting a partial scan.
Collection bounds cover processes, descriptors, owners per socket, listeners, and container-runtime processes. Unix socket names are retained only as domain-separated SHA-256 values. TCP listener addresses, raw Unix names, unreviewed cgroup paths, process arguments, and arbitrary executable paths are not serialized. Exact canonical executable paths are emitted only from a source-reviewed allowlist; every other path is reduced to unreviewed_executable_path. Socket owners contain only UID, a sanitized basename, that reviewed path or classification, a fixed reviewed unified-cgroup identity or unreviewed_cgroup, and a bounded process count. An unavailable or unreviewed executable or cgroup identity makes ownership incomplete rather than emitting the raw value. Equivalent public owners, listeners, and root container processes are aggregated with positive processes or instances multiplicities so redaction does not erase multiplicity.
Filesystem Unix sockets are retained only when an access check performed after dropping to the runner's UID, GID, and supplementary groups proves effective write access, including ACL effects. Abstract listeners owned by a resolved root process are treated as reachable. Socket inode/UID, owner or container PID, start-time and executable device/inode pins, and private executable-path and cgroup fingerprints for security-relevant retained entries participate only in the private stability comparison and are stripped before public serialization. An excluded socket that becomes reachable enters the retained inventory and cannot be ignored.
Filesystem reachability probes are attempted only for a resolved root owner,
an unresolved possible root owner, a root-owned socket file, or unavailable
socket-file identity. Nonroot-only socket files are discarded before spawning
a runner-identity probe. At most forty root candidates may be probed per
snapshot, each probe has a one-second deadline, the observation has a shared
five-second probe deadline, and the first unavailable result stops further
probes. Exceeding the candidate bound or exhausting the deadline fails closed;
the existing one-mebibyte /proc/net/unix byte bound still bounds cheap parsing
without treating arbitrary nonroot listener count as accepted root control.
Protected activation requires a stable, within-bounds observation with complete
reachability and ownership. Before mutation, the retained root container,
runner-reachable root Unix, and root TCP inventory must exactly match the
schema-3 accepted reference. Standard block may remove accepted container
processes or owners while stopping the measured runtimes. It may also remove
the one exact fingerprint-tagged Docker Unix listener after its reviewed
dockerd owner has exited and the accepted docker.socket unit is stopped; if
that listener remains, its non-container owners and multiplicity must remain
exact. No other endpoint, owner, identity, or multiplicity may change. Fence
then captures the verified post-lockdown reduction as its resident baseline.
Degraded block and audit must still match the accepted inventory exactly. Every
mode re-observes the resident baseline before readiness and every five seconds
afterward. Instability, collection bounds, incomplete acquisition, an
unreviewed transition reduction, an added endpoint, or later baseline drift
fails closed before readiness or becomes a terminal critical finding after
readiness.
The mutation probe is limited to /, /etc, /etc/sudoers.d, /usr,
/usr/bin, and /usr/sbin; it uses an exclusive empty file, immediately
deletes it as the runner when possible, and requires root cleanup after a
partial result. It does not modify commands or sudo policy. The observer does
not emit sudo policy text, environment values, process arguments, workload
identifiers, arbitrary host files, credentials, or payload data. Observation
output remains observation_only_no_protection. check-support remains
read-only and does not claim that protection is active. Source-built protected
run fails before mutation unless the live host satisfies fingerprint schema
3, including effective path access, profile-specific sudo policy digests, and the local-control inventory. An ephemeral source-built candidate and the published distribution bundle expose the same schema-3 fingerprint. Their classifier recursively validates the schema-4 observer envelope and compares every enforced schema-3 fact before activation; it does not project to a retired fingerprint shape or permit a release/canary skip. Malformed, internally inconsistent, incomplete, truncated, oversized, or unknown evidence fails closed.
Publishing fingerprint schema 3 does not change observer schema 4, bundle-manifest schema 4, runtime-evidence schema 5, the Action input contract, or the supported fixed GitHub-hosted ubuntu-24.04 x64 platform. The observer's sudo sha256 value is interpreted by the accepted source's required digest profile. Only the exact reviewed drop_in / 90-cloud-init-users identity may use cloud_init_generated_header_v1; every other source must use exact_file_v1. The generated-header profile rejects missing, malformed, or repeated headers and does not normalize body comments, whitespace, rules, or line endings. Activation also retains the raw whole-file SHA-256 in its runtime pin so any post-acceptance byte change remains detectable.
The accepted reference records:
| Surface | Accepted observed shape |
|---|---|
| Host identity | Ubuntu 24.04, x86_64, principal runner |
| Required group consequence | The principal is a member of docker, providing access to the Docker socket before lockdown |
| Trusted executables | Twelve exact root-owned regular files with reviewed canonical paths and modes: /usr/bin/docker, /usr/bin/id, /usr/bin/mount, /usr/bin/stat, /usr/bin/sudo, /usr/bin/systemctl, /usr/bin/systemd-run, /usr/bin/test, /usr/bin/true, /usr/bin/umount, /usr/sbin/visudo, and /usr/sbin/nft |
| Permission ancestors | Exact root-owned /, /etc, /etc/sudoers.d, /usr, /usr/bin, and /usr/sbin identities and modes; the runner can search every executable ancestor but cannot search /etc/sudoers.d, and cannot write any of them |
| Passwordless sudo classification | The runner sudo drop-in is the sole observed policy source with a direct runner-applicable NOPASSWD marker |
| Sudo policy integrity | sudoers, README, and runner use exact-file SHA-256; 90-cloud-init-users uses a domain-separated SHA-256 over the exact bytes following one validated cloud-init generated header, with the raw whole-file digest retained for runtime mutation checks |
| Container activation paths | docker.service, docker.socket, and containerd.service are loaded and active; containerd.socket is not present |
| Container sockets | Docker sockets are root-owned with docker group access; the containerd socket is root-owned with root group access |
| Existing Docker workload count | Zero at observation time |
| Local root-control inventory | Exact containerd and dockerd process identities, wildcard IPv4 and IPv6 TCP port 22 owned by systemd, and ten exact domain-separated Unix-listener identities with reviewed bounded owners and multiplicities |
The accepted reference is enforced by the trusted-launcher lifecycle and proved by destructive hosted integration. Image drift is a support failure until the observed shape and required lockdown behavior receive review.
The security-critical agent runs as a root-owned transient systemd service.
The launcher must establish the service before block-mode lockdown removes
ordinary passwordless-sudo access. A detached unsupervised process is not the
v0 lifecycle.
The service has one initial root-owned configuration, derives one runtime directory, performs one setup lifecycle, remains resident for observation and verification, and exits only through runner teardown or a setup failure path.
The trusted-launcher lifecycles use the production storage and
service-identity boundary. Production intake accepts only
/run/fence/<invocation-id>/config.json, requires pinned root-owned
0755 runtime directories and a root-owned 0600 regular configuration
file as the only initial invocation-directory entry, opens that file with
no-follow and close-on-exec flags, and derives the fixed state, report, and
readiness paths. The production service validator accepts only a root process
running as the MainPID of the matching fence-<invocation-id>.service
transient unit. The production worker accepts standard block with disabled
container access, explicit degraded unsafe_preserve with preserved
container access, or audit observation mode with the selected
github_hosted_workflow_bootstrap_v5 descriptor. Audit applies owned non-blocking
observation rules and local DNS mediation while preserving sudo and container
access. Unsupported production modes remain fail-closed.
Security-critical commands must use:
- the twelve schema-
3executable identities captured as no-follow, close-on-exec descriptors before host mutation; - canonical root-owned regular paths, exact reviewed modes, and device/inode revalidation before every execution;
- fixed argument arrays rather than shell interpolation;
- no
sh -c,eval, or arbitrary command execution; - a cleared or narrowly inherited environment;
- bounded output capture;
- bounded timeouts; and
- structured error classification suitable for local reports.
User input may shape typed policy data. It may not select executable paths, inject command syntax, or select arbitrary filesystem destinations.
Capture holds three /dev/null reservations until the complete set is open so
no retained executable can occupy descriptor 0, 1, or 2. Root commands
execute the captured inode through /proc/self/fd/<n> with the reviewed path as
argv[0]; there is no raw-path fallback. Effective runner-access probes use
the descriptor-pinned outer sudo to execute descriptor-pinned /usr/bin/test.
Fence requires its transient service to have no controlling terminal so sudo's
PTY policy cannot replace the descriptor transported on standard input. Every
trusted executable must be non-writable and executable by the runner; every
reviewed ancestor must be non-writable and have the exact expected search
result; /etc/sudoers.d must be non-writable and non-searchable; and every
accepted sudo source must be non-writable. Exact path or policy identity is
checked immediately before and after each effective-access probe, followed by
a full executable and sudo-source recheck.
This is a forward-identity guarantee under the first-step and trusted hosted image assumptions. It does not authenticate bytes that were modified and had metadata restored before capture, same-inode modification by an already privileged process, the dynamic loader or shared libraries, or a malicious platform/root process.
Protected block mode removes ordinary passwordless-sudo ability after network policy has been applied and verified. The implementation must:
- match the reviewed hosted-runner fingerprint before mutation;
- capture the sole measured runner sudo drop-in bytes, mode, owner, device, inode, and digest only in memory, then compare that exact source again immediately before removal;
- fail unsupported if passwordless access originates from a broader or unclassified source instead of editing unknown policy;
- fail before ready if lockdown cannot be established;
- verify through pinned nested sudo that uncached runner passwordless access cannot succeed; and
- never restore broad sudo access after readiness.
If setup fails before readiness, rollback recreates the captured source only
with exclusive no-follow creation and revalidates its bytes, mode, ownership,
digest, complete sudo inventory, visudo result, and uncached runner
capability. Sudo and container rollback are attempted independently and report
combined failure without skipping either component. Immediately after the
ready file is created, the in-memory state is committed to a no-restore state;
all later rollback attempts are refused.
Audit mode does not disable sudo and may not claim protection from sudo-based bypass.
For protected block mode with container_policy: "disable", Fence must remove
ordinary Docker/containerd control paths that would let later workflow code
regain host-equivalent authority. For the single accepted hosted fingerprint,
the implementation must fail if unexpected existing workloads are observed,
stop and runtime-mask the measured Docker/containerd socket and service
activation paths, verify daemon and socket access is unavailable, and prove
that the post-lockdown root-control inventory is only a reduction of the exact
accepted inventory. That reduced state becomes the exact resident baseline;
an added TCP/Unix endpoint, new root container identity, owner change, unstable
scan, or incomplete scan is critical. Tests must cover:
- present runtime services and their activation paths;
- present sockets and runner-user access to them;
- attempted privileged-container execution; and
- any root-control endpoint that exists before lockdown completes; and
- post-readiness local-listener drift.
If required container lockdown cannot be verified, protected block mode fails before readiness. Fence v0 does not adaptively mutate unrecognized runtime layouts; image drift is an unsupported-host result requiring review.
For container_policy: "unsafe_preserve", Fence does not make the ordinary
containment assertion. It must expose degraded assurance prominently in both
human and JSON output, even if its network rules were installed successfully.
Audit mode preserves containers and reports observation only.
The GitHub-hosted lockdown harness independently proves the fixed sudo and
container mechanisms. It compares the accepted fingerprint before mutation
and launches each posture in a separate transient systemd service on its own
ephemeral ubuntu-24.04 runner:
acl-rejectadds an effective runner search ACL to a root-owned0750test directory and proves the pinned sudo-to-test probe rejects it without writing readiness or mutating host controls;auditproves passwordless sudo and container control are preserved;local-control-rejectopens an unexpected root TCP listener and proves the complete pre-mutation inventory fails closed without readiness;rollbackinjects a pre-evidence failure after provisional standard lockdown, restores only its measured provisional changes, and emits no readiness; its sudo restoration proof checks the captured file mode, the pinned source digest, and return of the measured runner sudo capability, while container daemon restoration is allowed a bounded 30-second startup deadline;unsafe-preservedisables the accepted passwordless-sudo source while retaining Docker access and records degraded assurance; andstandarddisables the accepted passwordless-sudo source, stops and runtime-masks the accepted Docker/containerd units, and verifies runner container access fails.
Successful mutating scenarios intentionally expose no restore operation before
the ephemeral runner is torn down. They write bounded root-owned,
runner-readable reports labelled lockdown_evidence_test_only beneath a
non-production /run/fence-lockdown-evidence-* root and never write a ready
file. Historical compatibility experiments informed the selected
github_hosted_workflow_bootstrap_v5 descriptor, but their workflows and broad
diagnostic parser branches are retired. Required integration retains the
healthy, worker-failure, and post-ready local-control-drift selected-profile
runtime scenarios plus production-shaped standard, degraded, bounded
user-wildcard Docker, and audit services.
Reports are local evidence. They are not telemetry, a remote control input, or an authorization token. The report is intended to let a later wrapper or workflow step show what Fence applied or observed without gaining write access to security state.
A bounded JSON report should include:
- runtime-evidence schema version and Fence binary version;
- invocation identifier and supported-platform result;
- selected mode and assurance classification;
- selected container policy;
- requested policy, effective frozen policy, policy-hash schema version, policy hash, and ruleset hash;
- for DNS-mediated selected-profile runtime evidence, the logical policy hash, base-preview ruleset hash, and active TTL-derived ruleset hash as separate fields;
- for results storage, the five source-defined exact static compatibility accounts, the selected
allow_github_artifactssetting, exact bounded hostnames,pinned_runner_worker_dnsor explicitly enabledopt_in_github_artifact_dnsorigin, authorization and rejection counts, attribution failures, truncation, and the explicit opt-in data-egress warning; - for user wildcard policy, the configured canonical patterns, at most eight concrete lifetime authorizations, over-budget rejection count, and truncation flag;
- selected platform profile, if any, including profile identifier and frozen contribution;
- configuration-limit validation state;
- apply, verification, sudo-lockdown, and container-lockdown state where applicable;
- bounded rejected or would-block finding samples;
- optional bounded best-effort local process attribution for retained findings;
- aggregate counters and truncation flags;
- setup failure or post-ready critical findings; and
- explicit limitations applicable to the selected assurance class.
Production state.json, ready.json, report.json, and DNS-mediated evidence
documents use:
{
"runtime_evidence_schema_version": 5,
"platform_profile_id": "github_hosted_workflow_bootstrap_v5",
"profile_realization_id": "github_hosted_workflow_bootstrap_dns_provenance_v5"
}The logical platform-profile identifier remains distinct from the stable DNS-mediated runtime-realization identifier so consumers can validate both the selected policy contract and the concrete resident mechanism.
Schema 5 evidence also carries resident_health: the resident service PID,
a saturating verification sequence, the last successful verification time,
the fixed five-second interval, and the fixed required-worker statuses. The
sequence advances only after owned nftables state, mode-specific sudo and
container state, trusted executable state, the resident local-control
inventory, worker health, and local evidence persistence all verify.
The four required DNS listeners and the required process-attribution worker
report startup and fatal exit through one bounded local channel. A post-ready
worker failure or report-persistence failure is critical, does not restore
controls, and cannot later return to healthy status. Individual attribution
misses, ambiguity, or scan limits remain bounded finding evidence rather than
containment failures. DNS evidence separately records bounded dynamic
githubapp.com and user wildcard authorizations, rejections, and truncation.
Consumers of schema-5 runtime evidence must reject a dead or mismatched
service and reports older than 20 seconds.
After validating the protected Action runtime, root-owned launcher integrity, resident service, runtime report, and applicable DNS evidence, the protected Action post hook must preserve the existing GitHub job summary and write a bounded human-readable network activity table to its ordinary job log. It must then emit exactly one deterministic, single-line FENCE_REPORT_JSON= record with public report schema version 1.
In both block and audit modes, the resident must drain sampled network events in FIFO batches bounded by 32 events and 20 milliseconds, apply newly available process-attribution results before persisting evidence, and yield to DNS materialization and scheduled resident verification between batches. Before rendering the report, the post hook must validate the same protected runtime and live resident identity across at most four root-owned report rereads in a monotonic 160-millisecond settlement window. Changed policy or resident identity, malformed counters, decreasing resident-lifetime sampled counters, stale evidence, and critical-state inconsistencies fail closed. A firewall counter may reset only across a newly validated block-mode ruleset-hash epoch; audit and unchanged-epoch firewall counters must remain monotonic. Evidence settlement must not add a resident worker, writable control channel, network destination, synthetic DNS request, or delay that weakens the 180-second protected-finalization guarantee.
The public record contains only the validated mode, healthy / warning / critical result, final network, sudo, and container controls, existing sanitized allowed, blocked, and would-block network activity, bounded warnings, explicit omission and evidence-truncation signals, and safe audit allowlist recommendations. Network activity is limited to 20 sorted rows; the complete prefixed JSON line is limited to 16 KiB. Evidence removed to satisfy either limit must be represented by explicit omission counters rather than silently discarded or serialized as invalid JSON. A DNS query must not be represented as a completed network connection, and audit activity must never be represented as blocked traffic.
In block mode, a successfully enforced results-storage DNS refusal with no caller-attribution failure or exhausted account capacity must remain visible as blocked network activity and in warnings.results_storage_rejections without independently changing a verified result to warning or emitting a GitHub warning annotation; failed attribution, exhausted capacity, explicit artifact opt-in, evidence omission or truncation, degraded controls, and critical drift must retain their existing warning or critical classifications. In audit mode, an exhausted account limit must remain a warning, but human-readable summaries and annotations must describe what block mode would deny rather than claim the observed request was blocked.
The post hook must emit a verified critical report before its final critical-finding rejection so the job still fails without restoring access. Source-reported sudo or container drift, block-mode dynamic network failures, and truncation at the agent's 64-finding limit must remain reportable without being treated as healthy. Missing, stale, malformed, or untrusted runtime or DNS evidence must not generate a success record. The record must not contain arbitrary raw evidence, credentials, environment values, URLs, query strings, packet data, full executable paths, command arguments, or parent process details. Normal GitHub job-log access is the only remote delivery mechanism; the Action must not upload artifacts, add network access or token permissions, or expose a post-job Action output.
Reports must not include:
- environment dumps;
- credentials or token-shaped values;
- packet-prefix bytes or packet payload contents;
- source-file contents;
- arbitrary command-line attribution;
- full executable paths, working directories, or process environments;
- privileged restore or update capabilities;
- remote policy text; or
- unbounded logs.
Ready and report files are readable by the workflow user so local summaries can be rendered, but they remain root-owned and not writable by later steps. A reader may observe evidence; it may not change the boundary or forge the agent's current state.
The repository is bootstrapped from a hermetic Rust layout and currently includes:
- exact direct dependency pins and committed
Cargo.lock; - vendored application crates in
vendor/cache; - pinned Rust distribution metadata and checksums;
- checksum-locked update-tool inputs;
- committed coverage-tool artifacts;
- committed Zig and
cargo-zigbuildartifacts for prepared future cross-build experimentation; - SHA-pinned GitHub Actions; and
- script-first local and CI validation entrypoints.
The current v0 Rust CLI implements strict JSON parsing, deterministic policy
and native-ruleset preview generation, typed expected-state
verification modeling, accepted-but-unchecked hosted-runner fingerprint
reporting, and read-only support reporting. Ordinary direct run execution
is rejected before config read. The matching trusted root transient-service
path invokes the block production worker, while privileged
integration tests exercise native
apply, read, verify, rollback, bounded NFLOG conversion, transient-service
resident verification, and distinct disposable-runner lockdown operations;
they write evidence status network_enforcement_test_only,
resident_lifecycle_test_only, or lockdown_evidence_test_only. Resident
evidence may write only explicitly non-protecting test readiness below
root-created non-production runtime roots. Required integration retains one
selected-profile test-only regression scenario and adds packaged
production-shaped standard block, degraded block, and audit observation
services.
Routine prepared-project scripts stay offline:
| Script | Purpose |
|---|---|
script/bootstrap |
Validate toolchain/vendor state and run frozen check. |
script/test |
Run frozen tests, optionally using installed local coverage tooling. |
script/lint |
Run frozen format, lint, project, and documentation checks. |
script/build |
Build/package from prepared local inputs. |
script/server |
Run the current binary from prepared local inputs. |
Intentional preparation or refresh boundaries are separate:
| Script | Purpose |
|---|---|
script/prepare-rust |
Checksum-gated online Rust preparation through a temporary verified loopback mirror. |
script/update |
Online application dependency and vendor refresh. |
script/vendor-rust |
Online Rust distribution lock refresh. |
script/vendor-update-tools |
Online audit/deny helper lock refresh. |
script/vendor-release-tools |
Online prepared cross-build-tool refresh. |
script/vendor-test-tools |
Online coverage-tool archive and license refresh. |
script/install-zig |
Offline installation of retained prepared cross-build tools. |
script/install-test-tools |
Offline installation of coverage tooling. |
Hosted CI is not fully air-gapped. Checkout, action loading, checksum-gated toolchain preparation, artifact services, publication, and attestation verification remain network interactions. The offline contract begins after the explicit preparation steps.
Zig and cargo-zigbuild inputs remain committed and validated because future macOS investigation is expected. During the v0 Linux-only protection phase:
- these tools are not evidence that macOS or ARM is supported;
- they are not required to publish the protected Linux x64 agent artifact;
- their offline installation and exact-version verification run inside the fixed-Linux
acceptancejob; and - no unsupported security-agent artifact is released merely because a cross-build could be performed.
CI establishes that the public planner, trusted-launcher boundary, and standard block, degraded block, and audit observation production slices are healthy for the stable v0 Linux x64 implementation:
- the required source-check set is exactly
lint,test,build,acceptance,action acceptance, andintegration; lintruns on fixedubuntu-24.04and proves the frozen formatting, lint, project, and documentation checks;testruns on fixedubuntu-24.04and executes the complete all-features test suite through coverage with committedcargo-llvm-covtooling;buildruns on fixedubuntu-24.04and proves the native Linux x64 GNU release build;acceptanceruns on fixedubuntu-24.04, installs and verifies the committed Zig/cargo-zigbuildinputs, packagesx86_64-unknown-linux-gnuonce, verifies its checksum, and proves the packaged public contract without mutating Fence runtime or kernel state;- Action acceptance runs each unique production-shaped end-to-end case on a fresh runner while retaining coverage of standard block, degraded block, audit observation, failure propagation, and repeated quiet finalization;
- no pre-merge macOS assurance is provided, and macOS remains outside the protected target until it receives its own implementation, tests, and public support decision; and
- no workflow presents macOS or ARM binaries as protected Fence agent releases.
The integration workflow runs separately and only on the intended ubuntu-24.04 x64 hosted environment. A lightweight preflight validates repository locks before any privileged matrix starts. Every destructive leaf then prepares its own toolchain and runs script/observe-hosted-runner to generate bounded fingerprint and fixed-path permission evidence, including the narrowly scoped synthetic ancestor probes described above. The standard-lockdown matrix leg runs namespace-isolated script/test-privileged before its terminal no-restore standard lockdown, placing that proof in the initial parallel fan-out instead of a serialized aggregate tail. Its privileged evidence tests must:
- use disposable network namespaces rather than installing host deny rules;
- prove dual-stack native nftables block and audit behavior;
- prove routed forward-chain behavior without claiming container containment;
- prove singleton ownership, structured verification, and owned rollback;
- prove bounded NFLOG endpoint metadata extraction, payload non-retention, and report-size enforcement;
- write root-owned network test evidence without readiness, and resident
lifecycle evidence with only
test_only_ready_no_protectionreadiness below a root-created non-production runtime root; - launch the resident evidence process as a matching transient
systemdservice, maintain its owned network state, and re-verify on a five-second schedule; - prove pre-ready verification failure rolls back owned network state without readiness, and post-ready drift creates a bounded critical finding without restore; and
- run separate disposable-host
script/test-lockdownscenarios that prove ACL-aware path rejection, additive local-listener rejection, audit preservation, provisional rollback, visibly degraded container preservation, and standard sudo/container disablement without readiness or restore; and - run disposable-host
script/test-selected-profile-runtimescenarios that plan the selectedgithub_hosted_workflow_bootstrap_v5descriptor by omission, applies the bounded DNS-mediated Actions-suffix model, disables measured sudo/container paths, emits only test-only readiness and bounded reports, leaves controls resident for terminal hosted completion, and supplies healthy, worker-failure, and post-ready local-control drift evidence for the selected-profile runtime realization; and - run disposable-host
script/test-protected-runscenarios that launch the packaged binary through production-shaped trusted root transient services, prove standard block writes readiness only after network, sudo, and container controls verify, prove explicitunsafe_preservewrites degraded readiness only after network and sudo controls verify while container paths remain available, proveauditwrites observation-only readiness after owned non-blocking network rules verify while sudo and container paths remain available, and leave applied state resident for terminal hosted completion; and - run three quiet standard-block finalization replicas with no artificial post-ready traffic. Give each replica an eight-minute job budget for checkout, runner observation, Rust preparation, and bootstrap, and impose a separate three-minute timeout on protected activation. Use a downstream read-only verifier to require successful terminal job completion, including post hooks, within 180 seconds of the protected activation step's start and a nonempty downloadable job-log payload for each replica. Checkout and setup remain outside the 180-second security timer. Record a naturally observed runner-authorized results-storage request as additional evidence when it occurs, but do not require one: the runner may establish or resolve its upload path before Fence readiness or request it after the final readable report snapshot; and
- avoid release or deployment credentials.
The selected-profile runtime scenario supplies a required
no-restore/finalization regression gate after six terminal-success proofs. The
packaged production-shaped workers prove the activated standard block,
degraded block, and audit observation trusted-launcher paths separately.
Test-only readiness and reports do not by themselves establish protection.
All three services select the bounded github_hosted_workflow_bootstrap_v5 descriptor by
omission and record the planner identity plus current runtime-realization
revision.
Fence does not turn sampled literal addresses into implicit policy. The selected descriptor is source-reviewed, versioned, and proved under block mode with no-restore finalization.
The measurement pass also requires internally coherent counters: whenever a sampled finding is persisted, the resident reporter refreshes its aggregate violation count before writing evidence so retained sampled findings cannot appear newer than the reported total solely because of the verification interval.
Packaged direct-invocation acceptance runs separately from protected
trusted-launcher integration and only on ubuntu-24.04 x64. It must:
- install and verify the exact committed Zig and
cargo-zigbuildinputs without treating them as a supported artifact target; - independently package the current commit as a Linux x64 GNU artifact and verify the generated checksum before execution;
- invoke the packaged binary, not a Cargo test executable;
- use an IP/CIDR-only policy fixture so it requires no DNS or remote traffic;
- prove
--version,check-support, deterministicrender-plan, and the direct-invocationtrusted_launcher_requirederror through versioned JSON output; - prove public commands create neither
inet fence_v0nor planned runtime state under/run/fence/package-acceptance/; and - remain a non-mutating artifact-contract test, not readiness or containment evidence.
Fence publishes a release only from a reviewed change and matching Cargo.toml/root Cargo.lock version bump merged to main. The version must contain no SemVer build metadata and must be strictly newer than the highest published release. That merge is the sole human authorization; the protected release environment is restricted to protected main and has no required reviewer. The workflow is not manually dispatchable and serializes all releases through one concurrency group with cancellation disabled and queue: max, retaining up to GitHub's bounded queue limit instead of replacing an older pending version release.
The signed merge is source commit M. Before publication, the release workflow must require the exact protected source checks lint, test, build, acceptance, action acceptance, and integration on M to succeed, build the Linux x64 GNU artifact exactly once from M, and reuse those bytes throughout the release. script/assemble-action-bundle constructs a production-shaped tree offline from the explicit artifact, version, source SHA, and output root. Release automation creates a temporary release-candidate/vX.Y.Z branch at M, then uses GitHub's commit API with an expected head of M to create signed distribution commit D. D has exactly one parent, M, and its only diff is action/bin/fence plus action/bundle-manifest.json.
The schema-4 manifest records the repository, tag, stable/prerelease channel, release URL, source commit/ref, artifact name/digest, bundle path, signer workflow, and signer digest. source_commit and signer_digest both identify M, and source_ref is refs/heads/main. The manifest does not embed D, live draft/immutability state, or an attestation-verification boolean; those properties are verified against GitHub state before publication.
Complete fixed-ubuntu-24.04 Action acceptance and the fixed-label zero-input drift-canary invocation must run against exact D and require normal activation. The workflow then generates the final binary archive, checksums.txt, and schema-1 action-release.json; attests and verifies that final asset set against M, refs/heads/main, and the release workflow while rejecting self-hosted provenance; and only then creates the complete non-draft immutable vX.Y.Z release with its tag targeting D. action-release.json maps the repository, version, tag, M, D, artifact name/digest, manifest schema version, signer workflow, and signer digest.
{
"schema_version": 1,
"repository": "openai/fence",
"version": "X.Y.Z",
"release_tag": "vX.Y.Z",
"source_commit": "<M>",
"action_commit": "<D>",
"artifact_name": "fence_vX.Y.Z_linux-amd64",
"artifact_sha256": "<sha256>",
"bundle_manifest_schema_version": 4,
"signer_workflow": "openai/fence/.github/workflows/release.yml",
"signer_digest": "<M>"
}Final verification must re-download the published assets and recheck checksums, attestations, release metadata, vX.Y.Z -> D, signed one-parent D -> M, the exact two-file diff, manifest identity, and byte-for-byte bundle equality before emitting a consumer SHA. Matching partial state may be resumed only after full re-verification; conflicting candidates, tags, releases, assets, mappings, parents, signatures, or digests fail closed. Publication, post-publication verification, cleanup, and final reporting require release-state outputs from the same workflow run attempt; “re-run failed jobs” cannot reuse an earlier attempt's classification and must be replaced by “re-run all jobs.” The temporary candidate branch may be deleted only through a server-side Git lease that still expects M after interrupted setup or D after candidate validation. A matching draft left by interrupted immutable publication may be deleted and recreated only after its target, notes, release channel, GitHub Actions bot identity, and every uploaded asset digest agree with the newly prepared release; an indeterminate API response never means absence. After successful final verification, automation creates a matching temporary release-verified/vX.Y.Z ref at D before candidate deletion and lease-deletes that verification ref before reporting the consumer pin; the marker plus the exact prior verify-release job result make interrupted cleanup or marker creation unambiguously resumable. If the first final verification does not succeed after immutable publication, the candidate remains at D without a successful verification result as the durable withdrawal state; reruns reject that version, no consumer SHA is emitted, and remediation requires a corrected new version rather than moving or replacing the tag or assets. Scheduled stable-release canaries reject any release whose candidate or verification ref remains.
The immutable version tag identifies the complete distribution commit, but consumers must pin the full 40-character action_commit from action-release.json. Release notes and the final workflow summary show the ready-to-copy form uses: openai/fence@<D> # pin@vX.Y.Z with the actual release tag so Dependabot can update the same-line version comment. The main branch is not a runnable Action distribution. Releases through v0.6.3 retain their historical source-commit tag semantics, and releases through v0.8.3 retain their original GrantBirki/fence repository, release URL, and signer-workflow provenance.
A release is acceptable only while the required hosted evidence proves the standard, degraded, and audit contracts; the public security claims match the frozen threat model; and no supported-path regression is known.
The first published security-agent artifact is Linux x64 GNU only. A future macOS implementation requires its own design, enforcement backend, privilege model, tests, and public support decision before any macOS protection claim. The narrow agent package contains the binary and checksum/attestation evidence; it does not use runtime commands to generate shell completions or man pages.
Fence reached this contract through staged policy-model, native-network,
resident-lifecycle, hosted-compatibility, release, and Action-wrapper work. The
compact chronology is maintained in history.md. Historical
phase gates and prerelease milestones do not alter the current security or
support contract in this document.
After stable publication, follow-up work should add a checksum-bound release
SBOM, evaluate cargo-vet, evaluate auditable or reproducible binary
comparison, and review whether the bounded Actions-suffix profile can be
narrowed further without stranding hosted job completion. These improvements
must preserve the repository's explicit online preparation boundaries and
offline runtime acquisition model.
- The current public tree contains no named alternative-product comparison or link.
- This document is linked as the normative behavior/schema contract and the threat model is linked as the security-claim source.
- The obsolete design handoff is removed.
- Workflow files expose fixed runner labels where protection or packaging is represented.
- Release workflows cannot publish macOS or ARM agent artifacts.
- Retained cross-build-tool verification is distinct from protected artifact publication.
- The protected
maintree contains neither generated bundle file, while a distribution tree contains both and passes schema-4offline validation; half-present bundle state fails. - The release workflow has no manual dispatch, serializes version releases, limits write permissions by job, and publishes only after reusable acceptance plus canary gates pass against exact
D. - Public-hygiene scans find no secrets, private organization context, or machine-specific paths.
- Hermetic lock validation and existing scaffold test/lint/build/coverage scripts still pass.
On ubuntu-24.04 x64, the packaged public-contract workflow must prove:
- the Linux x64 GNU package checksum verifies before the artifact is executed;
--versionandcheck-supportemit JSON that reports v0 implementation work, an accepted but unchecked hosted-runner fingerprint reference, and no protection availability;- two literal-policy
render-planexecutions are byte-identical and report policy-hash schema version9, nativeinet fence_v0preview constants, andnot_applied/not_verifiedstatus; run --config <nonexistent-path>fails withtrusted_launcher_requiredrather than reading configuration; and- no public command creates the owned nftables table or planned runtime directory.
On disposable GitHub-hosted ubuntu-24.04 x64 runners, the reusable root Action workflow must prove the same contract for an ephemeral source-built candidate during pull-request and ordinary main validation and for exact distribution commit D during release validation. It schedules each unique end-to-end behavior once: zero-input standard block, standard block with broad GitHub domains disabled, explicit GitHub artifact compatibility with a real SHA-pinned official artifact upload, degraded wildcard-Docker compatibility, nested-layout audit, setup rejection, and post-ready tamper detection. Three quiet standard-block finalization replicas remain separate to preserve repeated no-restore terminal evidence. Each replica has an eight-minute job budget for checkout and setup, a three-minute protected activation-step timeout, and a downstream read-only verifier that requires successful job completion, including Action post hooks, within 180 seconds of that step's start. Together these cases retain coverage of every supported mode and failure boundary without redundant explicit standard, degraded, or audit matrix entries:
script/validate-action-bundle --root <candidate-root>validates schema4, stable/prerelease agreement, source/signer identity, bundle path, and binary digest without network access;uses: ./validates the mode-0644bundle bytes, copies them to a protected root-owned mode-0555path, and launches only that copy without downloading an agent or fetching policy at runtime;- a zero-input
uses: ./invocation selects standard block with an empty user-definedallowlist; - the non-quiet zero-input acceptance job verifies a bounded denied direct-IP packet burst, and the read-only downstream verifier proves that its single post-hook structured report retains both final blocked UDP destinations while explicitly disclosing omitted rows; the three quiet finalization replicas remain synthetic-traffic-free and strictly healthy;
- standard block reaches readiness, disables measured passwordless sudo and container control paths, and remains resident;
- opt-in
allow_github_artifacts: trueretains verified block-mode network, sudo, and container controls, authorizes a bounded exact results-storage account with explicit opt-in provenance, and successfully uploads one innocuous, one-day-retained artifact through the full-SHA-pinned official GitHub Action; - explicit degraded
unsafe_preservereaches degraded readiness, disables measured passwordless sudo, preserves container access, and denies ordinary containment assurance; - audit reaches observation-only readiness, preserves sudo and container
access, and emits bounded payload-free
would_blockevidence; and - malformed wrapper input is rejected before Action state, runtime directories, or owned nftables state are created;
- standard block leaves the registered Action runtime and bundled binary root-owned and read-only, so later runner-user code cannot overwrite, unlink, chmod, rename, or replace the post-job verifier;
- the disposable hosted tamper scenario proves the closest runner-writable registered-path ancestor remains the same mounted device and inode and cannot be renamed and recreated around the protected runtime;
- a disposable audit lifecycle can be tampered after readiness by deleting only the owned nftables table, after which resident verification records a bounded critical finding; and
- the post hook reads bounded local evidence, fails on missing or critical findings, and never stops the service or restores access. The tamper evidence invokes that protected post-hook implementation against the real drifted resident report while the service remains active.
A separate non-required nightly workflow runs daily and by input-free manual dispatch, fails closed unless the exact selected ref is main, builds an ephemeral production-shaped candidate from that exact main source SHA on fixed ubuntu-24.04, and reuses the complete unique-case Action-acceptance suite on ubuntu-latest. It is not a branch-protection or release gate. A green result means only that the image currently selected by the floating label matched the reviewed fingerprint and passed the complete suite; a future image selected by ubuntu-latest is not automatically trusted, and the supported protected target remains fixed GitHub-hosted ubuntu-24.04 x64.
A separate non-required drift canary runs daily, supports manual dispatch with an optional full Action SHA, and requires an explicit full SHA when invoked as a reusable release-validation workflow. Scheduled and input-free manual runs select the newest non-prerelease immutable release only after validating its action-release.json mapping, then execute the mapped action_commit with fail-fast: false on a reviewed matrix containing only fixed ubuntu-24.04 and floating ubuntu-latest. Manual runs with an explicit SHA and reusable release-validation calls execute on fixed ubuntu-24.04 only. Every leg requires normal schema-3 compatibility and zero-input standard activation. The canary detects image drift between repository changes without replacing the required pull-request and push acceptance matrices; a green ubuntu-latest leg is point-in-time evidence and does not expand the supported protected target.
- Source validation accepts both generated bundle files absent; distribution validation accepts both present and valid; either half-present state fails.
- The offline assembler rejects version, artifact-name, source-SHA, schema, digest, or output-containment mismatches and produces byte-identical binary output plus canonical JSON for identical inputs.
- Release-state tests cover a new release, a completely verified rerun, resumable matching candidate, tag, or interrupted-draft state, durable withdrawal versus successful-verification recovery, every conflicting candidate/tag/draft/release/asset/mapping state, non-monotonic versions, root Cargo version mismatch, and stable/prerelease classification.
- Generated-commit tests require expected-head protection, a valid GitHub signature, one parent, exact approved diff paths, source/signer identity, and byte-for-byte artifact equality.
- Release validation rejects mutable, draft, malformed, unmapped, mismatched, or incorrectly classified releases and emits a consumer SHA only after final published-state verification.
Configuration parsing tests must cover:
- missing and unknown top-level fields;
- missing, unknown, and explicit modes;
- empty allowlists;
- typed hostname, IP, and CIDR TCP/UDP entries;
- accepted exact-depth one- and two-label wildcard hostnames plus rejected bare, embedded, excessive-depth, URL-shaped, portless, and malformed destinations;
- invalid protocol, port, invocation identifier, profile, and container policy values;
- audit rejection of protection-bearing container policy; and
- every fixed input/resource limit.
Policy and reporting tests must cover:
- deterministic DNS result sorting/deduplication;
- resolution failure and timeout before mutation;
- expanded-rule bounds;
- policy hashing;
- default and explicit selected-profile behavior plus rejection of retired profile values;
- derived runtime path constraints;
- production runtime config-path, ownership, mode, symlink, and size rejection;
- production transient-service name, root identity, and matching
MainPIDvalidation; - bounded event/report serialization and truncation; and
- assurance labels for standard block, degraded block, and audit;
- fixed singleton nftables ownership and deterministic native-ruleset rendering;
- implicit IPv6 control contribution and policy-hash schema version
9; - ruleset hashing and typed owned-state verification mismatch behavior; and
- read-only backend observations without public enforcement activation.
On ubuntu-24.04 x64, tests must prove:
- unsupported capability failure occurs before mutation;
render-plandoes not alter security state;- native nftables state is applied atomically and verified structurally;
- allowed TCP and UDP destinations work as specified;
- unauthorized traffic is rejected in block mode;
- would-be violations are observed but not blocked in audit mode;
- IPv4 and IPv6 requirements are both met or block fails before ready;
- the selected hosted workflow-bootstrap profile resolves both local UDP and TCP DNS through its root-resident UDP upstream mediator and records its bounded DNS exception as an exfiltration limitation;
- bounded NFLOG findings contain approved metadata and never retain or serialize transient packet-prefix bytes or application payloads;
- passwordless sudo fails in standard protected block mode;
- Docker/containerd access fails in standard protected block mode;
unsafe_preserveretains container paths and visibly degrades assurance;wildcard-dockerpreserves container paths, lazily authorizes concrete*.docker.ioregistry names within the shared eight-name budget, records user-origin materializations without claiming a complete image pull, and proves network-free container startup and cleanup remain healthy across multiple resident verification intervals;- a production-shaped malformed-wildcard case rejects
*.*.*.docker.iounder the trusted source launcher before agent-owned network, sudo, container, or evidence mutation; - pre-ready setup failures roll back only Fence-owned provisional state;
- no post-ready restore interface exists; and
- post-ready verification drift creates critical local evidence.
Namespace and lower-level lockdown harnesses emit explicitly test-only
evidence beneath non-production runtime roots. Those documents are not a
protection assertion. Production-shaped trusted-launcher scenarios separately
exercise public run, production readiness, resident verification, and
no-restore behavior for standard block, degraded block, and audit.
The selected bounded mediator restricts dynamic Actions-suffix authorization
to at most eight unique lifetime names and two prefix labels, rebuilds
canonical A and AAAA upstream questions, permits bounded TTL-derived CNAME
descendants, prehydrates and refreshes every required exact platform hostname
and exact user hostname, retains user wildcard patterns as lazy logical policy,
attempts the optional hosted-runner watchdog without
making a transient empty answer a readiness failure, and materializes each
configured transport from prehydrated exact-root addresses or approved DNS
answers. Approved block-mode DNS answers are
released only after the complete corresponding rule batch is applied and
verified; queue rejection or resident-owner disconnection returns SERVFAIL
rather than returning an unusable address. The required integration and
Action-acceptance gates prove the workflow-bootstrap behavior.
One disposable copy now runs behind the stable required integration
aggregate as selected-profile runtime evidence. It plans
github_hosted_workflow_bootstrap_v5 by omission, rejects descriptor drift and user
allowlist entries, and records logical policy, base-ruleset, and active TTL-derived
ruleset hashes separately. Production-shaped trusted-launcher scenarios prove
the same selected mechanism for standard block, degraded block, bounded user
wildcard Docker compatibility, and audit.
Fence is a public repository. Tracked design, source, workflow, tests, logs, fixtures, issues, and pull request text must not disclose:
- secrets or credential material;
- private hosts, repositories, infrastructure, or operational incidents;
- organization-specific migration or response activity;
- personal workstation paths; or
- private research or communication context.
Fence should be explained on its own requirements: a source-owned local boundary for a supported hosted-runner class, built under a hermetic and reviewable maintenance model.
- Hermetic Builds
- GitHub-hosted runners reference
- GitHub-hosted runner communication requirements
- GitHub Meta API Actions domain inventory
- GitHub Actions job summary and log storage endpoints
- GitHub runner results upload client
- Azure storage-account naming and global uniqueness
- GitHub Ubuntu 24.04 runner image inventory
- GitHub Actions runner service allowlist documentation
- GitHub Actions runner job-completion implementation
- Azure platform address
168.63.129.16communication channels - Microsoft AntiSSRF special-purpose WireServer classification
- Azure Linux Agent goal-state protocol
- Azure Linux Agent host-plugin endpoints
- GitHub artifact attestations
- GitHub GraphQL
createCommitOnBranchmutation - GitHub composite action local-use model
- GitHub Actions metadata syntax
- GitHub immutable action releases and tags
- nftables families
- nftables man page
- Docker firewall with nftables
- systemd-run transient units