ccguard

package
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jun 23, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

Documentation

Overview

Package ccguard turns a Legant delegation token into a policy decision for every tool call Claude Code makes — Read, Write, Edit, Bash, WebFetch and MCP tools alike — enforced OFFLINE from the signed token. It is the local-machine analog of the MCP auth-gateway: where the gateway governs remote MCP tools, the guard governs Claude Code's built-in tools (the file reads/writes and shell commands an agent runs locally).

It runs as a Claude Code PreToolUse hook: Claude Code writes the pending tool call to the hook's stdin as JSON, the guard authorizes it against a delegated "acting-for-Alice" token (scoped, time-boxed, attenuating across sub-agents, revocable via the signed feed), and writes a decision to stdout.

Two design invariants:

  • ADDITIVE DENIAL ONLY. The guard can only ever BLOCK a tool call (it exits with code 2 — the hard block Claude Code honors BEFORE permission evaluation, so it bites even in bypassPermissions/"YOLO" mode); on allow it exits 0 with no output and defers to Claude Code's own permission system. Installing the guard can only tighten what an agent may do, never loosen it. This mirrors Legant's revocation feed, which can miss a revoke but can never forge an authorization.
  • SHELL IS A COARSE GRANT. fs.read/fs.write (Read/Write/Edit) are truly path-contained. shell.exec (Bash) CANNOT be path-contained offline — an allow-listed `cat`/`sed`/`python3`/redirect can touch any path, and no command parser stops that soundly. For shell.exec the guarantees are the command allow/deny lists, the catastrophic-command tripwires, best-effort redirect containment, audit and revocation. A role that needs HARD filesystem containment must NOT grant shell.exec (use the reviewer role) or must run the agent in an OS sandbox. This is stated plainly, not hidden.

Index

Constants

View Source
const (
	EnvToken     = "LEGANT_GUARD_TOKEN"      // the delegation token (inline)
	EnvTokenFile = "LEGANT_GUARD_TOKEN_FILE" // …or a file to read it from
	EnvJWKS      = "LEGANT_GUARD_JWKS"       // path to the issuer JWKS (public keys)
	EnvIssuer    = "LEGANT_GUARD_ISSUER"     // expected token issuer
	EnvAudience  = "LEGANT_GUARD_AUDIENCE"   // the guard's resource-server identity
	EnvFeed      = "LEGANT_GUARD_FEED"       // signed revocation feed; configured-but-unloadable fails CLOSED
	EnvAudit     = "LEGANT_GUARD_AUDIT"      // path to append decision audit JSONL (optional)
	EnvOverlay   = "LEGANT_GUARD_OVERLAY"    // deny-only overlay file (default <guarddir>/overlay.json)
	// Connected mode: stream each decision to a running Legant server's live console.
	EnvLiveURL   = "LEGANT_GUARD_LIVE_URL"   // POST .../admin/live/ingest (optional)
	EnvLiveToken = "LEGANT_GUARD_LIVE_TOKEN" // shared ingest secret
	EnvSource    = "LEGANT_GUARD_SOURCE"     // label for the console (default "claude-code")
)

Environment variables that configure the guard hook. Providing a token is what switches the guard ON; with no token the hook is a no-op (it allows everything and defers entirely to Claude Code), so it is safe to leave installed.

View Source
const (
	DefaultIssuer   = "https://legant.local"
	DefaultAudience = "legant:claude-code"
)

Defaults for the offline/local setup produced by `legant guard init`.

View Source
const LocalKID = "legant-guard-local"

LocalKID names the local demo signing key written by InitLocal.

Variables

This section is empty.

Functions

func BuildJWKS

func BuildJWKS(kid string, pub *rsa.PublicKey) ([]byte, error)

BuildJWKS renders a single-key RSA JWKS document the guard (and the SDK) can verify against — the public half of the local demo key.

func BuildSignedFeed

func BuildSignedFeed(jtis []string, version int64, issuer, kid string, key *rsa.PrivateKey, ttl time.Duration, now time.Time) (string, error)

BuildSignedFeed mints a signed revocation feed over the given token ids. Used by `legant guard revoke` to publish a new kill-list signed with the same key the guard trusts. jtis are sorted for a stable, diffable output.

func BuiltinRoles

func BuiltinRoles(projectRoot string) map[string]Role

BuiltinRoles are ready-to-use roles. NOTE: shell.exec cannot be path-contained (see the package doc), so the only role with HARD filesystem containment is `reviewer` (no shell). `builder` grants shell but with a tight command allow-list (no interpreters/network tools) plus tripwires; `open` shows the "allow everything except" pattern; `operator` is the unrestricted escape hatch.

func DefaultGuardDir

func DefaultGuardDir(projectRoot string) string

DefaultGuardDir returns a per-project guard directory OUTSIDE the project, under the user's config dir — so the guard's key/feed/token are never inside a path root the tokens grant (which would let an agent tamper with its own leash).

func JTIOf

func JTIOf(token string) string

JTIOf reads the jti from a freshly-minted token without re-verifying it (the signature was just produced locally).

func LoadPrivateKey

func LoadPrivateKey(path string) (*rsa.PrivateKey, error)

LoadPrivateKey reads a PKCS#1 PEM private key written by SavePrivateKey.

func MintChildToken

func MintChildToken(dir, parentTokenFile, childAgent string, requestedScopes []string, ttl time.Duration, now time.Time) (string, error)

MintChildToken mints an ATTENUATED child token from a parent token — the offline analog of a Claude Code sub-agent receiving a narrower slice of its parent's authority. It verifies the parent under the local key, intersects the requested scopes against the parent's (a child can never widen), tightens constraints, extends the sub/act provenance chain with childAgent, and clamps expiry to the parent's. Requesting a scope the parent lacks yields an error — escalation is impossible by construction.

func MintGrant

func MintGrant(signer *delegation.Signer, g *delegation.Grant, audience string, now time.Time) (token, jti string, err error)

MintGrant signs a delegation token for a grant, bound to the guard audience. It uses IssueClaims (not IssueForGrant) precisely because the guard overloads the resource list for "path:"/"cmd:" rules rather than RFC 8707 audiences, so the audience must not be required to appear in that list. The full sub/act provenance of the chain is preserved, so a sub-agent token records its parents.

func MintRoleToken

func MintRoleToken(dir, roleName, user, agent, projectRoot string, ttl time.Duration, now time.Time) (string, error)

MintRoleToken mints a fresh token for a built-in role using the local key.

func OverlayPath

func OverlayPath(dir string) string

OverlayPath returns the overlay file location for a guard dir.

func RenderRule

func RenderRule(tokenStr string, keys map[string]*rsa.PublicKey, feed *SignedFeed, now time.Time) (string, error)

RenderRule decodes a delegation token and renders the rule it carries as a human-readable block: provenance, capabilities, allow/deny rules, time window, validity, and (if a feed is supplied) revocation status. Decoding never fails on an expired/invalid token — validity is shown as a status line instead.

func RevokeJTI

func RevokeJTI(dir, jti string, now time.Time) (int64, error)

RevokeJTI republishes the local feed with jti added, bumping the version. It preserves the ids already on the feed.

func RunDemo

func RunDemo(w io.Writer) error

RunDemo runs a self-contained, narrated scenario that drives Claude Code tool calls through the guard for two roles, a sub-agent, and a revocation — all in-memory, no database, no Claude Code required. Path checks are lexical, so the synthetic project root need not exist on disk.

go run ./cmd/legant guard demo

func RunUI

func RunUI(ctx context.Context, dir string, port int) error

RunUI starts a LOCAL control panel for inspecting the role rules and editing the deny overlay, and blocks until ctx is cancelled. It is hardened for a security tool: it binds 127.0.0.1 ONLY (never a routable address), gates every request with a random per-run token printed in the URL, and rejects non-loopback Host headers (anti DNS-rebinding) — so no other process or web page can read or change your rules. Overlay edits are deny-only and take effect on the next tool call, exactly like `legant guard deny`.

func SaveOverlay

func SaveOverlay(path string, o *Overlay) error

SaveOverlay writes a normalized (sorted, deduplicated) overlay JSON file.

func SavePrivateKey

func SavePrivateKey(path string, key *rsa.PrivateKey) error

SavePrivateKey writes an RSA private key as PKCS#1 PEM (local demo key only).

func ShowFromDir

func ShowFromDir(dir, tokenFile string, now time.Time) (string, error)

ShowFromDir loads the JWKS + revocation feed from a guard dir and renders the rule carried by tokenFile.

Types

type AuditRecord

type AuditRecord struct {
	Time       string `json:"ts"`
	Session    string `json:"session,omitempty"`
	Tool       string `json:"tool"`
	Verb       string `json:"verb"`
	Target     string `json:"target,omitempty"`
	Decision   string `json:"decision"` // "allow" | "deny"
	Reason     string `json:"reason,omitempty"`
	JTI        string `json:"jti,omitempty"`
	Provenance string `json:"provenance,omitempty"`
}

AuditRecord is one line of the guard's local decision log — the offline analog of the audit a server-side resource check would land in Legant's hash chain.

type CheckResult

type CheckResult struct {
	Block  bool
	Reason string
}

CheckResult is the outcome of one PreToolUse authorization. The caller turns a Block into the actual hook decision (a stderr reason + exit code 2 — the hard block honored even in bypassPermissions mode); an allow produces no output.

func RunCheck

func RunCheck(r io.Reader) (CheckResult, error)

RunCheck is the body of `legant guard check`: it reads one PreToolUse event from r, authorizes it, appends an audit line, and reports whether to block. With NO token configured the guard is off (allow). With a token configured but its trust material broken, it fails CLOSED (Block=true) — a broken or tampered guard must not silently stop enforcing. It returns an error only when it cannot read the hook event at all (the caller then exits non-blocking so a broken pipe never bricks a session).

type Config

type Config struct {
	Token    string                    // the delegation token (the agent's authority)
	Issuer   string                    // expected token issuer
	Audience string                    // the guard's own resource-server identity
	Keys     map[string]*rsa.PublicKey // issuer JWKS (verifies token + feed)
	Feed     *SignedFeed               // optional offline revocation feed
	// SelfProtect lists absolute paths of the guard's own files (token, JWKS,
	// feed, key, audit, .claude settings). The guard ALWAYS denies fs.read/fs.write
	// to them regardless of scope/roots, so an agent cannot tamper with its own
	// leash (roll back revocation, read the signing key, repoint the token).
	SelfProtect []string
	// DenyAll, when non-empty, makes every decision a block with this reason. Set
	// when a configured revocation feed could not be loaded (tamper/fail-closed).
	DenyAll string
	// Warn is a non-fatal advisory for stderr.
	Warn string
	// Overlay is an optional deny-only local restriction layer applied on top of
	// the token. It can only tighten; nil/empty changes nothing.
	Overlay *Overlay
}

Config holds everything the guard needs to make decisions, normally sourced from the LEGANT_GUARD_* environment by LoadConfigFromEnv.

func LoadConfigFromEnv

func LoadConfigFromEnv() (cfg Config, enabled bool, err error)

LoadConfigFromEnv builds a guard Config from the LEGANT_GUARD_* environment. The returned enabled flag is false when no token is configured: in that case the hook should allow everything (the guard is opt-in). An error means the guard is configured but its key material could not be loaded — a misconfiguration the caller surfaces without blocking the session.

type Decision

type Decision struct {
	Block  bool   // true → the tool call is denied
	Reason string // human-readable reason (also fed back to the agent on a block)
	Verb   string // the abstract capability the call required, e.g. "fs.write"
	Target string // the concrete object, e.g. an absolute path or a command
	JTI    string // the delegation token id, for audit
	Prov   string // provenance, e.g. "user:alice -> agent:claude -> agent:reviewer"
}

Decision is the guard's internal verdict for one tool call.

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard authorizes Claude Code tool calls against a single delegation token.

func NewGuard

func NewGuard(c Config) *Guard

NewGuard builds a guard from a config.

func (*Guard) Decide

func (g *Guard) Decide(in HookInput) Decision

Decide authorizes one Claude Code tool call, entirely offline. It fails CLOSED: any verification or parsing failure blocks the call.

type HookInput

type HookInput struct {
	HookEventName string          `json:"hook_event_name"`
	SessionID     string          `json:"session_id"`
	Cwd           string          `json:"cwd"`
	ToolName      string          `json:"tool_name"`
	ToolInput     json.RawMessage `json:"tool_input"`
}

HookInput is the PreToolUse event Claude Code writes to the hook's stdin. Only the fields the guard needs are modeled; unknown fields are ignored.

type InstallOptions

type InstallOptions struct {
	Dir       string   // guard setup dir (auto-created if absent); default per-project, outside the project
	Project   string   // project root the agent works in
	Role      string   // rule set to wire: reviewer|builder|open|operator (default open)
	Binary    string   // absolute path to the legant binary (default: this executable)
	Tools     []string // claude-code|codex|opencode; empty → auto-detect installed agents
	UserScope bool     // write user-global config where supported (codex/opencode)
}

InstallOptions configures `legant guard install`.

type InstallResult

type InstallResult struct {
	Tool string
	Path string
	Note string
}

InstallResult describes one config the installer wrote (or would remove).

func Install

func Install(opts InstallOptions) ([]InstallResult, error)

Install ensures a guard setup exists and wires the guard hook into each selected (or auto-detected) coding agent. It is idempotent: re-running replaces the Legant block rather than duplicating it, and never clobbers other settings in a shared config file.

func Uninstall

func Uninstall(project string) []InstallResult

Uninstall removes the Legant guard hook from every known config location (project and user scope) for the given project. Best-effort and idempotent.

type LocalSetup

type LocalSetup struct {
	Dir          string
	KeyPath      string
	JWKSPath     string
	FeedPath     string
	AuditPath    string
	Tokens       map[string]string // role -> token file path
	SettingsJSON string            // a ready .claude/settings.json hook block
}

LocalSetup is the set of files InitLocal produced and the wiring it suggests.

func InitLocal

func InitLocal(dir, projectRoot string, ttl time.Duration, now time.Time) (*LocalSetup, error)

InitLocal writes a self-contained, OFFLINE guard setup under dir: a local signing key, its JWKS, an empty signed revocation feed, and one token per built-in role for projectRoot. It returns the file layout plus a .claude/settings.json hook block (with the env wired inline) so the result can be dropped straight into a project. The private key is a LOCAL DEMO key — in a real deployment the token comes from a token-exchange against your Legant issuer and the JWKS/feed are the issuer's published endpoints.

type Overlay

type Overlay struct {
	DenyPaths []string `json:"deny_paths,omitempty"` // paths refused (in addition to the token's)
	DenyCmds  []string `json:"deny_cmds,omitempty"`  // shell executables refused
	DenyHosts []string `json:"deny_hosts,omitempty"` // net.fetch hosts refused
	DenyTools []string `json:"deny_tools,omitempty"` // tool names refused outright
}

Overlay is a LOCAL, deny-only restriction layer applied on top of the signed delegation token. It can ONLY add denials — never grant, widen, or re-enable anything — so it preserves the token-as-ceiling model: edit it freely from the CLI or a UI to tighten what an agent may do, and it can never exceed what the delegation already permits. An empty or missing overlay changes nothing, so installing this layer cannot regress existing behavior.

