Skip to content

Add a Go Guardian reference implementation - #169

Open
President wants to merge 32 commits into
GenAI-Security-Project:integrationfrom
disciplinedware:refimpl/go-port
Open

President wants to merge 32 commits into
GenAI-Security-Project:integrationfrom
disciplinedware:refimpl/go-port

Conversation

@President

@President President commented Sep 21, 2026 •

Copy link
Copy Markdown

Closes #87. A Guardian written in Go. It is in reference-implementations/agt-go/, next to the TypeScript Guardian.

I compared the two Guardians on the same sessions

The policy files are the same. Both Guardians read the AGT Rego files in this repository. The Go Guardian runs them with the AGT runtime itself: AGT's Rust library, linked through its C ABI (cgo), built from source at the commit agt-native.lock pins. A test requires that commit to be the one agt/agt.lock pins for the policy bundle. AGT is the only evaluator, because the #256 README asks maintained implementations to ship AGT's runtime. A build without cgo still compiles, and the command then refuses to start.

I wrote 49 test sessions and recorded the TypeScript Guardian's answers to them. Then I sent the same steps to the Go Guardian, signed, the way an agent sends them.

Compared Sessions Steps Different
disposition, reason codes, policy references, reasoning, modifications 49 53 2, both declared

Two declared exceptions, both in docs/differences.md. When the engine fails, both Guardians deny with evaluation_failed, and each writes its own reasoning. And in two sessions the policy target is not a string: the Go Guardian denies it, as the TypeScript fix in #229 does, while the recording still holds the old ALLOW. The test fails once a new recording carries that fix, so the exception cannot outlive it.

The recorded file also stores the Git object IDs of the TypeScript paths that decide an answer: the Guardian and bridge sources, mapping.yaml, the policies, agt.lock, and the bridge's package.json, which pins the AGT SDK. The test TestRecordingIsCurrent compares them with the current ones. Object IDs depend only on content, so a squash merge does not change them. If someone changes that code, this test fails, and the sessions have to be recorded again with scripts/record-ts-cases.sh.

What it does

cmd/guardian is a program. You run it next to an agent. make run starts it with guardian.yaml. It uses AGT as its policy engine. make agt-native builds AGT's library and fetches opa; docs/building.md covers Linux, macOS and the Windows cross-build. Each decision under AGT runs one opa process, so make load measures latency and memory under concurrent clients; docs/load.md has the results behind the shipped limits.max_engine_calls. The HMAC secret file holds base64 of at least 32 bytes, under a configured key_id; the audit logs rotate by size; docs/configuration.md lists every setting, and docs/conformance.md maps each expectation of #256 to its tests.

guardian is also a Go library. You give it four things: the policy engine, the keys, the session storage, and the place to write the audit log. CONTRIBUTING.md leaves the policy engine and the signature algorithm to deployments. The library treats storage and the audit log the same way, so it takes each of the four as an interface. examples/custom-engine is a second Go module. It gives its own four and answers a step.

What checks each claim

Claim What checks it
ACS-Core is met docs/conformance.md has one row per item. Each row names its tests. make docs-check fails if a named test does not exist.
The specification decides The schemas inside the module are compared byte for byte with the files in specification/v0.1.0/. The test reads those files by relative path, so if the specification changes, the test fails.
Signatures are correct A second verifier reads every response. It was written separately from the signing code and has its own RFC 8785 canonicalizer. It refuses a deny that was changed to an allow. It also refuses a response signed with the key of another session.
The canonical form is RFC 8785 It is checked against the vectors published by the author of RFC 8785, and against the first 5000 ES6 number vectors.
The two Guardians meet The TypeScript conformance runner sends requests over HTTP to the Go Guardian while it runs. This happens every time the checks run.

docs/conformance.md says what it claims and what it does not claim. ACS v0.1.0 has no certification, so the claim is a self-declaration, and each row names the test that covers it. docs/differences.md lists every difference from the TypeScript Guardian.

What I ran

uv run pytest -v passes, 1206 tests. uv run mkdocs build --strict passes.

