Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d4462e2
feat: add Go ACS Guardian reference implementation
President Sep 21, 2026
12ebf90
Keep the checkout token out of .git/config and correct the setup-bun …
President Sep 23, 2026
158243d
Reorganise the Go Guardian docs so the architecture can be reviewed b…
President Sep 24, 2026
1afb339
Merge upstream/integration into refimpl/go-port
President Oct 5, 2026
2b8ccf5
Bring the Go Guardian in line with the merged ACS-Core floor (#21)
President Oct 5, 2026
fee41d4
Proceed instead of denying where the hook permits no DENY
President Oct 9, 2026
470dd21
Use AGT's runtime library in the Go Guardian
President Oct 9, 2026
23832ed
Update Go and its verification tools
President Oct 9, 2026
90d0eff
Merge remote-tracking branch 'upstream/integration' into refimpl/go-port
President Oct 9, 2026
3296e15
Run the Go gate without a Windows runner
President Oct 9, 2026
69b69e5
Show the failing test in the coverage check, and do not claim Windows…
President Oct 9, 2026
41d46bc
Merge remote-tracking branch 'upstream/integration' into refimpl/go-port
President Oct 14, 2026
e233a35
Answer the 12 October review of the Go Guardian
President Oct 14, 2026
8f31dbd
Merge remote-tracking branch 'upstream/integration' into refimpl/go-port
President Oct 15, 2026
c6e9db2
Follow #275: an empty AGT reason names no rule
President Oct 15, 2026
6e02186
Bound the audit log's queue in bytes and count every record it loses
President Oct 17, 2026
57a046f
Merge remote-tracking branch 'upstream/integration' into refimpl/go-port
President Oct 17, 2026
4d44c5b
Merge remote-tracking branch 'upstream/integration' into refimpl/go-port
President Oct 21, 2026
be57248
OpenCode plugin: no default key_id; Go 1.27.3
President Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,30 @@ updates:
update-types:
- "minor"
- "patch"

# Go modules of the Go reference Guardian, so module updates arrive without
# anyone touching the tree. The weekly run of reference-implementation-go.yml
# covers advisories for the Go toolchain, which Dependabot does not move.
- package-ecosystem: "gomod"
directories:
- "/reference-implementations/agt-go"
- "/reference-implementations/agt-go/examples/custom-engine"
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 7
open-pull-requests-limit: 5
commit-message:
prefix: "deps"
labels:
- "dependencies"
- "go"
groups:
# The two modules share dependencies, and make check fails while one
# go.mod is ahead of the other, so one pull request moves a dependency
# in both directories.
go-modules:
patterns:
- "*"
group-by: dependency-name
127 changes: 127 additions & 0 deletions .github/workflows/reference-implementation-go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Builds and tests the Go port of the reference implementation.
#
# Scoped by path, like reference-implementation.yml, and separate from it so a Go
# change does not pay for a Bun install. specification/ is in the path list because
# the module embeds a copy of the schemas and a test fails when the copy stops
# matching them. The AGT bundle and mapping are in it because the Go Guardian reads
# them from reference-implementations/agt/. The shared conformance package is
# included because make check runs it against the Go Guardian.
#
# Native platforms run the complete gate. The pure job also checks cgo is absent.
#
# No workflow expression appears inside a `run:` block.
name: Reference implementation (Go)

on:
pull_request:
paths:
- "reference-implementations/agt-go/**"
- "reference-implementations/agt/policy/**"
- "reference-implementations/agt/mapping.yaml"
- "reference-implementations/agt/agt.lock"
- "reference-implementations/agt/packages/guardian/src/**"
- "reference-implementations/agt/packages/agt-bridge/src/**"
- "reference-implementations/agt/packages/agt-bridge/package.json"
- "reference-implementations/agt/packages/conformance/**"
- "reference-implementations/agt/package.json"
- "reference-implementations/agt/bun.lock"
- "specification/**"
- "docs/spec/**"
- ".github/workflows/reference-implementation-go.yml"
push:
branches: ["main", "integration"]
paths:
- "reference-implementations/agt-go/**"
- "reference-implementations/agt/policy/**"
- "reference-implementations/agt/mapping.yaml"
- "reference-implementations/agt/agt.lock"
- "reference-implementations/agt/packages/guardian/src/**"
- "reference-implementations/agt/packages/agt-bridge/src/**"
- "reference-implementations/agt/packages/agt-bridge/package.json"
- "reference-implementations/agt/packages/conformance/**"
- "reference-implementations/agt/package.json"
- "reference-implementations/agt/bun.lock"
- "specification/**"
- "docs/spec/**"
- ".github/workflows/reference-implementation-go.yml"
schedule:
# Weekly even when no Go file changed, because govulncheck compares the pinned Go
# toolchain and modules against advisories published after the pin, and Dependabot
# does not move the toolchain.
- cron: "37 6 * * 1"
workflow_dispatch:

permissions: {}

concurrency:
group: reference-implementation-go-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
agt-go:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15-intel, macos-15]
runs-on: ${{ matrix.os }}
permissions:
contents: read
defaults:
run:
working-directory: reference-implementations/agt-go
shell: bash
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Nothing here pushes with git, so the checkout's token has no reason to
# stay behind in .git/config.
persist-credentials: false

