Skip to content

Add a Go Guardian reference implementation - #169

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

President wants to merge 17 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. By default 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. EVALUATOR=go builds without cgo and runs the same files with the OPA Go library instead.

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, once with each evaluator.

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, 913 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 evaluators, the host adapter and the test Observed Agent, with the AGT evaluator built in, is 85.9%. 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>
Go 1.27.2 fixes the net/http CONNECT connection desynchronization
(GO-2026-6605). Go 1.27.2 and golang.org/x/net v0.60.0 fix the HTTP/2
HPACK encoder race (GO-2026-6617). Use the same Go version in the image.

Run Staticcheck through go tool with this module's pinned dependencies.
The versioned go run command cannot read Go 1.27.2 export data.
Document the supported lint command.

Check the custom-engine module with the same pinned Staticcheck and
govulncheck binaries. Check its module tidiness and run vet once.
Run govulncheck with and without the native AGT build tag.
Prepare Go build commands for all pinned native targets to check cgo flags.
This check detects flags Go rejects without requiring cross-compilers.

Show each make check stage as it starts and completes.
Use one line in terminals and separate lines in CI. Keep the existing gate order
and save full output in a private log. Print diagnostics on failure and
retain the gate's nonzero exit code. Update and simplify the documentation.

Validation: full make check passes with Go 1.27.2, including both
evaluators, race checks, conformance, and govulncheck. Both Linux image
architectures build and pass the external conformance probes.
The full gate also passes with compact output. Negative checks prove that
failed checks remain failures and that the full log is retained.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
The repository's Actions policy does not allow msys2/setup-msys2, so the
workflow failed at startup and no Go job ran. Remove the Windows row and its
MSYS2 step. Linux amd64, Linux arm64 and both Mac architectures keep the
complete gate. State in building.md that a Windows build is checked by hand.

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

check-coverage.sh discarded the output of go test, so a test that failed
during the coverage run left only an exit code. The macos-15-intel job of
run 38003172529 failed this way. Keep the output and print it on failure.

No run on Windows backs the Windows claims. The README and building.md now
say that the Windows amd64 build is not tested on Windows.

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

Copy link
Copy Markdown
Author

