Skip to content

policy_bundle.lock — hash-pinned preset bundles

Landed in v0.6.0. Implementation src/agent_airlock/pack/lock.py; CLI airlock pack lock and airlock replay --bundle-lock.

Status: working as of v0.8.86

Verified 2026-09-04: airlock pack lock then --verify passes on all three packs that ship in the box, and the digest is byte-identical across separate processes.

It did not, through v0.8.85, and the two causes are worth keeping on the record because one of them is the more dangerous shape:

Shipped pack before v0.8.86 now
claude-code-ci crashed — TypeError: 'StdioGuardConfig' object is not iterable 4 presets pinned, verifies
copilot-agent-ci wrote a lockfile that failed its own verification seconds later 3 presets pinned, verifies
gemini-cli-ci same 3 presets pinned, verifies

The crash was the louder bug; the unstable digest was the worse one. A control that reports drift on an unchanged bundle gets switched off, and had the digest been made stable the careless way — hashing a callable by name only — it would have reported no drift on a bundle that had genuinely changed. Both failure directions are now regression tests in tests/pack/test_lock_hash_stability.py.

What it does

A lockfile records a SHA-256 over each active preset's canonical JSON form, so a policy bundle can be pinned the way Cargo.lock and uv.lock pin dependencies. verify_lock then refuses a bundle whose presets no longer hash to what was recorded — in both directions: a preset present in the bundle but absent from the lock, and a preset listed in the lock but missing from the bundle, are each drift.

File shape

# policy_bundle.lock — generated by airlock 0.8.85
schema_version = 1
airlock_version = "0.8.85"
generated_at = "2026-09-04T15:08:16Z"

[[preset]]
preset_id = "archived_mcp_server_advisory_defaults"
content_sha256 = "2f8970603584e2d69979963aa9cd40185a17b9d3bcce4c610564fcdba5f234cd"

[[preset]]
preset_id = "copilot_agent_cnc_2026_04"
content_sha256 = "f9830d955b19a8735eb93cd0e75213ed8fcd686559320ba5aa31f6318df0d133"

Entries are sorted by preset_id, so the rendering is deterministic given deterministic hashes.

Commands

# Emit a lockfile (defaults to policy_bundle.lock beside the manifest)
airlock pack lock path/to/manifest.yaml [--output policy_bundle.lock] [--format json]

# Verify an existing lockfile instead of regenerating it. Exit 2 on drift.
airlock pack lock path/to/manifest.yaml --output policy_bundle.lock --verify

# Refuse a replay run whose bundle drifted from the lock. Exit 2 on drift.
airlock replay --bundle-lock policy_bundle.lock --bundle-manifest path/to/manifest.yaml

--bundle-lock and --bundle-manifest must be given together; supplying one without the other exits 3.

Runnable example

This uses the Python API, which is the surface that currently round-trips:

from agent_airlock.pack.lock import build_lock, render_lock, parse_lock, verify_lock
from agent_airlock.pack.lock import LockfileDriftError

bundle = {"my_preset": {"block_list": ["rm", "curl"], "source": "internal"}}

lock = build_lock(bundle, airlock_version="0.8.85")
text = render_lock(lock)                    # -> the TOML above
verify_lock(parse_lock(text), bundle)       # passes: nothing drifted

bundle["my_preset"]["block_list"].append("wget")
try:
    verify_lock(parse_lock(text), bundle)
except LockfileDriftError as exc:
    print(f"drift on {exc.preset_id}: {exc.expected_sha256[:12]} -> {exc.actual_sha256[:12]}")

Key ordering does not affect the hash — json.dumps(..., sort_keys=True) canonicalises first — so reordering a preset dict is not drift. Changing a value is.

What does not work

1. A change to a check function's body is not detected. A callable is canonicalised as its qualified name plus its captured closure values, so re-parameterising a guard factory is drift, but editing the code inside check is not — the bytes of the function are not hashed. Hashing bytecode would tie a lockfile to one Python version, which is worse for a lockfile. Implementation changes are covered by the airlock_version field, which is provenance rather than a constraint (below).

2. airlock_version is recorded but never enforced. The field is written into the lockfile and read back by parse_lock, and verify_lock ignores it: a lockfile generated by 0.6.0 verifies without complaint under 0.8.86. Only schema_version is checked, and only for equality with 1.

3. The lockfile is unsigned. It carries hashes, not a MAC, and regenerating it is one command. It detects drift, not tampering — anyone who can edit the bundle can re-emit a matching lock. Manifest signing is a separate mechanism (airlock pack verify, with AIRLOCK_PACK_SIGNING_KEY).

4. The parser accepts a restricted grammar, not TOML. parse_lock is a hand-written line parser kept for the 3.10 floor, and it does not implement arrays, inline tables, multi-line strings or nested sections. foo = [1, 2] parses as the string [1, 2] rather than a list, and no error is raised. Only the shape this file renders is supported.

5. It pins a pack manifest, not the live policy. --bundle-lock verifies the presets composed from --bundle-manifest. It does not observe the SecurityPolicy a running process actually built, so a policy assembled in code is outside its scope.

6. A preset containing a value with an address-bearing repr is refused, not hashed. UnstableHashError names the preset and the type. This is deliberate: the alternative is a digest that differs in every process, which is the bug this release fixed.