Help us shape the future of Go! Take the 2026 Go Developer Survey

hooks

package
v2.0.0 Latest Latest
Warning

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

Go to latest
Published: Oct 9, 2026 License: MIT Imports: 26 Imported by: 0

Documentation

Overview

Package hooks loads, trusts and runs Celeste's lifecycle hooks (2.0 F0).

Hooks come from ~/.celeste/hooks.json (global, trusted), .celeste/hooks.json files in the workspace and its ancestors (repo-local, untrusted until the user approves them), and grimoire "## Hooks" sections, which are converted on load to protocol v1.

Index

Constants

View Source
const (
	// ProtocolV1 is a grimoire "## Hooks" entry: exit 0 allows, anything
	// else blocks with the output as the reason.
	ProtocolV1 = "v1"
	// ProtocolV2 reads one JSON object from stdout.
	ProtocolV2 = "v2"
	// DefaultTimeout and MaxTimeout are in seconds.
	DefaultTimeout = 30
	MaxTimeout     = 600
)

Variables

Events lists every event.

Functions

func CheckRepoGrimoire

func CheckRepoGrimoire(path string) error

CheckRepoGrimoire refuses a repo grimoire file the way Discover (and so `celeste hooks trust`) does: a symlinked file, or one reached through a symlinked directory under its project root, is never trusted or asked about.

func CheckRepoSandbox

func CheckRepoSandbox(configPath string) error

CheckRepoSandbox refuses a workspace config that is a symlink or sits in a symlinked .celeste directory, as repo hook files are refused: such a file is never trusted or asked about.

func Decide

func Decide(store *TrustStore, src Source, approve ApproveFunc) (run bool, why string, err error)

Decide is the one trust step every repo source passes (hooks, stream rules, sandbox settings, MCP servers): run reports whether src may run, why says why not ("declined", "not trusted", ...). A Trusted source runs and a Declined one does not, without asking; any other is put to approve (nil in non-interactive runs: it then never runs), and a yes or a no is recorded. err is a failure to record the answer: a yes then holds for this session only, a no still skips the source.

func DescribeSource

func DescribeSource(w io.Writer, src Source)

DescribeSource prints a source's hooks as the user must see them before approving. Every string from the file is shown Go-quoted, so control and bidi characters appear as escapes instead of acting on the terminal.

func DisabledWarning

func DisabledWarning(err error) string

DisabledWarning is the one warning every adopter shows when Load fails.

func Hash

func Hash(defs []Definition) string

Hash is the SHA-256 of the normalized definitions: exactly what a user approves. Formatting, key order and the rest of a grimoire don't change it.

func IsTerminal

func IsTerminal(f *os.File) bool

IsTerminal reports whether f is an interactive terminal. Callers require it for both the input and the output of a prompt. Character devices such as /dev/null and NUL are not accepted; use `celeste hooks trust` there.

func MCPServerName

func MCPServerName(src Source) string

MCPServerName is the server name of a KindRepoMCP source.

func SafeText

func SafeText(s string) string

SafeText returns s, or s Go-quoted when showing it could act on or hide text on a terminal. It is termsafe.Line, the one rule for one-line untrusted text celeste shows: hook errors and reasons, the approval prompt and `celeste mcp list`.

func SourceFile

func SourceFile(src Source) string

SourceFile is the file a trust source was read from: its path without the "#stream-rules", "#sandbox" or "#mcp:<name>" suffix that keeps it apart from other trust entries for the same file.

func StopContinuation

func StopContinuation(out Outcome, continued bool, turnsLeft int, s StopScope) (instr, warning string)