make check in the module runs gofmt, go vet, Staticcheck, govulncheck, the tests with the race detector, the examples/custom-engine module, the Codex and OpenCode adapter tests, the shared conformance check against a Guardian that is running, the coverage check, and make docs-check. It passes. Coverage of the protocol packages, the AGT evaluator, the host adapter and the test Observed Agent is 86.8%. The floor is 80%.

Two defaults you may ask about

on_decision_failure. The library keeps the default from §6.4, which is proceed. The guardian.yaml in this repository sets deny. So the Guardian you can run here blocks the step when it cannot decide.

Two Codex settings in make codex-example. -s workspace-write stops Codex from asking the person about each command, so the Guardian is the one that decides. Codex also asks once whether it may run the hook file of this project, and --dangerously-bypass-hook-trust skips that question. Both settings apply only to the Codex program that this command starts. examples/codex/README.md says what each one opens.

Examples

examples/codex/ and examples/opencode/ show that a host uses the decision the Guardian sends. make codex-example and make opencode-example start the real Codex and OpenCode programs. make check does not start them. It sends recorded host events to the same adapter code. When reference adapters for these hosts are added, these examples can point to them.

Files outside the new directory

There are seven. Two are under .github/: the Go job, .github/workflows/reference-implementation-go.yml, which also runs weekly so govulncheck sees new advisories, and the gomod entries in .github/dependabot.yml. The agt/README.md change updates its count of test files, rewords the two sentences around it, and tells a TypeScript contributor when to re-record the answers the Go Guardian replays.

The other four add one check to the TypeScript conformance runner. The check sends signed requests over HTTP to any Guardian and reads the answers. It works for any Guardian, not only this one. That is why it sits next to the runner and not inside the Go module.

  • packages/conformance/src/external-guardian.ts is the check.
  • packages/conformance/test/external-guardian.test.ts is its test.
  • packages/conformance/src/main.ts calls the check and prints whether it ran.
  • package.json adds the conformance:guardian script.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@github-project-automation github-project-automation Bot moved this to Needs triage in ACS Project Tracker Sep 21, 2026
rocklambros added a commit that referenced this pull request Sep 22, 2026
…179)

CodeQL default setup was turned on for this repository on 2026-09-21 and covers the Actions language. This adds zizmor, which the [OWASP GitHub Actions Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/GitHub_Actions_Security_Cheat_Sheet.html) recommends beside CodeQL for defense in depth. zizmor reads each workflow's logic and checks what CodeQL does not: hash pins no tag points to, version comments that disagree with their pin, App tokens broader than the job needs, and credentials a checkout leaves in `.git/config`.

Run against `integration` before this change with online audits on, zizmor 1.30.1 reported 15 findings. This clears 13. The other two are `github-app` findings on `board-reconcile.yml`, whose App token takes the installation's full grant instead of naming its repository and permissions. Token scopes are not changing right now, so those two stay open in code scanning, where they record the deferred decision instead of disappearing behind a suppression.

- **`pr-intake.yml`, 1 high.** zizmor flags every `pull_request_target`. This one checks out no code and reads the pull request body as data through an environment variable, which the workflow's header already explains. It carries an inline `zizmor: ignore` that points at that explanation.
- **Eleven checkouts across nine workflows, 11 low.** Each left the job's token in `.git/config`. None of those jobs pushes with git. They call `gh` with `GH_TOKEN` or touch no network at all, and `sync_version.yml` hands its push to `create-pull-request`, which configures its own token. All eleven now set `persist-credentials: false`. The checkout in `validate-owasp-metadata.yaml` also gains the `# v7.0.1` comment every other pin carries.
- **`reference-implementation.yml`, 1 medium.** The setup-bun pin `735343b` is v2.0.2, and its comment said v2.0.1. Only the comment changes. Dependabot's #83 already treats it as 2.0.2.

The new workflow runs zizmor from a `zizmor` dependency group in `uv.lock`, pinned with hashes. No new action enters the Actions allowlist, and Dependabot's uv ecosystem moves zizmor under the existing 7-day cooldown. The group is not a default group, so `uv sync --locked` in the deploy and test jobs never installs it. A default sync against this lockfile would uninstall it.

