agentguard

package
v0.138.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 18, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package agentguard refuses agent tool calls that would write into a canonical clone.

WB's Git hooks are the wrong layer for this. Every violation observed on 2026-08-27 — a `git checkout origin/main -- .` that staged 186 files, a `specscore` run that left a whole unlanded lesson untracked — happened without ever reaching a commit, so a pre-commit hook could not have seen them. The write is the damage. And when a pre-commit hook does fire, it can be walked around: the commit that survived that day was made with `git -c core.hooksPath=/dev/null commit`.

This package therefore runs one step earlier, from a Claude Code PreToolUse hook, and judges the tool call before the tool runs.

Fail open, without exception

The guard runs on every tool call of every agent on the machine. A guard that fails closed would stop the whole fleet on any WB bug, partial upgrade, or payload it has not seen before. Every unknown is therefore an allow: an unparseable payload, an unrecognised tool, a path that cannot be resolved, a shell construct the conservative scanner does not model, a panic. Callers must preserve that property — see Inspect.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func WriteDecision

func WriteDecision(out io.Writer, decision Decision) (bool, error)

WriteDecision emits the response Claude Code reads, and reports whether anything was written.

The decision travels as JSON on stdout with a zero exit status, never as exit code 2. Exit code 2 is Claude Code's other blocking channel, and WB already uses exit 2 for a usage error — so a WB too old to know this subcommand, or any mistyped invocation, would exit 2 and block every tool call on the machine with cobra's usage text as the reason. Carrying the decision in the document instead makes "WB said nothing" mean "allow", which is the only safe default for a guard on this path.

Types

type Decision

type Decision struct {
	// Deny is false for every allow, including every unknown.
	Deny bool
	// Reason is the message shown to the agent, empty unless Deny.
	Reason string
}

Decision is the guard's answer for one tool call.

func Inspect

func Inspect(call ToolCall, options Options) (decision Decision)

Inspect judges one tool call.

It never returns an error and never panics: a recovered panic is an allow, because a guard that runs before every tool call of every agent must not be able to take the machine down with it.

type Kind

type Kind string

Kind classifies the Git checkout that encloses a path.

const (
	// KindUnknown means no enclosing Git checkout was found, or the answer
	// could not be established. It is always allowed.
	KindUnknown Kind = "unknown"
	// KindCanonical is a primary checkout sitting exactly at
	// <projects-root>/{host}/<owner>/<repository>, or at the legacy
	// <projects-root>/<owner>/<repository>: a WB canonical clone, which must
	// stay clean because every linked worktree in the fleet is cut from it.
	KindCanonical Kind = "canonical"
	// KindLinked is a linked Git worktree — its `.git` is a file pointing at
	// the canonical clone's common directory. This is where work belongs, so
	// it is always allowed.
	KindLinked Kind = "linked"
	// KindForeign is a primary checkout somewhere other than the managed
	// <projects-root>/<owner>/<repository> shape: a scratch clone, a test
	// fixture, a vendored repository. WB has no policy over it, so it is
	// allowed.
	KindForeign Kind = "foreign"
)

type Location

type Location struct {
	Kind Kind
	// Root is the enclosing checkout's root directory, empty for KindUnknown.
	Root string
	// Host, Owner and Repository are set only for KindCanonical. Host is the
	// literal forge hostname the clone sits under, and is empty for the legacy
	// two-level <projects-root>/<owner>/<repository> placement.
	Host       string
	Owner      string
	Repository string
}

Location is what Classify resolved about a path.

func Classify

func Classify(projectsRoot, path string) Location

Classify names the Git checkout that encloses path, using nothing but filesystem metadata.

It deliberately spawns no process. `git rev-parse --git-dir` would answer the same question authoritatively, but this runs ahead of every tool call of every agent, and a fork+exec per call is a cost the whole fleet pays all day. The distinction Git itself draws is visible in a single lstat: a primary checkout's `.git` is a directory, a linked worktree's `.git` is a regular file holding a `gitdir:` pointer.

The innermost enclosing checkout wins. That is what makes a worktree nested inside a canonical clone — Claude Code's own `.claude/worktrees/<name>`, for instance — classify as linked and stay writable, even though it is physically below a canonical clone root.

path need not exist: a Write creating a new file names a path that does not exist yet. Only its ancestors are consulted.

func (Location) Slug

func (l Location) Slug() string

Slug returns owner/repository for a canonical clone, or "".

type Options

type Options struct {
	// ProjectsRoot is the directory holding {owner}/{repository} canonical
	// clones.
	ProjectsRoot string
}

Options configures Inspect.

type ToolCall

type ToolCall struct {
	HookEventName string          `json:"hook_event_name"`
	ToolName      string          `json:"tool_name"`
	CWD           string          `json:"cwd"`
	ToolInput     json.RawMessage `json:"tool_input"`
}

ToolCall is the part of a Claude Code PreToolUse payload this guard reads.

Every field is optional on purpose. The payload schema belongs to Claude Code, not to WB, and a WB that refuses a payload it does not fully recognise would block the whole fleet the first time a field is added. Unknown fields are ignored and missing fields resolve to an allow.

func DecodeToolCall

func DecodeToolCall(reader io.Reader) ToolCall

DecodeToolCall reads a PreToolUse payload. A payload that is not valid JSON, or not an object, yields an empty ToolCall — which Inspect allows.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL