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.