It runs on pull requests and pushes that touch `.github/`, an action definition, `pyproject.toml`, or `uv.lock`, and weekly, because the online audits compare pins against advisories published after the pin. Findings upload as SARIF under the category `zizmor`, and the job stays green on findings so code scanning tracks them and a ruleset can gate on them later. A tool or upload failure still fails the job. A pull request from a fork cannot upload with its read-only token, so it gets annotations instead. `upload-sarif` is pinned at v4.38.0 because v4.38.1 was three days old, inside the cooldown this repository applies to its dependencies.

Open pull requests #162 and #169 add workflows that zizmor will flag once they land, for unset `persist-credentials` and the same `# v2.0.1` setup-bun comment.

Verified locally: zizmor 1.30.1 with online audits reports only the two deferred `github-app` findings, with 1 ignored and 26 low-confidence findings suppressed by the default persona. The workflow's own command produces valid SARIF 2.1.0. `actionlint` is clean. `uv lock --check` passes under uv 0.9.9, the version CI pins, and the lockfile diff adds only zizmor. `uv run pytest` gives 278 passed, 1 skipped.

Signed-off-by: Rock Lambros <rock@rockcyber.com>
@acs-board-reconciler acs-board-reconciler Bot moved this from Needs triage to In progress in ACS Project Tracker Sep 22, 2026
…comment in the Go workflow

zizmor, added in GenAI-Security-Project#179, reports both once this workflow lands. Nothing in the
job pushes with git, so the checkout sets persist-credentials to false. The
setup-bun pin 735343b is v2.0.2, and only its comment changes.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President
President requested a review from artmaro as a code owner September 23, 2026 00:47
…efore the code

On the 24 Sep call the pull request was called too big to review. This commit
changes documentation only.

- docs/architecture.md, new: what the Guardian is made of, a diagram for each
  path a request takes, and the order to read the code in.
- docs/embedding.md is now docs/extending.md: what your own implementation of
  each interface must do.
- README: "Start here" links every section and document.
- Each fact lives in one document; a Mermaid diagram GitHub could not render and
  several statements that did not match the code are fixed.

No code changed. make check, uv run pytest (279 passed) and
uv run mkdocs build --strict pass.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President

Copy link
Copy Markdown
Author

@afogel @rocklambros Following today's call I reorganised the docs so the review can go in two steps.

  1. Agree on the architecture: docs/architecture.md. It shows the parts, the four interfaces, and a diagram for each path a request takes.
  2. Then review the code against it, in the order at the end of that page.

No code changed. Happy to answer questions while you go through it.

@rocklambros rocklambros changed the title Add a Go Guardian reference implementation Add a Go Guardian reference implementation . Oct 5, 2026
@rocklambros rocklambros changed the title Add a Go Guardian reference implementation . Add a Go Guardian reference implementation Oct 5, 2026
@shivang-sec

Copy link
Copy Markdown

I completed an independent substantive review of the Go Guardian against the ACS v0.1.0 requirements and existing TypeScript Guardian behavior. I checked the handshake/envelopes, duplicate request_id handling, chain hashing, all five dispositions, MODIFY/ASK/DEFER behavior, replay/skew handling, HMAC, session and chain handling, intent immutability, decision honoring, system/ping, wrapped MCP, PolicyEngine/delegate_to, hook-specific behavior, failure/state consistency, concurrency, TypeScript↔Go parity, external Guardian wire behavior, AGT bridge parity, ACS-Core vectors, and the existing #160–#167 regression areas.
I also checked the PR CI; all checks are passing. I did not identify a new substantive finding; the behaviors I reviewed were either conformant or already covered by existing tests/findings.
Conclusion: no new finding from my review.

@Mayur021

Mayur021 commented Oct 5, 2026

Copy link
Copy Markdown

Not a review of the Go implementation, I have not read it at that depth. But I did check it against the six reference-Guardian bugs in the #160 to #167 range, and each one has a named mechanism here.