@Mayur021 @artmaro - pushed an update:

  1. postCompact (Mayur's note): fixed in code, not only a comment. At the 5 hooks where DENY is not allowed (the action already happened), the adapter now lets it through and writes the refusal to the audit log. This replaces "they deny it" from my comment on 5 Oct.
  2. Real AGT (Evgeniy's ask): the Guardian now decides with Microsoft's AGT library (same version the TS reference pins), called through its C ABI. Our Go evaluator stays as an option (EVALUATOR=go, builds without cgo) - both run the same recorded cases and must match.
  3. Go 1.27.2 + x/net v0.60.0 - fixes GO-2026-6605 and GO-2026-6617.
  4. Merged latest integration. CI runs on Linux amd64/arm64 and both Macs.

@rocklambros

Copy link
Copy Markdown
Collaborator

@President, a heads-up about your embedded spec copy. This PR is based on db5c89e, which is older than #63. The copy of trace/otel-mapping.json and trace/ocsf-mapping.json under internal/schema/specification/ therefore already differs from integration. TestSchemasMatchSpecification will fail the next time this PR's CI runs. The green checks shown now come from the old base.

Three more spec PRs touch the same files: #258 is open, #261 is open, and the #228 change is waiting on those two. Each one changes the copy again. Please rebase on integration, run cd reference-implementations/agt-go && go generate ./internal/schema, and push. If any of the three lands after you, I'll run the same command in that PR, so the order doesn't matter.

@rocklambros rocklambros 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, I went through the commits from 9 October, since every review on this thread so far was done on 5 October against 2b8ccf52. That means fee41d41 (proceed where DENY is not allowed) and 470dd21a (AGT through the C ABI) haven't had a review yet. I merged this branch onto current integration (185ef4e) and ran the Go tests there with Go 1.27.2. Here's what needs to change before it can merge.

  1. The embedded spec copy fails on the merged tree. TestSchemasMatchSpecification reports that trace/ocsf-mapping.json and trace/otel-mapping.json differ, because PR #63 changed both. This is the same ask as my earlier comment: rebase on integration, run cd reference-implementations/agt-go && go generate ./internal/schema, and push. After go generate, every package and go run ./cmd/docs-check passed for me.

  2. TestRecordingIsCurrent will go red on integration after every TypeScript Guardian merge. agtbridge/recorded_cases_test.go:139-145 compares the recording with git log -1 --format=%H for the TS paths. We squash-merge into integration, so even if a TS author re-records inside their own PR, the squash creates a new SHA and the test fails right after merge. I checked this by adding a no-op commit under agt/packages/guardian/src on the merged tree, and the test failed. Four open PRs touch that path today (PR #244, PR #246, PR #229, PR #112). Please record tree IDs instead of a commit, for example git rev-parse HEAD:reference-implementations/agt/policy for each path, both in scripts/record-ts-cases.sh and in the test. Tree IDs are content-addressed, so they survive a squash.

  3. opa_timeout can equal decision_timeout, and guardian.yaml ships both at 5s. AGT runs one opa process per decision and cannot be interrupted (agteval.go:279-281), so when OPA is slow the agent's 5s deadline expires before AGT's own fail-closed deny arrives. A deployment that negotiated proceed then runs the tool ungoverned. validate() in cmd/guardian/main.go already enforces write_timeout > decision_timeout at line 290. Please add the same kind of check for the agt evaluator (opa_timeout must be less than decision_timeout), set opa_timeout: 4s in guardian.yaml, and add a row to evaluator_test.go for it.

  4. At hooks where DENY is not allowed, a failed decision plus a failed audit write now proceeds with no record anywhere. That's in internal/hostadapter/client.go:155-157, and client_test.go asserts it as post_compact_audit_unwritten. ALLOW is the right answer at postCompact, but §6.4 says the bypass "MUST be recorded". Please print one JSON line to stderr when recordAuditEvent fails, and extend that test to check for it. In the same commit, the §6.3 row in docs/conformance.md still says "Met", even though an invalid MODIFY at those five hooks now gives ALLOW and not DENY. I think your reading is right, but it's an interpretation, so mark the row that way and add a line to docs/differences.md. I'll open a spec issue to put the postCompact exception into §6.3.

  5. The PR body describes the code from before 9 October. It says the Go Guardian uses the OPA Go library, but the default is now evaluator: agt through cgo, and EVALUATOR=go is the fallback. It also says five files sit outside the directory, but there are six (agt/README.md changed). The 279 tests and the 87.5% figures are old too. Reviewers judge the design from the body, so please update it.

These aren't blockers, but they'd be nice in this PR or a follow-up:

  • Add a test that agt/agt.lock and agt-native.lock name the same AGT ref. Right now each evaluator only checks its own lock file, so an AGT bump can leave the default evaluator behind.
  • scripts/check-coverage.sh leaves out agtbridge/agteval and internal/hostadapter, so the 80% floor measures the non-default path.
  • max_engine_calls: 256 made sense in-process. Under agt each slot is an OS process, so a lower default for agt would be safer.
  • refusalAt depends on the Decision lines in hooks.md, but method_test.go:50 only checks the count. A test that reads the Decision lines from docs/spec/instrument/hooks.md would catch a disposition change.

I also checked a few things that came out clean: AGT reads the OPA env vars once at build time, an unknown evaluator value fails validate(), and AGT's FFI wraps every call in catch_unwind.

@rocklambros

Copy link
Copy Markdown
Collaborator

@President, one more thing I forgot in the review, and it isn't a blocker. Once this merges, nothing will propose updates for the Go module. .github/dependabot.yml only covers github-actions and uv, and the Go workflow has no schedule:, so govulncheck runs only when someone touches the path. You found GO-2026-6605 and GO-2026-6617 by hand. Please add gomod entries for /reference-implementations/agt-go and /reference-implementations/agt-go/examples/custom-engine, using the same weekly schedule and cooldown: default-days: 7 as the existing entries. It's a .github/ change, so I'll approve that part.

dtduc-git added a commit to dtduc-git/agent-control-standard that referenced this pull request Oct 14, 2026
An independent implementation of the ACS v0.1.0 wire contract under
reference-implementations/agt-python/: JSON-RPC envelopes, handshake
negotiation, the hook taxonomy, the five dispositions, the SessionContext
chain with a published chain_hash, replay and skew protection, and the
HMAC-SHA256 baseline signature.

Wrapped MCP is not implemented yet, so the ServerHello answers
profiles_accepted: [] rather than claiming acs-core; the README's status
table says exactly which parts are implemented.

The suite validates envelopes against this repository's schemas by relative
path, pins the cross-implementation signature vectors from the open Go port
(GenAI-Security-Project#169), ports the conformance harness's ten external probes, and pins every
hook's schema to its method name. A path-scoped workflow runs it in CI.

Signed-off-by: Duke - Duc Dinh <dtduc.contact@gmail.com>
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
The embedded schemas follow integration after GenAI-Security-Project#63, GenAI-Security-Project#258 and GenAI-Security-Project#261.

The parity recording stores the git object IDs of the deciding TypeScript
paths instead of a commit, so a squash merge no longer makes
TestRecordingIsCurrent fail. The paths include agt.lock and the
agt-bridge package.json, which pin the AGT commit and SDK. The recorder
and the test read them from one list; the test fails when the recording
names other paths or was made from another corpus, and the recorder
refuses uncommitted changes to them. A test checks that the Go workflow
runs on every deciding path. agt/README.md tells a TypeScript contributor
when to re-record.

A test requires agt-native.lock and agt.lock to pin the same AGT repo
and commit. The native build fails when AGT at that commit changes
ffi.rs, which agteval/acs.h is written from, or fetches another opa than
agt-native.lock pins.

Under the agt evaluator, policy.opa_timeout must be shorter than
protocol.decision_timeout; guardian.yaml ships 4s. Otherwise a slow opa
lets the agent stop waiting before AGT's deny arrives (GenAI-Security-Project#269).

make load measures either evaluator under concurrent clients, selected
by EVALUATOR like the other targets. Under agt each decision runs one opa
process; on a 20-core host decisions stop rising
near 16 concurrent ones, and at 256 they reach the 5s timeout.
guardian.yaml now ships limits.max_engine_calls 32, and docs/load.md
records the results. The load run found that an engine call gave its slot
back only after its answer was sent, so a client's next step could be
refused engine_saturated with no call running; callEngine now releases
the slot first, also after a panic.

When the host adapter's audit log cannot be written, the record goes to
stderr as one JSON line with the final disposition, and the refusal's
reasoning says whether the record went to stderr or nowhere. The audit file's close error now
counts. The Codex and OpenCode hooks pass their own stderr. The §6.3 row
says that ALLOW at a hook that permits no DENY is an interpretation
(GenAI-Security-Project#270), and the docs say only the adapter library reaches those hooks.

Dependabot proposes Go updates, one pull request per dependency across
both modules. The Go workflow runs weekly, so govulncheck sees advisories
for the toolchain that Dependabot does not move, triggers on the
agt-bridge package.json, and no longer fetches full history. A test
checks that the method table permits DENY exactly where hooks.md does,
and fails on a Decision line it cannot classify.
The coverage floor counts every package except commands, examples and
test helpers.

The Guardian meets the expectations GenAI-Security-Project#256 sets for reference
implementations, mapped to tests in docs/conformance.md. audit.max_file_bytes
and audit.max_files rotate each audit file; guardian.yaml keeps at most
1 GiB per log; max_file_bytes needs max_files of at least 1, so a
rotation never deletes the live log. make load can set the rotation and
fails if the log drops, splits or reorders a record or exceeds its bound;
docs/load.md records a run and the cost of long sessions. storage.provider names the session store. The HMAC secret
file holds base64 of at least 32 bytes. key_id is a configured label with
no default, an unknown key_id is refused (GenAI-Security-Project#118, GenAI-Security-Project#272), and the hooks and
the conformance harness take it as a flag. A toolCallResult whose
request_id_ref matches no request is recorded (GenAI-Security-Project#272).

Decisions from other pull requests that the Go code contradicted:
- A shell command's URL ends only at ASCII whitespace; U+FEFF or U+00A0
  in the userinfo no longer hides the host curl reaches (GenAI-Security-Project#268).
- Versions are compatible when they share X.Y under 0.x; the handshake
  negotiates only such a version, and a step must match it (GenAI-Security-Project#196, GenAI-Security-Project#255).
- A policy target that is not a string is denied evaluation_failed; the
  TypeScript fix is in GenAI-Security-Project#229, so two recorded cases are listed as ahead of
  the recording until it is re-recorded.
- The host adapter records every decision failure under either posture,
  with its class, handshake failures included (GenAI-Security-Project#37).
- The host adapter denies under any posture a signed error other than
  -32603, an unverified answer and a malformed answer; the docs mark this
  as an interpretation of §6.4 ahead of GenAI-Security-Project#32, which is open. The version
  rule is marked the same way against §3.
- The audit directory is 0700; a symlinked audit path or an audit file
  open to group or others is refused (GenAI-Security-Project#71).

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

Copy link
Copy Markdown

This pull request's description says it closes #87, which is on the project roadmap. When it merges, GitHub closes that issue and the roadmap counts it as delivered. Please confirm the change delivers the whole issue before merging.

@President

Copy link
Copy Markdown
Author

@rocklambros thanks for the review! Everything is in commit e233a35. Answers in your order:

Schema copy is out of date

Review

  1. Schema copy: done, see above.
  2. TestRecordingIsCurrent breaks on squash merges: fixed. The test now compares the content of the TypeScript files that decide the answers, not a commit hash. A squash merge keeps the content, so the test passes. I re-recorded the 49 sessions; the answers did not change.
  3. opa_timeout equal to decision_timeout: fixed. The Guardian refuses to start under agt unless opa_timeout is shorter. guardian.yaml now uses 4s. Tests cover both cases.
  4. No audit record when the decision and the audit write both fail: fixed. The record now goes to stderr as one JSON line, with the final decision and the error. The §6.3 row in docs/conformance.md now says "one case by interpretation (§6.3 needs the same no-DENY hook exception that §6.5 has for postCompact #270)".
  5. PR description: updated.

Non-blockers:

  • AGT pinned in two lock files: a new test checks that both pin the same commit.
  • Coverage: now counts every package except commands, examples and test helpers. 85.9%.
  • max_engine_calls: I measured it with a new make load. At 256, slow answers reach the 5s timeout. At 32, all answers arrive within 1.5s. guardian.yaml now uses 32. Numbers are in docs/load.md. The load test also found a bug, fixed with a test: an engine slot was freed too late, so a step could be refused as busy when nothing was running.
  • refusalAt vs hooks.md: a new test reads the Decision lines in hooks.md and checks that our table allows DENY at the same hooks.
  • Thanks for checking the env vars, the evaluator validation and catch_unwind.

Dependabot

  • Added Go updates for both modules: weekly, 7-day cooldown.
  • Dependabot does not update Go itself, so the Go workflow also runs every Monday and govulncheck catches new Go advisories.
  • Removed fetch-depth: 0, nothing needs Git history now.

Also in this commit

@President
President requested a review from rocklambros October 15, 2026 02:46
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
- Regenerate the embedded schema copy and re-record the TypeScript answers
  after merging integration; GenAI-Security-Project#275 changed four schemas and the Guardian
  sources. No recorded answer changed.
- An empty string in an AGT verdict field is read as absent, as the
  TypeScript mapVerdict now does: no policy_references, no reason_codes,
  and a warn without a rule is an evaluation failure (GenAI-Security-Project#263).
- One test signer for the agtbridge tests instead of five copies.

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

Copy link
Copy Markdown
Author

@rocklambros one more commit after my reply: c6e9db2 - merged integration after #275 (schema copy regenerated, TS cases re-recorded, no answer changed), and an empty AGT reason now names no rule, same as #275 does in TS.

- The writer's queue is bounded by audit.max_queue_records and
  audit.max_queue_bytes (4096 records and 64 MiB in guardian.yaml), the
  record being written included. Before, only the record count was
  bounded: a stalled disk could hold 4096 request bodies of
  limits.max_body_bytes, about 4 GiB, and requests are recorded before
  their signature is checked. The library takes the limits as
  guardian.JSONLQueue, zero meaning the defaults. The command refuses a
  max_queue_bytes that cannot hold one request of max_body_bytes.
- Every record that does not reach the files is counted: dropped from a
  full queue, failed, discarded after a write failure, or offered after
  Close. A queue-full report no longer hides a later write failure, and
  the command returns the count on every exit path.
- A queued envelope no longer keeps the caller's body alive beside its
  own copy: it held a pointer into the record.
- A record dropped from a full queue keeps its seq, so a reader of the
  file sees the gap.
- shutdown takes separate decision and delivery grace periods; its test
  keeps a short decision grace and delivers within shutdown_grace.
- Remove agtbridge.test, a test binary committed by mistake, and ignore
  *.test. make check refuses a tracked binary file, a file over 1 MiB and
  a home-directory path.

Tests: queue bounds by bytes and records, retained heap against counted
bytes, losses add up under 8 concurrent producers, seq gaps, Close
timeout, configuration bounds. Each was seen failing on an injected
regression. make check passes, also merged with integration; pytest 919
passed; mkdocs --strict builds; the TypeScript suite 1237 passed.

Claude-Session: https://claude.ai/code/session_01NqVwNgkbimQeV7PRGx9Zpa
Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
Re-record the TypeScript answers: GenAI-Security-Project#289 changed the Guardian sources. No
recorded answer changed.

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

Copy link
Copy Markdown
Author

@rocklambros - one more commit, 6e02186:

  1. Same issue I raised in Batch Guardian log writes and drain them on shutdown #112, found in our own audit log: a stalled disk could hold ~4 GiB in the queue. Now it is bounded in bytes too (audit.max_queue_bytes), and every lost record is counted.
  2. Removes agtbridge.test - a test binary I committed by mistake. make check now refuses binary files.

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