func LoadOverlay

func LoadOverlay(path string) (*Overlay, error)

LoadOverlay reads an overlay JSON file. A MISSING file is (nil, nil): no overlay, no error. A malformed file returns an error the caller treats as a non-fatal warning — the signed token still fully enforces regardless.

func (*Overlay) Add

func (o *Overlay) Add(paths, cmds, hosts, tools []string)

Add merges deny rules into the overlay (deduplicated on normalize).

func (*Overlay) Empty

func (o *Overlay) Empty() bool

Empty reports whether the overlay imposes no additional restriction.

func (*Overlay) Remove

func (o *Overlay) Remove(paths, cmds, hosts, tools []string)

Remove drops deny rules from the overlay. Because the overlay only ever holds ADDED denials, removing one merely un-does a local restriction — it can never grant anything the token denies, so it cannot widen past the ceiling.

type Role

type Role struct {
	Name        string
	Description string
	Scopes      []string // capability verbs: fs.read fs.write shell.exec net.fetch mcp.call agent.spawn (or "*")
	Tools       []string // optional explicit Claude Code tool allow-list
	PathRoots   []string // filesystem roots the agent may touch
	Commands    []string // shell executables the agent may run
	Hosts       []string // hosts the agent may fetch from
	DenyPaths   []string // paths refused even inside an allowed root
	DenyCmds    []string // commands refused even when shell.exec is granted
	DenyHosts   []string // hosts refused even within an allowed host
	DenyTools   []string // Claude Code tools refused outright
}

Role is a named bundle of capabilities for a Claude Code agent. Roles compile down to a Legant delegation grant: Scopes become the token's capability scopes; PathRoots / Commands / Hosts become "path:" / "cmd:" / "host:" ALLOW entries; and DenyPaths / DenyCommands / DenyHosts / DenyTools become "deny-*" entries that OVERRIDE allow — letting a role say "allow everything EXCEPT X".

func (Role) Grant

func (r Role) Grant(user, agent string, ttl time.Duration, now time.Time) *delegation.Grant

Grant turns a role into a root delegation grant: user delegates to agent.

type SignedFeed

type SignedFeed struct {
	// contains filtered or unexported fields
}

SignedFeed is a verified, in-memory snapshot of revoked token ids. It is read from a compact JWS (a local file for the offline guard, or the issuer's /.well-known/revoked in a server deployment) and verified under the same JWKS keys that verify tokens — so it introduces no new trust root, and a tampered or forged feed is rejected rather than trusted.

func LoadSignedFeedBytes

func LoadSignedFeedBytes(compact []byte, issuer string, keys map[string]*rsa.PublicKey) (*SignedFeed, error)

LoadSignedFeedBytes verifies a compact-JWS feed and returns its revoked set. The signature must be RS256 under a known kid, the issuer must match, and the feed must carry an expiry. Anyone can read the jti list, but only the issuer's key can produce a feed the guard will trust.

func LoadSignedFeedFile

func LoadSignedFeedFile(path, issuer string, keys map[string]*rsa.PublicKey) (*SignedFeed, error)

LoadSignedFeedFile reads and verifies a feed file. An empty path returns (nil, nil): no offline revocation source, so revocation falls back to the token's short TTL (Tier C). A present-but-unreadable or invalid file is an error the caller decides how to treat (the guard fails closed on it).

func (*SignedFeed) IsRevoked

func (f *SignedFeed) IsRevoked(jti string) bool

IsRevoked reports whether a token id is on the feed's kill-list.

func (*SignedFeed) RevokedJTIs

func (f *SignedFeed) RevokedJTIs() []string

RevokedJTIs returns the sorted token ids currently on the feed — used when republishing the feed to add another id without dropping the existing ones.

func (*SignedFeed) Version

func (f *SignedFeed) Version() int64

Version is the feed's monotonic version (verifiers reject a regress).

Jump to

Keyboard shortcuts

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