Capability enforcement returns -32003, and the handshake narrows the client's methods_implemented to what this Guardian actually evaluates, with a later step on an unnegotiated method refused as not negotiated (#160). The JSON-RPC boundary is validated before the envelope is interpreted or a signature is checked, returning -32600 with invalid_jsonrpc_request, so a JSON-RPC-level failure no longer arrives as an envelope error or as a deny (#163). Step traffic on a session that has not completed handshake/hello is refused the same way (#164). transports_supported is read rather than ignored: no overlap with the transport this Guardian serves is a SESSION_REFUSED with transport_unsupported, instead of asserting http regardless (#165). A step naming an agent other than the one that opened the session is denied with agent_id_not_bound (#166). And steps/sessionStart dispatches against its own payload rather than falling through to method-not-dispatched (#167).

Worth adding that the wire here is authenticated. Anything other than system/ping is rejected unsigned, and hmac_signer.go derives a per-session key with HKDF-SHA256 and compares with hmac.Equal, which is #70. It also writes down the HKDF parameter choice that section 10 leaves open, which is part of what #118 asks for.

All of those are filed against the TypeScript reference, so they stay open on it. But a second implementation that does not reproduce them is useful evidence for what the reference should be doing, and every claim above is cheap to re-check from the name.

Brings in PR GenAI-Security-Project#21, which changed specification/v0.1.0/hooks/subagent-stop.json.

Claude-Session: https://claude.ai/code/session_01NFMr7gTJSEMihfcUTXMhR4
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
…ecurity-Project#21)

- Refresh the embedded hooks/subagent-stop.json: final_chain_hash is now
  optional, and the minimal subagentStop in the tests omits it.
- The Codex and OpenCode host adapters deny a MODIFY their host cannot
  apply with modify_unsupported and record it in their audit log (§6.5).
  They record a signed MODIFY of invalid shape as modify_invalid (§6.3).
- The docs cite GenAI-Security-Project#21 as merged ACS-Core, not an open proposal.

Claude-Session: https://claude.ai/code/session_01NFMr7gTJSEMihfcUTXMhR4
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President

Copy link
Copy Markdown
Author

