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
- func BuildJWKS(kid string, pub *rsa.PublicKey) ([]byte, error)
- func BuildSignedFeed(jtis []string, version int64, issuer, kid string, key *rsa.PrivateKey, ...) (string, error)
- func BuiltinRoles(projectRoot string) map[string]Role
- func DefaultGuardDir(projectRoot string) string
- func JTIOf(token string) string
- func LoadPrivateKey(path string) (*rsa.PrivateKey, error)
- func MintChildToken(dir, parentTokenFile, childAgent string, requestedScopes []string, ...) (string, error)
- func MintGrant(signer *delegation.Signer, g *delegation.Grant, audience string, now time.Time) (token, jti string, err error)
- func MintRoleToken(dir, roleName, user, agent, projectRoot string, ttl time.Duration, ...) (string, error)
- func OverlayPath(dir string) string
- func RenderRule(tokenStr string, keys map[string]*rsa.PublicKey, feed *SignedFeed, ...) (string, error)
- func RevokeJTI(dir, jti string, now time.Time) (int64, error)
- func RunDemo(w io.Writer) error
- func RunUI(ctx context.Context, dir string, port int) error
- func SaveOverlay(path string, o *Overlay) error
- func SavePrivateKey(path string, key *rsa.PrivateKey) error
- func ShowFromDir(dir, tokenFile string, now time.Time) (string, error)
- type AuditRecord
- type CheckResult
- type Config
- type Decision
- type Guard
- type HookInput
- type InstallOptions
- type InstallResult
- type LocalSetup
- type Overlay
- type Role
- type SignedFeed
Constants ¶
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.
const ( DefaultIssuer = "https://legant.local" DefaultAudience = "legant:claude-code" )
Defaults for the offline/local setup produced by `legant guard init`.
const LocalKID = "legant-guard-local"
LocalKID names the local demo signing key written by InitLocal.
Variables ¶
This section is empty.
Functions ¶
func BuildJWKS ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
RevokeJTI republishes the local feed with jti added, bumping the version. It preserves the ids already on the feed.
func RunDemo ¶
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 ¶
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 ¶
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).
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 ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
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.
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".
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 ¶
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).