feat(agent): wire AGENTS.md discovery into a policy-gated turn - #50
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #29.
What this does
Wires the runtime's
AGENTS.mddiscovery (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.mdonly (noGARRISON.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
discover_with_root(working_directory, project_root, user_file), never its conveniencediscover()(which walks up to the nearest.git— a different, possibly wider, root than every other gate in this daemon already enforces).PolicyBundlefields:agents_md_discovery(enabled/disabled/restricted, defaultenabled— preserves current behavior for untouched bundles) andagents_md_allowed_paths(newline-separated, meaningful only underrestricted). Both are part of the checksum (allowed_pathssorted 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); anUngovernedinstall or a failed policy ask fails closed todisabled.The audit-trail gap
The issue asks that loading an
AGENTS.mdbe recorded in the audit trail (which files, which hash) so an auditor can see what steered a turn. Building this surfaced that acton-ai'sTurnRecordhas 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()'sagents_md_discoveryparsing can't reuse the hook's genericparse_enum(): that helper collapses "field absent" and "unrecognized value" onto the sameDefault, which is safe forNetworkEgress/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 dedicatedparse_agents_md_discovery()keeps "absent → Enabled" but fails an unrecognized value closed toDisabled.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.schemas/audit.schema:AuditEvent.operatorwas neverrequired(filled in by the ingest hook, never sent by the daemon), soaudit_event_hooks.proto'soperatorfield is nowoptional stringto match — it had drifted to non-optional.Verification
cargo check --workspace --all-targets— cleancargo clippy --locked --workspace --all-targets -- -D warnings— zero warningscargo test -p garrison-policy --lib— 76/76cargo test -p garrison-hooks policy_bundle::— 14/14 (includes the two new fail-closed-behavior tests)cargo test -p garrison-wire --lib— 38/38garrison-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.