session-control · git:20261016.78627d9 · 2026-10-16 · sha256 38e655813ea3c9e8

session-control git:20261016.78627d9A

Immutable. This exact content never changes and is served at /api/v1/blob/38e655813ea3c9e8.

---
name: session-control
description: Use when adding or changing a clud "session control" (a chat or agent control) — a switch only the user can turn that hooks enforce on every agent in a session, subagents included, such as "worktrees allowed", set from the user's prompt or the launch environment. Covers the state route (UserPromptSubmit → daemon session-flag dictionary → PreToolUse / harness-event hooks), session identity, the CLUD_* environment passthrough, fail-closed rules, and the tests to write.
---

# Adding a session control

The facts (state route, identity, the worktree control as the worked
example) live in
[docs/architecture/session-controls.md](../../../docs/architecture/session-controls.md).
Read it first. This page is the checklist.

## Decide first

- **Who may turn it on.** Only the user: their prompt, their launch
  environment or flag, or a mode they started (an active `/grind` run).
  Never anything an agent can write: Bash text, a file in the repo, a
  `clud` subcommand, or `CLUD_BAD_CMD_OVERRIDE`. Refused agents look for an
  escape, so leave none.
- **Default and failure direction.** A control that refuses by default
  fails **closed**: no session id, daemon down, unreadable reply → refuse.
  A control that allows by default fails open. Write the choice in the
  module docs.
- **Scope.** The payload `session_id` scopes it to one conversation and
  every subagent in it; `/clear` resets it. An environment variable scopes it
  to the whole launch.

## Steps

1. **Rule module** at the crate root, like `worktree_consent.rs`: the flag
   key, the env name, a pure `decide(...)` that takes the lookup as a
   closure (so tests need no daemon), and a `refusal(session_id)` wrapper
   that reads the env and calls `crate::daemon::session_flag_get`. Register it
   in `lib.rs`.
2. **Prompt opt-in.** `UserPromptSubmit` is already registered by
   `clud_hooks_compile::deletion_safety_fragment` and routed in
   `block_bad_cmd::run_for_event`. Extend `worktree_consent::handle_prompt`,
   or call a sibling from the same branch; there is one registration for all
   controls. The handler must exit 0 and print nothing: stdout is added to the
   conversation, and exit 2 erases the prompt.
   - Match a **yes**, not a mention: split into clauses and reject a
     negation before the keyword ("no X", "don't use X", "without X").
     Keep the verb list to granting verbs; "can", "make" and "with" turn
     questions and chores into consent. Pin both directions in a unit
     table built from real prompts.
3. **Daemon state.** Use `daemon::session_flag_set` / `session_flag_get`
   with a new key. No protocol change is needed; the dictionary takes any
   JSON value. They connect only and never start a daemon.
4. **Launch opt-in.** Read `std::env::var` of a `CLUD_*` name in the hook.
   `runner::child_env` already passes `CLUD_*` from clud to `claude` to every
   hook. If a backend flag implies consent (as `--worktree` does), set the
   variable in `main.rs` next to the unsafe-mode marker, from
   `args.passthrough`.
5. **Enforcement points.** Cover every way the action happens:
   - shell commands: after the parse in `block_bad_cmd` (use
     `evaluation.git_path_captures` or the `shell_ast` invocations, never
     substring matches on the command);
   - non-shell tools: a PreToolUse check next to the worktree-consent one.
     Add each tool to `GUARDED_TOOLS` and to the predicate in
     `every_rule_tool_reaches_the_dispatcher`, or the harness never calls
     the hook;
   - harness events that perform the action (`WorktreeCreate`): refuse in
     their handler as the backstop.
6. **Refusal text** says what is off, what to do instead, and how the
   *user* turns it on. Update any other refusal that recommended the action
   now gated (the checkout-claim text did).
7. **Docs.** Add the control to `session-controls.md`, a breadcrumb in the
   touched READMEs, and a `DD-NNN` if the design is non-obvious.

## Tests

- `decide`: allowed with consent; refused without; refused when the lookup
  errors; refused with no session id; the env skips the lookup.
- The prompt table (yes and no phrasings from real transcripts).
- Each tool or event reaches the check (`every_rule_tool_reaches_the_dispatcher`,
  a fragment test in `clud_hooks_compile_tests.rs`).
- The daemon dictionary keeps sessions apart (`daemon/session_flags.rs`).

## Gotchas

- **The transcript is not the user.** Its "user" entries include task
  notifications, compaction summaries and hook text. Read the prompt from
  the `UserPromptSubmit` payload, never by scanning the transcript.
- **Subagents never see the prompt.** That is why the control lives in
  hooks: the main agent's brief to a subagent drops constraints.
- **Codex** has no prompt event mapped; there a control is env-only.
- **Unsafe mode** and `CLUD_ALLOW_ALL_CMDS` bypass PreToolUse rules; decide
  whether the harness-event backstop should honor them too.
- Validate through `bosn ci` per CLAUDE.md; for a local type check use
  `soldr --no-cache cargo check -p clud --tests`.