- name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: reference-implementations/agt-go/go.mod
cache-dependency-path: reference-implementations/agt-go/go.sum

- name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: "1.3.11"

- name: Prepare the Go Guardian quality gate
run: make setup

- name: Run the Go Guardian quality gate
run: make check

- name: Check the Linux container
if: runner.os == 'Linux'
run: make image PLATFORMS=linux/$(go env GOHOSTARCH) IMAGE_OUTPUT=type=docker && make image-check

agt-go-pure:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: reference-implementations/agt-go
env:
CGO_ENABLED: "0"
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: reference-implementations/agt-go/go.mod
cache-dependency-path: reference-implementations/agt-go/go.sum

- name: Build without cgo
run: go build ./...

- name: Test without cgo
run: go test ./...
2 changes: 2 additions & 0 deletions reference-implementations/agt-go/.codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[features]
hooks = true
16 changes: 16 additions & 0 deletions reference-implementations/agt-go/.codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": ".acs/bin/acs-codex-hook --hmac-key-id 2026-10 --hmac-secret-file .acs/hmac-secret --state-dir .acs/codex --audit-log .acs/codex-audit.jsonl",
"statusMessage": "Checking Bash with ACS"
}
]
}
]
}
}
4 changes: 4 additions & 0 deletions reference-implementations/agt-go/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/examples/custom-engine/custom-engine
/cover.out
.acs/
*.test
186 changes: 186 additions & 0 deletions reference-implementations/agt-go/.opencode/plugins/acs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"

type Reply = {
decision: "allow" | "deny" | "modify" | "ask" | "defer"
reasoning?: string
updated_input?: unknown
updated_result?: unknown
}

type BeforeEvent = {
readonly sessionID: string
readonly messageID: string
readonly id: string
readonly tool: string
input: unknown
}

type ToolResult = {
content?: string | ReadonlyArray<{ type: string; [key: string]: unknown }>
metadata?: Readonly<Record<string, unknown>>
output?: unknown
}

const isToolResult = (value: unknown): value is ToolResult => {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
const candidate = value as { content?: unknown; metadata?: unknown }
if (candidate.content !== undefined && typeof candidate.content !== "string") {
if (!Array.isArray(candidate.content) || candidate.content.some((block) => block === null || typeof block !== "object" || typeof (block as { type?: unknown }).type !== "string")) return false
}
return candidate.metadata === undefined || (candidate.metadata !== null && typeof candidate.metadata === "object" && !Array.isArray(candidate.metadata))
}

type AfterEvent = {
readonly sessionID: string
readonly messageID: string
readonly id: string
readonly tool: string
readonly input: unknown
} & (
| { readonly status: "completed"; result: ToolResult }
| { readonly status: "error"; error: { message: string; error?: unknown; metadata?: Record<string, unknown> } }
)

interface PluginContext {
tool: {
hook(name: "execute.before", callback: (event: BeforeEvent) => Promise<void>): Promise<unknown>
hook(name: "execute.after", callback: (event: AfterEvent) => Promise<void>): Promise<unknown>
}
}

const projectRoot = fileURLToPath(new URL("../../", import.meta.url))
const executable = fileURLToPath(new URL("../../.acs/bin/acs-opencode-hook", import.meta.url))
const hmacKeyID = process.env.ACS_OPENCODE_HMAC_KEY_ID ?? ""
const hookArguments = [
"--guardian-url",
process.env.ACS_GUARDIAN_URL || "http://127.0.0.1:8787/acs",
"--hmac-key-id",
hmacKeyID,
"--hmac-secret-file",
process.env.ACS_OPENCODE_HMAC_SECRET_FILE || ".acs/hmac-secret",
"--state-dir",
".acs/opencode",
"--audit-log",
".acs/opencode-audit.jsonl",
]

