airsl 0.1.3

Embeddable Lua 5.4 runtime with a capability-gated sandbox and a host standard library
Documentation
# agent-hook

An agent hook end to end: take the JSON payload on stdin, decide, and write the envelope the agent
host expects on stdout.

This gets its own example because the hook contract is the one place in this crate where the *bytes
on stdout* are the interface. Everywhere else a script's output is for a person to read; here it is
parsed by the process that invoked the hook, so a stray newline, a hand-rolled nesting or a
well-meant progress line is a bug rather than a blemish. That is a shape rather than a function
call, which is why it does not fit in a doc comment.

## Run

```bash
cargo run -p airsl --example agent-hook
```

And the way a real hook is actually invoked — a JSON document on stdin:

```bash
cargo run -q -p airsl --example agent-hook < crates/airsl/examples/agent-hook/payload.json
```

Both print the same bytes, and so does `< /dev/null`. That is arranged, not luck; see
[The stdin problem](#the-stdin-problem) below.

## Output

```
-- payload: event=PreToolUse tool=Write file=hooks/enforce.lua
-- hook.context: the envelope, nesting included
{"hookSpecificOutput":{"additionalContext":"This file is an airsl hook: it runs under a policy, and its authority comes from the invocation.","hookEventName":"PreToolUse"}}
-- hook.emit: any document, and exactly the bytes handed to it
{"reviewed":"hooks/enforce.lua","suppressOutput":false}
```

## The stdin problem

`hook.payload()` reads **this process's own stdin** (`src/modules/hook.rs:98`), and there is no seam
to inject anything else through. A naive example would therefore block forever when a developer runs
it in a terminal, waiting for a person to type a JSON document by hand. So the script asks first:

```lua
local payload = {}
if not airsstack.stdio.isatty("stdin") then
  payload = airsstack.hook.payload()
end
if next(payload) == nil then
  payload = airsstack.json.decode(airsstack.fs.read(airsstack.path.join(here, "payload.json")))
end
```

Three invocations, one result:

- **On a terminal**, stdin is a tty, so the script never reads it and never blocks.
- **Under CI**, where `cargo make examples` runs every example with inherited stdin, stdin is not a
  tty but is already at end of stream — so `hook.payload()` returns an empty table rather than
  raising.
- Both fall through to the bundled `payload.json`, so **the output is byte-identical in both**,
  which is what lets this example have a real, reproducible `## Output` block at all.
- **Piping that same file in** takes the `hook.payload()` path instead, and prints the same bytes a
  third time, because it is the same document. That is the path a real hook takes.

The fallback exists only so the example is self-running. A real hook calls `hook.payload()` and
nothing else, because the host always has a document to hand it — this is the one place the example
deviates from what you would ship.

## What it demonstrates

- **`hook.payload()` reads and decodes in one call** (`src/modules/hook.rs:98`), because every
  hook's first two lines were the same pair and the failure mode of getting them wrong is a hook
  that silently does nothing.
- **An empty stdin yields an empty table, not an error** (`src/modules/hook.rs:90`). No payload is
  not a parse failure: a hook invoked by hand, or one whose event carries nothing, should see `{}`
  rather than an error it has to guard every call site against. `next(payload) == nil` is how the
  script tells that apart from a real document.
- **`hook.context(event, additional)` writes the nesting once, in Rust**
  (`src/modules/hook.rs:119`, built by `context_envelope` at `src/modules/hook.rs:129`), so no
  script has to remember that `hookEventName` and `additionalContext` live *inside*
  `hookSpecificOutput`. `hookEventName` is an argument rather than a guess, because the contract
  requires it and there is no sensible default.
- **The envelope deliberately carries no `permissionDecision` field**
  (`src/modules/hook.rs:13`), and a test holds that line:
  `the_context_envelope_carries_no_permission_decision_field` (`src/modules/hook.rs:229`). The
  reason is behavioural, not stylistic, and it was watched directly against the agent CLI installed
  on the machine that built the module rather than read out of a specification — no version is
  claimed, and the exact conditions could shift release to release. What was watched: a hook
  returning `permissionDecision: defer` was watched
  having the tool call it fired on swallowed outright — no `tool_result` at all — when the session
  is non-interactive, the tool batch is solo, and the abort signal is not already set. That strands
  the caller with no record the call ever happened. `additionalContext` alone was confirmed to carry
  no such risk: the note reaches the model's turn and the tool call still returns normally. The test
  goes through the real builder rather than a literal, so a decision field added to the
  implementation turns it red instead of leaving it green against its own fixture.
- **`hook.emit(table)` writes an arbitrary JSON document** (`src/modules/hook.rs:106`, encoding at
  `src/modules/hook.rs:102`) with no envelope shape imposed — the escape hatch for anything
  `context` does not model. Keys come out sorted, so `reviewed` precedes `suppressOutput` whatever
  order the Lua table was built in.
- **No trailing newline is added, by either of them** (`src/modules/stdio.rs:98`), because a hook's
  output is a single JSON document read by a parser that cares what the bytes are. The newline that
  ends each of the two documents above is written by the script itself, so that this example can
  print two of them on separate lines; a real hook writes one and adds nothing to it.
- **`stdio.isatty("stdin")` is the only way a script can tell a reader from a parser**
  (`src/modules/stdio.rs:140`) — the difference between printing a progress line and corrupting a
  JSON document. This example is the only place in the ladder that uses it.
- **Only the exit status can block a tool call; printing a diagnostic cannot.** A `PreToolUse` hook
  that exits non-zero blocks the tool call that triggered it, and such matchers commonly cover
  `Read` — so a propagated failure can block every file read in a session. That is why
  `FailurePolicy::FailOpen` exists (`src/failure_policy.rs:37`) and why its `exit_code()` is `0`
  (`src/failure_policy.rs:49`). The script here never exits and never can: deciding the status is
  the host's job, not the script's (`src/modules/hook.rs:31`).
- **The chunk name is chosen, not inherited.** The host builds an absolute path from
  `CARGO_MANIFEST_DIR`, so `Script::with_name` (`src/script.rs:118`) relabels the chunk to
  `hook.lua` — a raised error would otherwise carry the developer's home directory into the
  traceback, and a hook's stderr routinely lands in someone else's log.
- **The read root travels as an argument.** The host grants a read root and passes its path to the
  script with `with_args`, the same way [`filesystem-grants`]../filesystem-grants/ does, so the
  Lua holds no path that is true only on one machine.

The script prints through `airsstack.stdio.write` and never through Lua's own `print`. Not because
the two would reorder — `print` flushes after every line and so does `stdio.write`
(`src/modules/stdio.rs:109`), so they interleave correctly even through a pipe — but because `print`
appends a newline and cannot be told not to. On the one stream where the bytes are the interface, a
writer that adds a byte of its own is the wrong tool no matter what it is asked to write.

`payload.json` is a synthetic `PreToolUse` payload: a placeholder session id, a repo-relative
`file_path`, and nothing else. Nothing in it is a real path or a real user's data.

## See also

- [tutorial]../../docs/tutorial.md — step 6 builds a hook from the CLI side, with `airsl run` and
  a shell pipe instead of an embedded engine.
- [`failure-policy`]../failure-policy/ — the decision this example refuses to take: whether a
  failed hook exits non-zero.
- [`values-and-json`]../values-and-json/ — why the keys in both documents above come out sorted.
- [stdlib]../../docs/stdlib.md — the full `airsstack.hook` and `airsstack.stdio` roster.