Reference
Hook model
What exactly is a hook, and where is it declared? A hook is one approved policy command bound to one or more agent events, described by a registry that ships sealed inside every release. This page is the registry model; sealing and installation are in hook-releases, and the runtime controls are in enforcement-control.
#The registry
shared-hooks/registry.json at the release root is the single declaration.
Its top-level fields are version, generatedAt, generatedFrom,
managedBy, releaseId, catalogChecksum, events (the per-event hook
wiring the runtime executes), adapters (provider config paths), and
catalog — the human-auditable record.
catalog carries:
agentHooks[]— the approved agent-facing hooks (below);gitHooks[]— repository Git hooks:id,event,scope,hooksPath,source, plus the same category/description/why/side-effect record;editorAdaptersandompAdapters— where editor and OMP integrations attach;maintainedIn— the canonical structured registry source rewritten by the installer;version,updatedAt, andnotes— catalogue identity and operator context.
catalogChecksum is the SHA-256 of the registry JSON with the checksum
field itself removed (sorted keys, canonical separators). It is recomputed
by the installer after path rewriting, and every session record carries it,
so the desktop can compare the bundle, the installed runtime, and the live
session by checksum rather than by trust.
#Machine enforcement state
The operational selection is separate from the sealed registry:
| Property | Contract |
|---|---|
| Path | ~/Library/Application Support/Tama/hook-enforcement.json |
| Schema | ai.wisent.tama.hook-enforcement.v1; mode is all or only, enabled is the selected id array, changedAt is the transition time, and previous retains the preceding mode and ids |
| Writer | The operator-facing enforcement controls use the shared Rust enforcement writer, which replaces the file atomically with mode 0600 |
| Retention | The file persists across sessions, runtime reloads, installation, and emergency disable/re-enable; emergency state temporarily takes precedence without deleting the selection |
all requires an empty enabled array; only requires at
least one unique, nonempty, exact-trimmed id. Operator writers additionally
refuse ids absent from the installed registry. Missing state resolves to
all. An unreadable, malformed, wrong-schema, or invalid-shape file reports
an error and also resolves to all, so state failure never weakens policy.
#One agent hook
Each agentHooks[] entry declares:
| Field | Meaning |
|---|---|
id | Stable hook identity, e.g. block-inline-execution |
command | The exact command the runtime executes |
source | The hook's source file inside the release |
occurrences[] | Every event binding: event, blocking, statusMessage |
category | Policy area, e.g. git safety, credential safety, edit safety |
status | Lifecycle state (active) |
description, why, sideEffects | What it does, the justification for it, and what it touches |
aliases | Alternate ids the runtime accepts |
Events are names like pre_tool_use:bash, pre_tool_use:write,
user_prompt_submit, or stop. A binding with blocking: true can refuse
the action and statusMessage is what the agent surface shows while it runs; nothing
in a binding bounds how long the hook may take, because the engine waits for its answer.
Hooks read a JSON payload from stdin. A refusal is one decision document,
{"decision":"block","reason":"…"}, printed on stdout (as the whole output or as its last
line), with the same reason on stderr and a nonzero exit for surfaces that read the exit status.
The engine reads only that document as a refusal. A hook that ends nonzero without it — a panic, a
missing binary, a gate that printed TAMA_HOOK_INFRASTRUCTURE: because it could not
decide — crashed; the engine logs hook <id> malfunctioned: … and the action goes on,
because a crash is not a policy decision. Nothing reads the wording of a crash to tell it apart.
A payload a hook cannot read — stdin that fails, is empty, is not JSON or is not a JSON object —
is refused, not crashed: the hook prints the decision document with the reason
TAMA_HOOK_INFRASTRUCTURE: the hook payload … naming which of those it was, because
judged as an empty object every gate would let the call or the stop through with nothing judged.
The real test is tests/hook-payload/refused.rs.
The hooks of a tool-call event (pre_tool_use:*) and of stop are
started together and their answers read in registry order: a tool call is refused by the first
gate in that order that refuses, with that gate alone named, and the end of a turn is refused for
every reason at once. Each gate is a process; started one after another, they can together take
longer than the harness waits for a hook, and the harness then refuses the call with no gate named.
The hooks of a session event (session_start,
user_prompt_submit) still run one after another, in case a later one reads what an
earlier one wrote. The real test is tests/hooks-running/together.rs
(cargo test -p tama-cli --test hooks_running_together): two gates that can only both
end when they run at the same time, one reading a named pipe and one writing it, and a third that
refuses after them.
#Write target validation
Every normalized write or edit needs a nonempty string tool_input.file_path. Shared write guards refuse a missing, empty or non-string target before resolving it against cwd; the working directory is never substituted as the file.
The refusal names tool_input.file_path and its actual shape error. Correct the adapter's per-file normalization, not the parent directory's file count. The folder guard admits an existing regular-file edit without counting ancestors; a directory or special object named as the target refuses as an invalid write target. Metadata, directory-read and entry-inspection errors retain their actual path and OS cause, never an empty count. Only a genuinely new file is checked against its actual target folder. The same reason is retained in the Session decision table and Posture.
The real write_payload_targets journey invokes built guards with malformed targets, an existing relative file, an actual directory target and a symlink loop. It checks causal refusal and unchanged filesystem state, binds the declared clean revision and executable digest, and retains inputs, commands, statuses and output. Its definition is not an installed multi-file edit qualification.
cargo test --manifest-path rust/Cargo.toml -p tama-cli --test crowded_folders_hashline exercises the native folder guard with existing-file hashline sections, a new file in a full folder and an unreplayable envelope. Set TAMA_TEST_SOURCE_REVISION to the exact clean candidate before an authorized run. Each case owns a separate fixture and retains the actual executable digest, input, output and exit status through the shared evidence harness. An unsuccessful hook process fails the journey even when stdout is empty; a passing native decision must have no decision document. This direct-hook suite does not apply edits or qualify installed OMP dispatch.
#Quoted data and executable input
A Bash request is parsed as shell syntax. Literal arguments remain data when they contain apostrophes, escaped quotes, newlines, or examples of commands. Quoting an executable name does not make it data. Command substitutions still run inside double quotes, and recognized interpreter arguments are inspected through their complete argument boundaries rather than the first closing quote.
For example, printf '%s' 'ssh example.invalid' reports text;
bash -c 'ssh example.invalid' requests a transport and still requires
the normal authorization. Here-document handling keeps the consumer's program
separate from expansions the shell performs before delivering that input.
Native process arguments keep their quoting boundaries when projected as shell text.
Eval and recognized non-shell interpreter input use non-shell string rules:
console.log("$(ssh example.invalid)") does not perform a Bash
substitution. Existing embedded-interpreter recognition remains in force.
Unclassified shell syntax remains visible to the guard rather than being
silently discarded. This reader does not replace the transport's authorization,
target, operation, or exact-command checks.
The native interface is tama hook block-unauthorized-ssh with a
JSON request on stdin. The Session decision table and
Posture show the installed runtime's decisions and reasons.
Their installed and loaded release identities must match the release being
qualified. An older last-blocking reason is history, not proof that a later
request was refused. Source-suite evidence and live-runtime evidence are separate.
tama hook block-background-execution reads a Bash payload on stdin. Three readings are structural: the call's own async request, the call's own request to be stopped after some seconds (the call asks to be stopped after <seconds> s), and a bare & outside quotes (2>&1, && and a quoted '&' are not one). What the command does is read by a model through Oko's model call: a program that detaches work from the session, a multiplexer started detached, a shell handed a command that backgrounds, a program that only pauses or stops another after a fixed time, and an option or variable that limits how long work runs, inside bash -c as well. No list of program, flag or option names decides it. The model's answer is the detail after BLOCKED: background execution is forbidden. Run it in the foreground so its output is seen. Detected: or BLOCKED: no limit on how long work runs and no guessed wait. Let the command run to its end, or read the state it would wait for. Detected:. A quoted sentence, a commit message and an operand after -- are data. A question the judge leaves unanswered refuses the command with TAMA_HOOK_INFRASTRUCTURE: block-background-execution: review unavailable: …, as asking a model describes.
tests/background-execution/limits.rs exercises the real hook interface through cargo test -p tama-cli --test background_execution_limits with the judge recorded as not answering, where a command only the judge could read is refused as unjudged; with TAMA_TEST_BACKGROUND_JUDGE=1 and -- --ignored it asks the real judge. The Session decision table and Posture show the loaded release and the refusals it gave.
tama hook pre-bash parses executable Git arguments, including directory/configuration options, shell wrappers and command substitutions; quoted examples supplied to another program remain data. Patch and interactive modes refuse, except interactive rebase with an explicit nonempty sequence editor. What a flag means is read from Git's own declaration of that subcommand (git <subcommand> -h: its option lines such as -i, --[no-]include, then its usage alternations such as [-p | --patch]), and from the further word's declaration (git stash push -h) for a flag the subcommand does not name; only a flag that stands for --patch or --interactive there refuses. So git add -p, git commit -p, git restore -p, git clean -i and git rebase -i refuse, while git commit -i (--include), git grep -i (--ignore-case), git log -p and git show -p (which declare no patch mode) pass. When Git cannot be asked or prints nothing, the command refuses with TAMA_HOOK_INFRASTRUCTURE: cannot read which options `git <subcommand>` declares: <cause>. Commit, merge and annotated-tag message editing requires a supplied message or applicable --no-edit; a later explicit --edit still refuses. Lightweight tags and tag listing do not open an editor. Refusals name noninteractive alternatives: named paths, -m/-F, --no-edit, or a prepared sequence editor. During activation the OMP adapter applies tama hook pre-bash --git-environment, which configures the agent process to use the actual Tama --git-editor refusal entrypoint without changing human Git settings. Missing configuration fields and actual producer failures remain diagnostic errors and refuse tools rather than dropping the extension. See agent Git editor activation for the environment contract and commands. The registered interactive_git_refusal journey uses the actual hook and Git with a declared clean candidate, preserving revision, executable digest, payloads and results; its admitted forms include git commit -i, git log -p, git show -p, git grep -i and git format-patch -p.
#The desktop's catalog snapshot
The app does not parse the registry directly. At build time,
Scripts/app_build/export-catalog.mjs renders it into tama-catalog.json inside the
app resources: version, generatedAt, hooks[], orphanSources[]
(source files no hook references), and repoGitHooks[] (project, event,
sourcePath). Each snapshot hook carries id, type, command,
sourcePath, category, status, description, why, sideEffects,
events[], and justificationRequirements[].
On every load the app validates the snapshot structurally: duplicate hook ids and hooks with no events are errors; a hook whose source path cannot be mapped from its command is a warning. The result — ok flag, errors, warnings, hook count, orphan-source count — is what Posture shows as Structural validation, with the standing warning that the bundled snapshot check does not run high-entropy or live runtime drift checks.
#Justification-gated hooks
A hook of type requires_justification names one or more local
justification registries instead of blocking outright. Each requirement
declares kind, title, registryPath (a ~-relative JSON file),
field (the justification text field), minimumWords, and optionally
directUserQuoteField. The desktop's Justifications screen reads those
registries and shows every entry with its word count, its expires_at
expiry, and whether the target it justifies still exists — expired or
missing-target entries are the ones to renew or remove.
#Numbers and word classifiers
See numeric values and standard operations for declaration, manifest, source-citation and syntax-based operation rules.
#Identities
See identity provenance and incremental checking for sources, refusal diagnostics, shared transcript snapshots and real prose journeys.
block-keyword-literals refuses collections and regex alternations
of two or more words used to classify input — an alternation inside a quoted
literal, or inside a regex literal of a JavaScript or TypeScript file
(/Try again|Retry/i) — and the same vocabulary written
as a comparison: a chain of == (or ===, !=)
against two or more quoted words on one line, and the arms of a
matches! or match pattern joined with |.
Moving the collection to JSON,
YAML or TOML does not make it acceptable. A list in a parsed JSON or TOML
document whose every word names a key the same document declares, at any
depth, is that document's own structure and passes: a JSON Schema's
required, a record's field list, a Cargo [features]
entry naming the features and optional dependencies of its manifest. One word
the document declares nowhere and the list is judged. No declaration allows a word
classifier: keyword-provenance.json is retired, and writing it is
itself refused. A refusal identifies the file, line, kind (list, alternation,
pattern, comparison or data list) and words:
src/k.rs:2: a pattern of 3 words decides something by keyword ("okay", "sure", "done").
The supported choices are a model judgment through Brama or the structure the
input already carries, such as a field, status code or exit code.
Comments and documentation prose are not word classifiers. Protocol fields,
paths and command options are not English trigger words. A collection passed
as an ordinary call argument is that call's data:
run(["transcripts", "tasks", "show"]) is not a classifier, and
neither is a command line bound to a name before it runs,
let args = ["build", "status", id, "--json"]: its collection also
holds a computed value or an option, which a vocabulary of words never does.
The same holds for the argument list of a process call in any language —
Command::new("stado").args([…]), a test fixture's
service.cli(&[…]), spawnSync("stado", […]), a Swift
function that hands a command line back. A command line built a step at a time
is read the same way: a list bound to a name that a later step hands an option
(var args = ["crawl", "start"], then args.append("--catalog"),
or args += ["--json"]) is that command line. A list of nothing but words
bound to a name and grown only with values is still judged, a collection asked a
question where it stands ([…].contains(x)) is reported whatever else it
holds, and one comparison with one word (flag == "--json", state ==
"completed") is not a list. Compared words are grouped by the input on the
other side of each operator — a name, a property path, a call on it or a
subscript of it — so one line checking different typed fields with one word each
(payload.event !== "SessionStart" || payload.source !== "compact",
left.kind() != "identifier" && right.kind() != "array",
after["current"]["status"] !== "open" || receipt["state"] !== "queued")
passes, while one input compared with several words
(reply.trim() == "okay" || reply.trim() == "sure") is refused. The
real tests are tests/keyword-literals/argument_vectors.rs,
tests/keyword-literals/argv/built.rs and
tests/keyword-literals/quoted_punctuation.rs (cargo test -p
tama-cli --test keyword_literals_argument_vectors --test keyword_literals_built_argv
--test keyword_literals_quoted_punctuation), on the built hook with
the write payloads above, read from its decision document.
In a release manifest, argv is a command vector and promotion.channels names release states. Neither list classifies input; other word arrays in that document are still judged.
An edit of a JSON file sends only the changed lines, which do not parse as a document; an array written as the value of argv, or of channels in a release manifest, is exempt the same way. The rest of the fragment is judged as quoted text.
YAML command mappings are read as syntax: argv names the executable and its arguments; args names arguments only when the same mapping carries a nonempty string program; each step of a Stado catalog recipe's after_install is one argv, because Stado refuses any step that is not an array of strings. Block and flow sequences, quoted keys, comments and either field order are supported. Only sequences of strings are command vectors; sibling word lists, nested classifier objects, an args list whose program belongs to a different mapping, and an after_install list holding any step that is not itself a sequence remain judged.
A complete command mapping within a partial edit retains that meaning. Missing, malformed or ambiguous command context is not treated as proof of a command. Blanking a proven vector preserves line numbers, so any refusal still names the remaining collection's line.
The native interface remains tama hook block-keyword-literals, with a write or edit payload on stdin. cargo test -p tama-cli --test keyword_literals_yaml_commands drives that real interface, requiring TAMA_TEST_SOURCE_REVISION and a matching clean tracked candidate. It retains source identity, binary digest, requests, exit statuses and output under Cargo's build directory, and checks structured policy decisions rather than transport success or a crash. Desktop's Session and Posture show the installed hook's decisions.
A collection retained in a variable, iterated, or used to answer a membership question is judged; ["ready", "done"].contains(reply) classifies by words. Renaming the variable or moving the same decision into a data file does not change what it does.
A list a function returns is its caller's data, as a list handed to a call is: when the collection stands in a container a return statement opens, the guard reads it as the value the function produces — a table's column headers in a returned view (return { table: { columns: ['Study', 'Status', 'Question'], rows } }) — not a vocabulary the program keeps. The same returned list asked a question where it stands (return ['nope', 'never'].includes(answer)) still classifies by words and is refused, and so is a list bound to a name. tests/keyword-literals/returned_views.rs drives tama hook block-keyword-literals with all three.
One iterated list is read from the loop around it: when a for (const field of […]) (JavaScript, TypeScript) or for field in […] (Rust) loop binds one name to the written words and every use of that name in the loop's braced body is a subscript key (message[field]), the words are the names of the fields the loop reads, not a vocabulary input is compared with, and the list passes: for (const field of ["steering", "synthetic"]) { assert.equal(typeof message[field], "boolean") }. The same loop that compares the name (if (reply === word)), hands it to a call or returns it is still refused, and so is a loop whose body never uses the name. tests/keyword-literals/quoted_punctuation.rs drives both outcomes through the built hook.
The keyword guard judges every list the file holds after the write, not only the lists the write adds: a list somebody left on a line the edit does not touch is refused by name and cleaned up, not counted out as already present. A write whose target is missing, a directory or unreadable is still judged from the text it writes. tests/keyword-literals/held_lists.rs (keyword_literals_held_lists) drives tama hook block-keyword-literals with an edit beside a held list, a list written to an unreadable or missing target, and a file with no list, which passes.
#A number spelled another way is still a number
The numeric guard also reads a number written so that it does not look like
one, and judges it as the number it is: Number('401'),
Number(true) and Number(false), and a constant whose
value is built only from other upper-case constants and operators
(const TWO = ONE + ONE). A name such as ZERO,
IDX_ONE or RADIX_TEN bound to a bare number is a bare
number under a name, and so is 'helloworld'.length standing for 10.
Each is refused like the literal it hides.
What a refusal asks for depends on what the number does. A limit, cut, cap,
page size, retry count or wait that the operator never asked for is
removed together with the behaviour it drives: .limit(500) goes and
the query answers every row; .slice(0, 160) goes and the text is
kept whole; Math.min(limit, 200) becomes a refusal of a malformed
limit; a default page size of 50 becomes "no limit unless the caller asks".
Moving such a number into a constant, an environment variable or a different
spelling is not a repair. A number that is not a choice stays, written as a
literal: an HTTP status or exit code, a unit conversion such as
24 * 60 * 60 * 1000, an array index, the radix 10, the ISO date
prefix slice(0, 10), a hash prefix, and a limit the vendor
documents (Resend's 100 emails per list call, X's 160-character bio).
pre-write-edit also refuses code that opens a private local database file in a
product (rusqlite::Connection::open, sqlite3.connect(, new Database(
and the other call shapes in ports/private_stores/stores.json). Durable data belongs to the
fleet database resolved through stado database; a private
file keeps it where the rest of the fleet and the operator's other machines cannot see it. The refusal
names that route. When the owning store really cannot serve the data, record why with
tama justify record --kind file --file <path> --justification "<why stado database cannot
serve it>" — the reason has to name stado database — and write again. Stado itself, the
owner of durable data, is not judged; only code files are read. The whole file the write leaves is
judged, lines it already held included (whole-file judgement).
pre-write-edit refuses a failed read quietly replaced with a stand-in value (zero, minus
one, an empty list, map or string, null, nil, undefined), by the
code's shape: a catch body that assigns one, a JavaScript .catch arrow that returns one, a
read call followed by || or ?? and one, and in Swift an optional
try? read in parentheses followed by ?? and one. It also refuses a Swift
statement that puts try? before await and uses nothing it returns: a refused
read, write or send then looks exactly like success; an assignment to _ discards it the
same way. A value that is used — assigned, returned, passed on or compared — is not judged, and waiting
out a cancellable Task.sleep is allowed. The Swift checks read only .swift
files and only their code: comments and string literals are blanked first, so a sentence quoting the
shape is not a call. Wrap the call in do/catch and put the error where the
screen shows it, or let it throw. Source: ports/pre_write_edit/sentinels.rs and
ports/pre_write_edit/swift.rs.
#Asking a model, when shape cannot decide
A rule that is not about shape — whether a claim is grounded, whether a closing report hands work back to the operator — is decided by a model through one route, which asking a model describes with what a gate does when no model answers.
#Shared executable identities
Existing command readers share the compiled vocabulary under
ports/shell_text/vocabulary/, including shell, interpreter,
wrapper and source-extension identities. Other command families use their
own existing product tables. ports::shell_text exposes these
families to consumers instead of requiring a second copy in each guard.
The shell reader combines syntax-tree argument boundaries with the existing interpreter predicate. The predicate identifies which argument is a program and which interpreter family receives it; it does not turn an interpreter mentioned inside ordinary data into a new command.
These existing tables are not permission to add keyword classifiers. New decisions must follow structured input or a model judgment through Brama. Guard selection, grants and operator-owned authorization records are separate from source-text parsing and are not changed by a reader repair.
#Catalogue access
The full catalogue remains structured in shared-hooks/registry.json.
Use tama list --json, tama show <hook-id> --json, and
tama validate --json for machine-readable inspection. The website explains
the catalogue contract without generating a second Markdown registry.