Skip to content

feat(agent): wire AGENTS.md discovery into a policy-gated turn - #50

Merged
rrrodzilla merged 1 commit into
mainfrom
worktree-agents-md-discovery
Aug 30, 2026
Merged

rrrodzilla merged 1 commit into
mainfrom
worktree-agents-md-discovery

Conversation

@rrrodzilla

Copy link
Copy Markdown
Contributor

Closes #29.

What this does

Wires the runtime's AGENTS.md discovery (Govcraft/acton-ai#15, shipped in acton-ai 0.36.0) into the daemon, gated by a governed install's policy bundle, per the issue's requirements: AGENTS.md only (no GARRISON.md), project instructions treated as untrusted content that can never widen what the bundle/approved root/sandbox/approval gate decide, and a policy-bundle knob to disable or restrict discovery.

Design

  • Confined. Discovery always calls acton-ai's discover_with_root(working_directory, project_root, user_file), never its convenience discover() (which walks up to the nearest .git — a different, possibly wider, root than every other gate in this daemon already enforces).
  • Gated. New PolicyBundle fields: agents_md_discovery (enabled / disabled / restricted, default enabled — preserves current behavior for untouched bundles) and agents_md_allowed_paths (newline-separated, meaningful only under restricted). Both are part of the checksum (allowed_paths sorted before hashing). A governed bundle's setting is exclusive, same as command/tool rules; standalone runs unrestricted (same trust the local auto-approve list gets); an Ungoverned install or a failed policy ask fails closed to disabled.
  • Labeled. The injected fragment tells the model, in its own context, that what follows is project content, not an operator instruction — defense in depth, not the boundary itself (the real boundary is structural: sandbox/approval/rules never read a turn's context).

The audit-trail gap

The issue asks that loading an AGENTS.md be recorded in the audit trail (which files, which hash) so an auditor can see what steered a turn. Building this surfaced that acton-ai's TurnRecord has no field for it, and sealing is entirely internal to the crate — garrison-agent has no way to inject this into the tamper-evident chain today.

Filed Govcraft/acton-ai#18 proposing a chain-integrated field (mirroring how acton-ai#16 unblocked turn entries). Until that lands, loaded files + BLAKE3 hashes (never content) are recorded via structured tracing — documented in both design docs as logged, not sealed, so this isn't quietly overclaiming a guarantee it doesn't have yet.

Also fixed along the way

  • header_of()'s agents_md_discovery parsing can't reuse the hook's generic parse_enum(): that helper collapses "field absent" and "unrecognized value" onto the same Default, which is safe for NetworkEgress/ApprovalMode (their default is the strict end) but wrong here, since this field's schema default (Enabled, for backward compat) is the permissive extreme. A dedicated parse_agents_md_discovery() keeps "absent → Enabled" but fails an unrecognized value closed to Disabled.
  • PromptBuilder::system() replaces rather than appends (verified against acton-ai's source) — caught before shipping a bug where the operator's configured system prompt and the AGENTS.md fragment would have silently clobbered each other.
  • Regenerating the hook protos picked up an unrelated, correct fix already latent in schemas/audit.schema: AuditEvent.operator was never required (filled in by the ingest hook, never sent by the daemon), so audit_event_hooks.proto's operator field is now optional string to match — it had drifted to non-optional.

Verification

  • cargo check --workspace --all-targets — clean
  • cargo clippy --locked --workspace --all-targets -- -D warnings — zero warnings
  • cargo test -p garrison-policy --lib — 76/76
  • cargo test -p garrison-hooks policy_bundle:: — 14/14 (includes the two new fail-closed-behavior tests)
  • cargo test -p garrison-wire --lib — 38/38
  • garrison-agent's own test binary aborts locally on startup due to a pre-existing, machine-local AWS-LC-FIPS self-test SIGABRT unrelated to this change (confirmed against CI on a prior PR, which ran the same suite clean) — relying on this PR's CI run to verify it, same as the last PR that touched this crate.

Closes #29.

garrison-agent now loads AGENTS.md project instructions before a turn's
system prompt is assembled, over acton-ai 0.36.0's AgentInstructions
(Govcraft/acton-ai#15). AGENTS.md only, deliberately: no GARRISON.md.
An administrator's instructions already have a trusted channel, the
control-plane policy bundle, and a second markdown layer would duplicate
that channel rather than add anything to it.

Confined and gated, not merely wired in:

- Discovery always calls acton-ai's discover_with_root(working_directory,
  project_root, user_file), never its convenience discover(), which walks
  up to the nearest .git — a wider and different notion of "root" than the
  one every other gate in this daemon already enforces.
- PolicyBundle gets two new fields, agents_md_discovery (enabled/
  disabled/restricted) and agents_md_allowed_paths, both part of the
  checksum (allowed_paths sorted before hashing, so two authors who typed
  the same paths in a different order published the same policy). A
  governed install's bundle is exclusive here the same way it already is
  for command and tool rules: enabled searches the whole approved root
  plus the operator's own ~/.agents/AGENTS.md, restricted confines search
  to the named paths and drops the user layer entirely, disabled loads
  nothing. A standalone install with no plane runs unrestricted, on the
  same reasoning the local auto-approve list is only read while
  standalone. An Ungoverned install or a policy ask that errors fails
  closed to disabled, matching every other gate in this daemon.
- Project instructions are treated as untrusted content in both the code
  and the doc comments: loading one can shape what a turn asks for and
  never what the policy bundle, the approved root, the sandbox, or the
  approval gate decide, because none of those read a turn's context. The
  injected fragment is also labeled in the model's own context as
  project-authored content, not an operator instruction — defense in
  depth, not the boundary itself.

One design gap surfaced building this, and it's the reason this isn't a
smaller diff: acton-ai's audit chain (TurnRecord) has no field for
recording which AGENTS.md layers steered a turn, so garrison-agent has no
way to seal that provenance into the tamper-evident chain the rest of the
audit trail gets. Filed Govcraft/acton-ai#18 proposing a chain-integrated
field, mirroring how acton-ai#16 unblocked turn entries. Until that
lands, loaded files and their BLAKE3 hashes (never their content) are
recorded via structured tracing, clearly documented in both docs/
garrison-agent-design.md and docs/control-plane.md as logged and not yet
sealed — an honest interim, not a claim of a guarantee this doesn't have
yet.

Also fixed along the way:

- header_of() in the policy_bundle hook can't reuse its generic
  parse_enum() for agents_md_discovery: parse_enum collapses "field
  absent" and "value this binary doesn't recognize" onto the same
  Default, which is safe for NetworkEgress and ApprovalMode because their
  Default already sits at the strict end, but agents_md_discovery's
  schema default is Enabled — chosen so an untouched bundle keeps today's
  behavior — which makes it the permissive extreme instead. A dedicated
  parse_agents_md_discovery() keeps "absent" reading as Enabled
  (backward compatible) while making an unrecognized value fail closed to
  Disabled, so a future discovery mode this binary predates can't
  silently decode as unrestricted loading.
- .system() replaces rather than appends (confirmed against acton-ai's
  own source), so the operator's configured system prompt and the
  discovered AGENTS.md fragment are combined into one string before a
  single .system() call, instead of two calls where the second would
  have silently discarded the first.

Regenerating the policy_bundle and audit_event hook protos via
schemaforge also picked up an unrelated, correct fix already latent in
schemas/audit.schema: AuditEvent.operator has never been `required` (it's
filled in by the ingest hook, never sent by the daemon, so client-facing
required-field validation running before before_validate can't demand
it) — audit_event_hooks.proto's operator field is now `optional string`
to match, where it had drifted to non-optional.
@rrrodzilla
rrrodzilla merged commit e4bbd44 into main Aug 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adopt AGENTS.md project instructions as untrusted content under the policy bundle

1 participant