Merged integration (with #21) into this branch and updated the code for it:

  1. updated our copy of hooks/subagent-stop.json: final_chain_hash is optional now
  2. Codex/OpenCode adapters: if a MODIFY can't be applied (§6.5) or is broken (§6.3), they deny it and write it to the audit log
  3. docs: removed the "open PR Set the ACS-Core floor: MODIFY and system/ping to SHOULD, Wrapped MCP and subagentStart as conditional MUSTs #21" notes, now they point to the spec sections

The Guardian works the same as before.

@Mayur021

Mayur021 commented Oct 5, 2026

Copy link
Copy Markdown

Re-checked the eight against 2b8ccf5 after the merge, since my earlier note named code at 158243d. All still hold: -32003 for the unnegotiated method and for a session lookup no handshake negotiated, -32600 with invalid_jsonrpc_request ahead of the envelope and the signature, the transport refusal, agent_id_not_bound, steps/sessionStart in the method table, and hmac.Equal over the HKDF-derived key.

On the section 6.5 change, the postCompact exception does not bite here. RefuseModification always answers DENY, which would be wrong at postCompact where DENY is not a legal disposition and the client is meant to keep the original summary. But the Codex adapter wires only steps/toolCallRequest and the OpenCode one adds steps/toolCallResult, so that hook never reaches it. Worth a comment at the call site if either adapter grows a compaction hook later, because the rule reads naturally as universal and the exception is one paragraph away from it.

One thing the merge changed in this PR's favour. The acs-core row #21 landed now names replay protection and a baseline HMAC-SHA256 signature in the mandatory column. Those are the two things this tree has and the TypeScript reference discloses as absent, so the floor moving today made the gap between them wider rather than narrower.

@artmaro artmaro self-assigned this Oct 8, 2026
The host adapters answered DENY at every hook when they could not honor a
Guardian decision. §6.5 forbids DENY at postCompact, and hooks.md permits
no DENY at five hooks, whose actions have already happened.

- Where the hook permits no DENY, the host's refusal is ALLOW plus an
  audit record. This covers a MODIFY the host cannot apply
  (modify_unsupported), a MODIFY of invalid shape (modify_invalid), a
  refused handshake (now recorded as session_refused) and the decision
  failure posture.
- The handshake diagram shows the AuditLog envelopes.

Claude-Session: https://claude.ai/code/session_01NFMr7gTJSEMihfcUTXMhR4
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
The default build uses the pinned AGT Rust library through its C ABI.
Set EVALUATOR=go to build or run the Go evaluator explicitly.

Keep one projection and one evaluator interface. Verify the loaded library
against its build record. Refuse changed sources and missing native support.
Compare both evaluators against recorded decisions. Preserve empty result
labels and retain decision slots until uninterruptible engines return.

Use GOOS and GOARCH for Linux amd64 and arm64, both Mac architectures,
and Windows amd64. Package the library and build record with the binary.
Use MinGW for Windows and the Apple SDK for native macOS builds.
Add portable timeout and shutdown tests and a five-platform CI matrix.

Build Linux amd64 and arm64 images with Docker Buildx. Keep secrets outside
the image. Run signed conformance probes through a published host port.
Update build, configuration, architecture, and deployment documentation.

Validation: Linux make check passes. Windows AGT, CLI shutdown, and
recorded decisions pass under Wine. Both Linux images build and pass all
10 external probes; arm64 executes through Docker emulation. Real Windows
and macOS runner results remain unverified.

Claude-Session: https://claude.ai/code/session_01NFMr7gTJSEMihfcUTXMhR4
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
Remove agtbridge/goeval, the port's Go reimplementation of AGT's
policy runtime. The README merged in GenAI-Security-Project#256 requires maintained
implementations to ship only AGT's runtime as their evaluator.
AGT is now the only evaluator. Remove OPA from go.mod. The README
allows a second evaluator in tests as a drift check; do not keep
goeval for that, because it would have to follow every AGT update.
A build without the agteval tag still compiles, and the command then
refuses to start.

Leave policy.evaluator, EVALUATOR and the agtbridge.Evaluator interface
in place, with "agt" as the only value, so another evaluator is one new
case. The bridge tests also use the interface to plug in fake
evaluators.

Switch the bridge tests that used the Go evaluator to AGT. They used
it because it was fast and needed no Rust. AGT already ran all 49
recorded sessions before this change. Drop
TestNewRefusesAnUnsupportedManifest: it tested which manifest
features the Go evaluator refused. Drop the load measurements that
only the Go evaluator produced.

Regenerate the spec's JSON schema copies and re-record the
TypeScript Guardian's answers. No recorded decision changed.

Add a signer test against the example key in spec section 10.
GenAI-Security-Project#272 defines the HKDF key-derivation parameters there. The Go signer
already used exactly those parameters.

In docs/conformance.md, mark three expectations of the GenAI-Security-Project#256 README
as not met yet:
- The command accepts one HMAC key. The README requires at least
  two keys at once to support key rotation.
- A full audit queue, or a write failure, loses audit events the
  spec requires while decisions continue. The README requires
  keeping them and denying instead.
- An oversized or unreadable request body gets an error, and the
  engine's deadline equals the declared timeout. The README
  requires a deny and a lower deadline.
Mark the interim verification as met: make check runs the
TypeScript conformance probes against this port.

GenAI-Security-Project#273 needs no change. TestDecisionFailureRecordsItsClass and
TestHandshakeFailureRecordsItsClass in internal/hostadapter already
cover recording every decision failure.

Verified locally: make check passes, including all 10 external
wire checks. The container image and other CI platforms were not
tested locally. CI runs those checks.

Claude-Session: https://claude.ai/code/session_01SzmLMNjG3KSaCFfg8aKB11
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
The GenAI-Security-Project#256 README expects the configuration to map each key_id to one
secret and accept at least two at once, so a secret is replaced
without an outage. The library already took several keys; the command
took one.

Add security.hmac_previous_keys, a list of key_id and secret_file
pairs. The Guardian verifies a request under any configured key and
answers under the same key, as section 10 requires. An unknown key_id
is still refused with SIGNATURE_INVALID.

docs/configuration.md gives the steps to replace a secret.
TestServesThePreviousHMACKey runs the command with a current and a
previous key and checks that both are answered; it fails when the
previous keys do not reach the signer.

Claude-Session: https://claude.ai/code/session_01SzmLMNjG3KSaCFfg8aKB11
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
The GenAI-Security-Project#256 README expects the default time limit to be below the
timeout_config the Guardian declares. The Guardian declared
decision_timeout and gave the engine the same time, counted from the
engine call, so a step denied for time reached the agent after it
stopped waiting, and the agent applied on_decision_failure.

Count the deadline from the request's arrival and end it
protocol.answer_margin before the declared timeout. Reading the body,
the wait for the session lock and the session's load run under this
Guardian's own deadline, since a session's terms are known only once it
is loaded; the rest of the step, the store and the engine included, runs
under the session's negotiated timeout. Recording the decision gets half
the margin, past the decision's deadline but not past a lost session
lock, a shutdown or the caller's deadline; signing and sending keep the
other half. The margin scales with a session's negotiated timeout.
guardian.yaml ships 500ms. policy.opa_timeout must stay below
decision_timeout minus the margin, which keeps the GenAI-Security-Project#269 rule, and
server.read_timeout at least that, since the body's read ends there.

An engine that never answers, a store that stops answering, a step that
waits too long for its session's lock, a late decision whose recording
stalls, and a stalled store under a session negotiated with a shorter
timeout all get an answer in time
(TestDenialArrivesBeforeTheDeclaredTimeout,
TestSlowStoreAnswersBeforeTheDeclaredTimeout,
TestLockWaitAnswersBeforeTheDeclaredTimeout,
TestSlowRecordingAnswersInTime,
TestRecoveredSessionAnswersInItsOwnTime), and a session negotiated with
a longer timeout keeps it (TestRecoveredSessionKeepsItsLongerTimeout);
each fails without the change. A lost lock and a shutdown after the
decision's deadline, and the caller's deadline, still stop the recording
(TestLostLockStopsRecording, TestShutdownStopsRecording,
TestCallerDeadlineStopsRecording). docs/conformance.md cites GenAI-Security-Project#324 for
the body size case: the Guardian cannot sign a deny without the key_id
and session_id inside the body it does not read.

Claude-Session: https://claude.ai/code/session_01SzmLMNjG3KSaCFfg8aKB11
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
The GenAI-Security-Project#256 README expects the audit events §6.4, §6.5 and §9.2 require,
and the §8 session chain, to be never dropped: if the Guardian cannot
keep them, it answers deny. §6.4's record is the Observed Agent's, and
the host adapter already denies when it cannot write it; the §8 chain
lives in the store, and a step whose entry is not appended is already
denied, or allowed with the reason at a hook that permits no DENY. The
Guardian's own events, those of §6.5 and §9.2 and the unresolved
request_id_ref hooks.md requires at toolCallResult, were written in the
background and dropped when the queue was full or the disk failed, and
rotation deleted the oldest file.

Add AuditLog.RequiredEvent, which returns once the event is written or
its context ends. modify_unsupported, which names the rewrite not
delivered, ask_substituted and request_id_ref_unresolved go through it;
when it fails, the step is answered DENY with the substitute's reason
code, which §6.5 and §9.2 require, and audit_unavailable, a reason code
of this port. An embedder that implements
AuditLog adds the method. A nil AuditLog records nothing, so such a step
is denied. At postCompact, where §6.5 permits no DENY, the step is
answered ALLOW with modify_unsupported and audit_unavailable, and the
failure is reported to the audit log and its error callback.

JSONLAuditLog writes a required event in queue order and reports the
write by the step's deadline; a write still pending then also reaches
the error callback and Close. An event whose waiter gave up before the
writer took it is not written; a write already under way may still land,
followed by the Guardian's failure record for the same request. Each
file gets its own size and file count: audit.max_envelope_file_bytes and
audit.max_envelope_files replace audit.max_file_bytes and
audit.max_files, and audit.max_event_file_bytes and
audit.max_event_files are new. The envelope file deletes its oldest
file. The event file deletes none: while audit.max_event_files remain,
each event is dropped and counted and a required one fails, until the
deployment archives them; each new fill reports once. guardian.yaml
keeps at most 1 GiB of envelopes and 2.25 GiB of events.
NewJSONLAuditLog takes one JSONLRotation per file and refuses two paths
that rotate onto each other.

Tests cover a failing, a panicking and a missing log for each of the
three events, a stalled, a queued-out and a failed write, an abandoned
event, retention across rotations, a full and a refilled event file
and overlapping paths; each fails without its fix.

Claude-Session: https://claude.ai/code/session_01SzmLMNjG3KSaCFfg8aKB11
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
Older edge cases, found in review, in code that predates the README
changes.

A §6.5 or §9.2 substitution answers with modify_unsupported or
approver_unavailable and reasoning that names the modification or the
missing approval capability. A later answer in the same step replaced
it with a plain DENY and lost both: a store failure, a store panic, the
session's deferral bound and an invalid intent grant. Each now keeps
the substitute's reason code and reasoning, and audit_unavailable when
the required audit event was not written.

A crash during a write could leave an audit file ending inside a
record, and the next record then joined that line. The file now gets
the line ended when it is opened.

Each case has a test that fails without its fix.

Claude-Session: https://claude.ai/code/session_01SzmLMNjG3KSaCFfg8aKB11
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President

Copy link
Copy Markdown
Author

@rocklambros @artmaro, hi! On the call I promised to double-check #169 after the latest merges into integration.

Done - #169 now includes the latest integration. The README expectations for reference implementations (#256) added 3 things #169 did not meet yet, so I fixed them in this PR:

  1. HMAC keys - previous keys are accepted too, so a secret is replaced without an outage - f55d8a7
  2. Time limit - a deny for time now reaches the agent before timeout_config - 68afa73
  3. Required audit records - never dropped or deleted; if one can't be written, the step gets DENY - c4fe9dd

One case the README leaves open: a body over the size cap can't get a signed deny in any Guardian - opened #324, marked "Not met yet".

Also:

  • removed the Go evaluator, as we discussed on the call - AGT is the only one now - b57dfb6
  • the review found a few older edge cases - fixed in a separate commit - ea29dc4

make check passes on Linux and macOS. Happy to change anything.

artmaro
artmaro previously approved these changes Oct 25, 2026

@artmaro artmaro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Konstantin (@President), for bringing this in line with the README so quickly.

I reviewed #169 against the expectations in reference-implementations/README.md (#256). I also built and ran it locally with the native AGT runtime. make check passes, and the Guardian starts with the AGT evaluator and answers over HTTP.

From the Reference Implementation side this is ready to merge.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
GenAI-Security-Project#309 changed packages/guardian/src. Only its tree ID changes; all 49
answers stay the same.

Claude-Session: https://claude.ai/code/session_01Rhwvb5CFUg1pyMvKKnbHXt
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President

Copy link
Copy Markdown
Author

@rocklambros @artmaro - synced with integration and re-recorded the TS answers after #309, nothing else changed - 6abce88. Could you take another look, please?

go generate ./internal/schema copies specification/ into the module, and TestSchemasMatchSpecification compares the two.

Claude-Session: https://claude.ai/code/session_01NFeGFKPFpAQbJj5ZhbHkRy
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President

President commented Nov 2, 2026 •

Copy link
Copy Markdown
Author

Synced with integration again - no code changes, only the copied #278 schema, which the Go Guardian does not read - 81693cf.
I also updated the description: it no longer mentions the Go evaluator, which was removed earlier, and the test numbers are current.

The library takes an approver (agtbridge.Options.Approver), but the
acs-guardian binary had no setting for it, so every escalate verdict was
DENY approver_unavailable, whatever policy.ask_substitution said: with
defer, the agent got DENY, not DEFER.

policy.ask_approver (type and id) in guardian.yaml now names it. The
§9.2 substitution applies first, so deny and defer work with or without
an approver. Under none, an ASK names the approver; without one it is
DENY approver_unavailable, and the reasoning and the audit event say the
Guardian names no approver. The shipped guardian.yaml keeps
ask_substitution: deny for its Codex and OpenCode examples and names
the approver the TypeScript Guardian of GenAI-Security-Project#229 uses, deployment-default.

TestServesAnAsk runs the binary over the reference policy with its
approval rule on, for none, deny and defer, each with and without an
approver.

Claude-Session: https://claude.ai/code/session_01NFeGFMmjiqJzqraaWso548
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
ACS §12.1 gives the policy engine the request envelope, SessionContext,
Intent and provenance. The Go core already passes them to the engine,
but the AGT bridge built the snapshot from the call alone, so a rule
could not see who sent the step or where an argument came from.

The snapshot now places them where AGT's snapshot specification names
them. envelope carries agent, session, intervention_point, timestamp
and tenant. tool_call carries the whole call payload, each argument's
provenance keyed by argument name because args holds plain values, and
tool_result the whole result payload with each output's provenance.
actor carries metadata.user_context, metadata the rest of metadata,
and intent the session's Intent. The session's chain stays out: it
grows with every step, and AGT refuses a snapshot over 1 MiB.
docs/architecture.md, "What the policy receives", lists every member.

TestPolicyReadsTheRequestAndSession and TestPolicyReadsTheToolResult
decide on the new members at both gates and pin the snapshot's members.
make load on the same host shows no change in decisions per second or
latency, with sessions of 20 and of 500 steps.

Claude-Session: https://claude.ai/code/session_01NFeGFMmjiqJzqraaWso548
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
When mapping.yaml named no policy_target_argument for a tool, the AGT
bridge returned an error before AGT ran, and the Guardian denied the
call. A manifest whose policy_target is not the copied argument, such
as $.tool_call.name, could not govern any tool, and a tool with no
arguments could never be allowed. The TypeScript Guardian has the same
behaviour.

Now the bridge copies nothing into acs_policy_target in that case, and
AGT evaluates the snapshot. The check that refuses an agent-supplied
acs_policy_target stays on. A transform of the policy target has no
argument to change, so the Guardian still denies it. The reference
mapping names an argument for every tool, so the recorded TypeScript
answers do not change.

TestPolicyTargetOutsideTheArguments allows a call with no arguments
and denies a transform with no argument named.

Claude-Session: https://claude.ai/code/session_01NFeGFMmjiqJzqraaWso548
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President

Copy link
Copy Markdown
Author

@rocklambros @artmaro - sorry, three more commits, and not small ones.

I started to use the Go Guardian in practice, with an OpenClaw agent (#287), and found real gaps:

  1. 35240d9 - the binary could never return ASK: it had no setting for the approver. Now policy.ask_approver.
  2. e46525f - the policy saw only the tool call, not who sent it or where the data came from (§12.1). Now it gets the sender, metadata, Intent and provenance; docs/architecture.md lists every field.
  3. e353603 - a tool call was always denied when mapping.yaml names no policy target argument, e.g. a tool with no arguments.

The TypeScript Guardian has gaps 2) and 3) too - I will open separate issues for them.

make check passes. Happy to change anything.

@artmaro artmaro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@President Konstantin, thanks for checking these under real work load as well. I testing it with claude and it looks good, I see no blockers to merging from my side.

@rocklambros @afogel please take a look to approve. Thanks

@President

President commented Nov 4, 2026 •

Copy link
Copy Markdown
Author

@artmaro thanks for testing it with Claude Code!

FYI: As promised, the TypeScript Guardian gaps are filed: #493 (gap 2) and #492 (gap 3).

@President

Copy link
Copy Markdown
Author

@rocklambros @bar-capsule @afogel, following today’s call: this PR closes #87, which is accepted and assigned to me.

  • Evgeniy approved it on Nov 4.
  • Rock, your review from October 12 still requests changes. I made them in e233a35, but that review still blocks the merge.

Is anything left for me to fix?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

Port the AGT reference implementation to Go

5 participants