StopContinuation is the rule the chat, MCP chat and the agent share for a Stop or SubagentStop outcome: a deny continues the work once per unit and only while turns remain; a later deny, or one with no turns left, is reported as a warning and ignored, so a hook cannot keep the work going to the turn cap. instr is what to continue with (the hook's reason, or "Continue." when it gave none), or "" to finish; warning is "" unless a deny was ignored.

func TrustPath

func TrustPath(home string) string

TrustPath is where approvals are stored.

func WithWarn

func WithWarn(ctx context.Context, warn func(string)) context.Context

WithWarn returns ctx carrying warn as the sink for warnings raised by hooks run under it. A Runner shared by concurrent callers (MCP chat's cached Env) sends each caller's hook failures to that caller, not to the sink it was loaded with. A nil warn leaves ctx unchanged.

Types

type Answer

type Answer int

Answer is a person's reply to a trust question.

const (
	// AnswerLater is no answer (EOF, a cancelled prompt, a question queued
	// for later): the source does not run and nothing is recorded.
	AnswerLater Answer = iota
	// AnswerYes approves the source; the approval is recorded.
	AnswerYes
	// AnswerNo declines it; the decline is recorded, so it is not asked
	// about again until it changes (#411).
	AnswerNo
)

type ApproveFunc

type ApproveFunc func(src Source, status TrustStatus) Answer

ApproveFunc asks a person whether an untrusted source may run. Only interactive callers supply one. It is never asked about a Trusted or Declined source.

func PromptApprover

func PromptApprover(in io.Reader, out io.Writer) ApproveFunc

PromptApprover asks on out and reads a y/N answer from in. y or yes approves; anything else on the line (Enter included) declines, and the decline is remembered. EOF is no answer (AnswerLater), also after a partial line other than y or yes.

func PromptApproverNoRemember

func PromptApproverNoRemember(in io.Reader, out io.Writer) ApproveFunc

PromptApproverNoRemember is PromptApprover for a caller that records only a yes (`celeste hooks trust`): the prompt says a no leaves the stored decision as it was instead of claiming it is remembered.

type Decision

type Decision string

Decision is a hook's verdict.

const (
	Allow Decision = "allow"
	Deny  Decision = "deny"
	Ask   Decision = "ask"
)

type Definition

type Definition struct {
	Event    Event  `json:"event"`
	Matcher  string `json:"matcher,omitempty"`
	Command  string `json:"command"`
	Timeout  int    `json:"timeout,omitempty"`
	Protocol string `json:"protocol,omitempty"`
}

Definition is one hook as written in hooks.json.

func FromGrimoire

func FromGrimoire(entries []grimoire.HookEntry) (defs []Definition, skipped []string)

FromGrimoire converts grimoire "## Hooks" entries to protocol v1 definitions. Phases other than PreToolUse and PostToolUse never ran in 1.x; they are reported in skipped, not converted.

func ParseFile

func ParseFile(data []byte) ([]Definition, error)

ParseFile parses a hooks.json document. An unknown field or any invalid entry rejects the whole file, so a typo never silently drops a guard.

type Event

type Event string

Event names a point in a session where hooks run.

const (
	EventPreToolUse       Event = "PreToolUse"
	EventPostToolUse      Event = "PostToolUse"
	EventSessionStart     Event = "SessionStart"
	EventUserPromptSubmit Event = "UserPromptSubmit"
	EventPreCompact       Event = "PreCompact"
	EventPostCompact      Event = "PostCompact"
	EventStop             Event = "Stop"
	EventSubagentStop     Event = "SubagentStop"
)

type Options

type Options struct {
	Workspace string
	Home      string // "" = os.UserHomeDir()
	SessionID string
	// Approve is nil in non-interactive modes: untrusted sources are then
	// skipped with a warning and never approved.
	Approve ApproveFunc
	Warn    func(string) // nil = stderr
}

Options configures Load.

type Outcome

type Outcome struct {
	Decision          Decision       // Allow when no hook matched
	Reason            string         // as the hook wrote it: escape it to show it on a terminal
	AdditionalContext string         // returned to the model
	UpdatedInput      map[string]any // PreToolUse only; nil = unchanged
}

Outcome is the combined verdict of every hook that ran for one event.

type Runner

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

Runner runs a session's hooks. It is immutable after Load and safe for concurrent use. A nil *Runner allows everything.

func Load

func Load(opts Options) (*Runner, error)

Load discovers the workspace's hooks and keeps the ones allowed to run: global sources always, repo sources when trusted or approved now. On error, callers must warn the user and treat hooks as disabled; global guards are not running in that state.

func (*Runner) Has

func (r *Runner) Has(ev Event) bool

Has reports whether any loaded hook listens for ev.

func (*Runner) PostCompact

func (r *Runner) PostCompact(ctx context.Context, trigger, summary string) Outcome

func (*Runner) PostToolUse

func (r *Runner) PostToolUse(ctx context.Context, tool string, input map[string]any, res ToolResponse) Outcome

func (*Runner) PreCompact

func (r *Runner) PreCompact(ctx context.Context, trigger, instructions string) Outcome

PreCompact: trigger is "manual" or "auto"; anything but Allow blocks the compaction; AdditionalContext is extra summary instructions.

func (*Runner) PreToolUse

func (r *Runner) PreToolUse(ctx context.Context, tool string, input map[string]any) Outcome

func (*Runner) SessionStart

func (r *Runner) SessionStart(ctx context.Context, source string) Outcome

SessionStart: source is "startup" or "resume".

func (*Runner) Stop

func (r *Runner) Stop(ctx context.Context, lastMessage string) Outcome

Stop: Deny means "don't stop; continue with Reason" (acted on by F2).

func (*Runner) SubagentStop

func (r *Runner) SubagentStop(ctx context.Context, agentID, lastMessage string) Outcome

SubagentStop: same rule as Stop, for a subagent's final message.

func (*Runner) ToolHooks

func (r *Runner) ToolHooks() tools.HookRunner

ToolHooks adapts r for tools.Registry.SetHookRunner. It is nil when no PreToolUse or PostToolUse hook is loaded, so the registry skips hook work.

func (*Runner) UserPromptSubmit

func (r *Runner) UserPromptSubmit(ctx context.Context, prompt string) Outcome

UserPromptSubmit: any Decision other than Allow blocks the prompt.

type Source

type Source struct {
	// Path is the absolute path where the file was found, symlinks not
	// resolved. It is the trust key, so an approval never transfers to a
	// different location.
	Path string
	// Root is the project root the hooks run in: the directory holding
	// .celeste/, or the grimoire's directory. "" for global sources, which
	// run in the workspace.
	Root  string
	Kind  SourceKind
	Hooks []Definition // normalized
	Hash  string       // Hash(Hooks); for KindRepoStreamRules, the hash of Rules
	// Rules is the "## Stream Rules" text of a KindRepoStreamRules source,
	// shown for review before it is trusted.
	Rules string
	// contains filtered or unexported fields
}

Source is one file that defines hooks.

func Discover

func Discover(workspace, home string) ([]Source, []string, error)

Discover finds every hook source for workspace, in run order: ~/.celeste/hooks.json, ~/.celeste/grimoire.md, .celeste/hooks.json files from the filesystem root down to workspace, then every other grimoire file in grimoire priority order. A file that fails to read or parse (including a symlinked repo file or one over 1 MiB) is skipped with a warning. Sources that define no hooks are left out.

func MCPSource

func MCPSource(configPath, name, summary, hash string) Source

MCPSource is the trust source for server name in the workspace MCP config at configPath: keyed by the file's path plus "#mcp:<name>", pinned to hash (mcp.ServerConfig.TrustHash), described by summary (mcp.ServerConfig.TrustSummary).

func SandboxSource

func SandboxSource(configPath, body string) Source

SandboxSource is the trust source for one workspace config's "sandbox" object: keyed by the file's path plus "#sandbox", hashed over body (the object's canonical JSON), so any edit asks again.

func SourcesAt

func SourcesAt(target, home string) ([]Source, []string, error)

SourcesAt returns what `celeste hooks trust` acts on: everything Discover finds when target is a directory, or the one file when it is a file Discover would load. Anything else is an error.

func StreamRulesSource

func StreamRulesSource(grimoirePath, body string) Source

StreamRulesSource is the trust source for one grimoire file's "## Stream Rules" section: keyed by the file's path plus "#stream-rules", hashed over the section text, so any edit asks again.

func (Source) Global

func (s Source) Global() bool

Global reports whether the source is the user's own file under ~/.celeste, trusted without approval.

type SourceKind

type SourceKind string

SourceKind says where a hook source lives, which decides whether it is trusted without approval.

const (
	KindGlobal         SourceKind = "global"          // ~/.celeste/hooks.json
	KindGlobalGrimoire SourceKind = "global-grimoire" // ~/.celeste/grimoire.md (v1)
	KindRepo           SourceKind = "repo"            // .celeste/hooks.json in the workspace or an ancestor
	KindRepoGrimoire   SourceKind = "repo-grimoire"   // any other grimoire file (v1)
)
const KindRepoMCP SourceKind = "repo-mcp"

KindRepoMCP is one server in a workspace's .mcp.json or .celeste/mcp.json. An enabled one would start its command (or connect to its URL) when the chat launches, so it is trusted like a repo hook: by a hash of what it runs, in the same store, with the same `celeste hooks trust` flow.

const KindRepoSandbox SourceKind = "repo-sandbox"

KindRepoSandbox is the "sandbox" object of a workspace's .celeste/config.json (2.0 W4). It runs nothing, but its loosening ("enabled": false, "network": true, extra writable paths) lets every bash command write or connect where the sandbox would stop it, so it is trusted like a repo hook: by content hash, in the same store, with the same `celeste hooks trust` flow.

const KindRepoStreamRules SourceKind = "repo-stream-rules"

KindRepoStreamRules is a repo grimoire's "## Stream Rules" section (2.0 W3). It runs nothing, but its rules can stop replies, force paid re-runs and add reminders the model treats as authoritative, so it is trusted like a repo hook: by content hash, in the same store, with the same `celeste hooks trust` flow. Load never runs or warns about it; the rules package checks its trust (rules.Trusted).

type StopScope

type StopScope struct {
	Event, Actor, Unit string
}

StopScope names, for StopContinuation's warnings, the event that fired ("Stop" or "SubagentStop"), who it asked to continue ("the chat", "the agent") and the unit a continuation is counted per ("turn", "call", "run").

type ToolResponse

type ToolResponse struct {
	Content string
	Error   bool
}

ToolResponse is a tool's result as PostToolUse hooks see it.

type TrustStatus

type TrustStatus int

TrustStatus is whether a source may run without asking.

const (
	Untrusted       TrustStatus = iota // never approved or declined
	Trusted                            // global, or approved with this exact hash
	Changed                            // approved before, but its hooks changed since
	Declined                           // declined with this exact hash: never runs, never asked again
	DeclinedChanged                    // declined before, but it changed since: asked again
)

func (TrustStatus) String

func (s TrustStatus) String() string

type TrustStore

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

TrustStore is ~/.celeste/trusted.json: approved and declined repo hook sources, keyed by canonical path, pinned to the hash of the definitions the person saw.

func LoadTrust

func LoadTrust(home string) *TrustStore

LoadTrust reads the store. It never returns nil; check Err.

func (*TrustStore) Approve

func (s *TrustStore) Approve(src Source) error

Approve records src's current hash, replacing any decline. It re-reads the file first so another process's decisions survive, then replaces the file atomically, all under a lock file shared with other processes (trusted.json.lock), so no decision another process stored is lost.

func (*TrustStore) Decline

func (s *TrustStore) Decline(src Source) error

Decline records that the person said no to src's current hash, replacing any approval: it never runs and is not asked about again until it changes (#411). Like Approve, it re-reads the file first.

func (*TrustStore) Err

func (s *TrustStore) Err() error

Err reports why the store could not be read; nil when it is usable.

func (*TrustStore) Forget

func (s *TrustStore) Forget(key string) (forgot bool, err error)

Forget removes any approval or decline stored under key (a Source's Path), so the source is asked about again. forgot is false when nothing was stored; the file is then left untouched.

func (*TrustStore) Status

func (s *TrustStore) Status(src Source) TrustStatus

Status reports whether src may run without asking. A decline of this exact hash wins over any approval.

Jump to

Keyboard shortcuts

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