Skip to content

Latest commit

 

History

History
1817 lines (1496 loc) · 114 KB

File metadata and controls

1817 lines (1496 loc) · 114 KB

Fence v0 Specification

Status: normative design for the stable v0 implementation Audience: maintainers, implementers, security reviewers, and workflow authors Last reviewed: 2026-07-15

Purpose

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.

Design Principles

Fence v0 is governed by the following requirements:

  1. Security-critical behavior is local, source-owned, and inspectable.
  2. Required enforcement fails closed; it never silently becomes observation.
  3. Network policy, platform-profile policy, and report contents are explicit.
  4. No privileged binary, policy, blocklist, or reporting instruction is fetched from a remote control plane while a job runs.
  5. A ready boundary is not later unlocked for convenience on an ephemeral protected runner.
  6. Routine repository builds, tests, linting, and packaging remain offline after inputs have been deliberately prepared.
  7. Claims describe precisely what is enforced, what remains possible, and what is unsupported or not proved by current evidence.

Why Fence Exists

Hermetic inputs do not constrain running code

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.

Egress filtering alone is insufficient when bypass is ordinary

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.

Scope And Security Contract

Supported protected environment

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.

Unsupported as a protected v0 target

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.

Protected assets

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.

Attacker model

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.

Statements permitted only after proof

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.

Statements Fence must not make

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.

Assurance Modes

block: protected configuration

block is the only v0 containment mode. With container_policy: "disable", the resident root-owned agent must:

  1. Validate configuration, limits, and platform capabilities.
  2. Capture the reviewed privileged executables and verify effective runner access plus the accepted local root-control inventory.
  3. Resolve and freeze hostname policy before mutation.
  4. Install and verify an effective dual-stack nftables policy.
  5. Start bounded local finding collection for rejected traffic.
  6. Remove ordinary passwordless-sudo access.
  7. Remove ordinary Docker/containerd control paths.
  8. Verify required lockdown and post-lockdown local-control state.
  9. Write a ready state and bounded local report.
  10. Keep verifying owned state while the runner remains alive.
  11. Never restore network or ordinary privilege paths after readiness.

If any required pre-ready step fails, block fails rather than downgrading to audit.

block with unsafe_preserve containers

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: observation configuration

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.

No implicit downgrade

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

Lifecycle And No-Restore Rule

Lifecycle

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

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

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.

Post-ready verification failures

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.

Configuration Interface

Agent CLI

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: 9 and policy_hash for normalized logical policy, including fixed implicit IPv6 control rules and the selected versioned DNS-mediated compatibility descriptor;
  • ruleset_hash for the exact rendered native base-ruleset preview before any runtime trusted-launcher TTL-derived replacement; and
  • network_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.

Strict JSON only

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.

Schema

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

Invalid input

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_artifacts boolean or allow_github_artifacts: true in audit mode;
  • any policy source URL or report upload destination; or
  • input exceeding fixed v0 bounds.

Fixed limits

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.

Derived runtime state

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.

Effective Policy

User policy

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.

Empty policy

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.

Hostname resolution

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:

  1. Parse and validate the entire JSON document.
  2. Merge exact platform and user hostnames separately from sorted user wildcard patterns, preserving every complete protocol/port set.
  3. Compute policy_hash_schema_version: 9 and 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.
  4. 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.
  5. 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 addressless A/AAAA rounds, 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.

DNS after readiness

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.

Hosted workflow-bootstrap platform profile

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.

Native Nftables Architecture

Backend choice

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.

Owned objects

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_v0 table 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.

Protected block rule behavior

The effective block plan must include:

  1. Necessary loopback behavior.
  2. Established/related return traffic.
  3. Fixed reported outbound ICMPv6 router solicitation, neighbor solicitation, and neighbor advertisement rules, requiring hop limit 255.
  4. Effective TCP and UDP user/profile allowlist entries.
  5. Explicit treatment of traffic paths relevant during container lockdown.
  6. Counted and bounded logged rejection of undeclared new traffic.
  7. No implicit runtime DNS allowlist entry.
  8. 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 rule 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.

Bounded event collection

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.

Active-state verification

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.

Privilege And Container Controls

Hosted runner fingerprint gate

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 NOPASSWD marker classifications of sudo policy sources; exact sources hash the complete file, while 90-cloud-init-users requires exactly one generated cloud-init version/timestamp header and hashes every remaining byte with the fence-cloud-init-sudo-body-v1 domain;
  • reviewed root dockerd and containerd process 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.