// A missing key_id blocks every tool call here rather than failing setup:
// OpenCode (checked on 2.0.11) logs a plugin whose setup throws and runs tools without it.
const invoke = (event: unknown): Promise<Reply> =>
new Promise((resolve, reject) => {
if (hmacKeyID === "") {
reject(new Error("ACS_OPENCODE_HMAC_KEY_ID must name the key_id the deployment gave the HMAC secret"))
return
}
const child = spawn(executable, hookArguments, {
cwd: projectRoot,
stdio: ["pipe", "pipe", "pipe"],
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let size = 0
child.stdout.on("data", (chunk: Buffer) => {
size += chunk.length
if (size > 1 << 20) {
child.kill("SIGKILL")
reject(new Error("ACS policy adapter returned too much data"))
return
}
stdout.push(chunk)
})
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk))
child.on("error", reject)
child.on("close", (status) => {
if (status !== 0) {
reject(new Error(Buffer.concat(stderr).toString("utf8").trim() || `ACS policy adapter exited ${status}`))
return
}
try {
resolve(JSON.parse(Buffer.concat(stdout).toString("utf8")) as Reply)
} catch {
reject(new Error("ACS policy adapter returned invalid JSON"))
}
})
child.stdin.end(JSON.stringify(event))
})

const blocks = (reply: Reply) => reply.decision !== "allow" && reply.decision !== "modify"

const blockedResult = (reply: Reply) => ({
content: reply.reasoning || `Guardian returned ${reply.decision}`,
metadata: { acs: { decision: reply.decision } },
})

const blockedError = (reply: Reply) => ({
message: reply.reasoning || `Guardian returned ${reply.decision}`,
metadata: { acs: { decision: reply.decision } },
})

const modifiedError = (value: unknown): { message: string; error?: unknown; metadata?: Record<string, unknown> } | undefined => {
if (value === null || typeof value !== "object") return undefined
const candidate = value as { message?: unknown; error?: unknown; metadata?: unknown }
if (typeof candidate.message !== "string") return undefined
if (candidate.metadata !== undefined && (candidate.metadata === null || typeof candidate.metadata !== "object" || Array.isArray(candidate.metadata))) return undefined
return {
message: candidate.message,
...(candidate.error === undefined ? {} : { error: candidate.error }),
...(candidate.metadata === undefined ? {} : { metadata: candidate.metadata as Record<string, unknown> }),
}
}

export const applyAfterReply = (event: AfterEvent, reply: Reply): void => {
if (event.status === "completed") {
if (blocks(reply)) {
event.result = blockedResult(reply)
} else if (reply.decision === "modify") {
event.result = !isToolResult(reply.updated_result)
? blockedResult({ decision: "deny", reasoning: "The Guardian modification could not be applied" })
: reply.updated_result
}
return
}
if (blocks(reply)) {
event.error = blockedError(reply)
return
}
if (reply.decision === "modify") {
event.error = modifiedError(reply.updated_result) ?? blockedError({
decision: "deny",
reasoning: "The Guardian modification could not be applied",
})
}
}

export default {
id: "acs",
setup: async (ctx: PluginContext) => {
await ctx.tool.hook("execute.before", async (event) => {
const reply = await invoke({
event: "execute.before",
session_id: event.sessionID,
message_id: event.messageID,
call_id: event.id,
tool: event.tool,
input: event.input,
})
if (blocks(reply)) throw new Error(reply.reasoning || `Guardian returned ${reply.decision}`)
if (reply.decision === "modify") event.input = reply.updated_input
})

await ctx.tool.hook("execute.after", async (event) => {
const reply = await invoke({
event: "execute.after",
session_id: event.sessionID,
message_id: event.messageID,
call_id: event.id,
tool: event.tool,
input: event.input,
status: event.status,
result: event.status === "completed" ? event.result : undefined,
error: event.status === "error" ? { message: event.error.message, error: event.error.error, metadata: event.error.metadata } : undefined,
})
applyAfterReply(event, reply)
})
},
}
Loading
Loading