Transient root service

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.

Privileged command execution

Security-critical commands must use:

  • the twelve schema-3 executable 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.

Sudo lockdown

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.

Container lockdown

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.

Hosted lockdown evidence boundary

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-reject adds an effective runner search ACL to a root-owned 0750 test directory and proves the pinned sudo-to-test probe rejects it without writing readiness or mutating host controls;
  • audit proves passwordless sudo and container control are preserved;
  • local-control-reject opens an unexpected root TCP listener and proves the complete pre-mutation inventory fails closed without readiness;
  • rollback injects 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-preserve disables the accepted passwordless-sudo source while retaining Docker access and records degraded assurance; and
  • standard disables 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.

Reporting

Report purpose

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.

Report content

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_artifacts setting, exact bounded hostnames, pinned_runner_worker_dns or explicitly enabled opt_in_github_artifact_dns origin, 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.

Action job-log report

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.

Report exclusions

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.

File permissions and integrity

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.

Hermetic Repository Contract

Bootstrap status

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-zigbuild artifacts 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.

Offline and online boundaries

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.

Retained cross-build tools

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 acceptance job; and
  • no unsupported security-agent artifact is released merely because a cross-build could be performed.

CI And Release Contract

Current bootstrap validation

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, and integration;
  • lint runs on fixed ubuntu-24.04 and proves the frozen formatting, lint, project, and documentation checks;
  • test runs on fixed ubuntu-24.04 and executes the complete all-features test suite through coverage with committed cargo-llvm-cov tooling;
  • build runs on fixed ubuntu-24.04 and proves the native Linux x64 GNU release build;
  • acceptance runs on fixed ubuntu-24.04, installs and verifies the committed Zig/cargo-zigbuild inputs, packages x86_64-unknown-linux-gnu once, 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.

Integration workflow

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_protection readiness below a root-created non-production runtime root;
  • launch the resident evidence process as a matching transient systemd service, 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-lockdown scenarios 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-runtime scenarios that plan the selected github_hosted_workflow_bootstrap_v5 descriptor 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-run scenarios 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 explicit unsafe_preserve writes degraded readiness only after network and sudo controls verify while container paths remain available, prove audit writes 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 public-contract acceptance workflow

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-zigbuild inputs 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, deterministic render-plan, and the direct-invocation trusted_launcher_required error through versioned JSON output;
  • prove public commands create neither inet fence_v0 nor planned runtime state under /run/fence/package-acceptance/; and
  • remain a non-mutating artifact-contract test, not readiness or containment evidence.

Release gate

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.

Implementation History

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.

Post-v0 Hardening

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.

Test Requirements

Repository contract tests

  • 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 main tree contains neither generated bundle file, while a distribution tree contains both and passes schema-4 offline 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.

Packaged Trusted-Launcher CLI Acceptance Tests

On ubuntu-24.04 x64, the packaged public-contract workflow must prove:

  • the Linux x64 GNU package checksum verifies before the artifact is executed;
  • --version and check-support emit JSON that reports v0 implementation work, an accepted but unchecked hosted-runner fingerprint reference, and no protection availability;
  • two literal-policy render-plan executions are byte-identical and report policy-hash schema version 9, native inet fence_v0 preview constants, and not_applied / not_verified status;
  • run --config <nonexistent-path> fails with trusted_launcher_required rather than reading configuration; and
  • no public command creates the owned nftables table or planned runtime directory.

Bundled Action Acceptance Tests

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 schema 4, stable/prerelease agreement, source/signer identity, bundle path, and binary digest without network access;
  • uses: ./ validates the mode-0644 bundle bytes, copies them to a protected root-owned mode-0555 path, 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-defined allowlist;
  • 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: true retains 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_preserve reaches 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_block evidence; 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.

Release Automation Tests

  • 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.

Pure Rust tests

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 MainPID validation;
  • 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.

Privileged hosted tests

On ubuntu-24.04 x64, tests must prove:

  • unsupported capability failure occurs before mutation;
  • render-plan does 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_preserve retains container paths and visibly degrades assurance;
  • wildcard-docker preserves container paths, lazily authorizes concrete *.docker.io registry 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.io under 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.

Public Documentation And Review Rules

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.

Public Technical References