Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
"command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude"
}
]
},
{
"matcher": "^AskUserQuestion$",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-decision-pretool-check.sh --claude"
}
]
}
],
"Stop": [
Expand Down
10 changes: 10 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
"timeout": 10
}
]
},
{
"matcher": "^request_user_input$",
"hooks": [
{
"type": "command",
"command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v jq >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-decision-pretool-check.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.PreToolUse[]?; .matcher == \\\"^request_user_input$\\\" and any(.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-decision-pretool-check.sh\\\")))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-decision-pretool-check.sh\"'",
"timeout": 10
}
]
}
],
"Stop": [
Expand Down
16 changes: 16 additions & 0 deletions .grok/hooks/fm-primary-decision-check.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "ask_user_question",
"hooks": [
{
"type": "command",
"command": "bash -lc '[ -n \"${GROK_WORKSPACE_ROOT:-}\" ] || exit 0; exec \"${GROK_WORKSPACE_ROOT:-}/bin/fm-decision-pretool-check.sh\" --grok'",
"timeout": 10
}
]
}
]
}
}
56 changes: 56 additions & 0 deletions .opencode/plugins/fm-primary-decision-check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { realpathSync } from "node:fs";
import { resolve } from "node:path";
import { spawn } from "node:child_process";

// OpenCode adapter for the decision gate owned by
// bin/fm-decision-pretool-check.sh and docs/decision-pretool-check.md.
// Exact tool identity is the only policy input.

function runProcess(command, args) {
return new Promise((resolvePromise) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", () => resolvePromise({ code: 0, stdout: "", stderr: "" }));
child.on("close", (code) => resolvePromise({ code: code ?? 0, stdout, stderr }));
});
}

async function resolveRoot(anchor) {
if (!anchor) return "";
const result = await runProcess("git", ["-C", anchor, "rev-parse", "--show-toplevel"]);
if (result.code === 0) {
const root = result.stdout.trim();
if (root) return root;
}
try {
return realpathSync(anchor);
} catch {
return resolve(anchor);
}
}

export const FmPrimaryDecisionCheck = async ({ directory, worktree }) => {
const root = worktree ? (() => {
try {
return realpathSync(worktree);
} catch {
return resolve(worktree);
}
})() : await resolveRoot(directory);

return {
"tool.execute.before": async (input) => {
if (!root || input?.tool !== "question") return;
const result = await runProcess(`${root}/bin/fm-decision-pretool-check.sh`, ["--tool-name", input.tool]);
if (result.code !== 2) return;
throw new Error(result.stderr.trim() || "built-in structured questions are disabled; use Lavish or plain chat");
},
};
};
93 changes: 93 additions & 0 deletions bin/fm-decision-pretool-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Stable tool-identity gate for captain-facing structured questions.
#
# The checker denies only the exact built-in structured-question tool names
# exposed by firstmate's verified primary harnesses.
# It never inspects question text, option counts, Bash command text, or any
# other tool arguments.
# See docs/decision-pretool-check.md for the complete contract, harness wiring,
# and verification record.
#
# Usage:
# <PreToolUse JSON on stdin> | bin/fm-decision-pretool-check.sh [--claude|--grok]
# bin/fm-decision-pretool-check.sh --tool-name <exact-name>
#
# Exit/output contract:
# ALLOW - exit 0 and no output.
# DENY - exit 2 and a native deny response for the selected adapter.
# FAIL OPEN - empty or malformed input, missing jq, or an unknown argument.
#
# Claude and Codex consume the stderr deny with stdout empty.
# Grok consumes the stdout decision object selected by --grok.
# OpenCode consumes exit 2 plus stderr after passing --tool-name.
set -u

ADAPTER=default
TOOL_NAME=""
TOOL_NAME_SET=0

while [ "$#" -gt 0 ]; do
case "$1" in
--claude)
ADAPTER=claude
shift
;;
--grok)
ADAPTER=grok
shift
;;
--tool-name)
[ "$#" -gt 1 ] || exit 0
TOOL_NAME=$2
TOOL_NAME_SET=1
shift 2
;;
--tool-name=*)
TOOL_NAME=${1#--tool-name=}
TOOL_NAME_SET=1
shift
;;
*)
exit 0
;;
esac
done

command -v jq >/dev/null 2>&1 || exit 0

if [ "$TOOL_NAME_SET" -eq 0 ]; then
PAYLOAD=$(cat 2>/dev/null || true)
[ -n "$PAYLOAD" ] || exit 0
TOOL_NAME=$(printf '%s' "$PAYLOAD" | jq -er '
if (.tool_name? | type) == "string" then .tool_name
elif (.toolName? | type) == "string" then .toolName
else empty
end
' 2>/dev/null) || exit 0
fi

case "$TOOL_NAME" in
AskUserQuestion|request_user_input|ask_user_question|question) ;;
*) exit 0 ;;
esac

MESSAGE=$(cat <<'EOF'
[lavish-only] Built-in structured questions are disabled in firstmate sessions. For a multi-option decision, write the complete context to request.md and the ordered questions to questions.json using [{"key":"lowercase-slug","prompt":"Which option?","options":[{"value":"option-a","label":"Option A"},{"value":"option-b","label":"Option B"}]}], then run: lavish-axi create --id <lowercase-slug> --title '<title>' --request <request.md> --questions <questions.json> --destination data/<id>/captain-answer.toon. For a yes/no, ask in plain chat instead. Surface only the decision title and: Run: lavish answer <id>
EOF
) || exit 0

if [ "$ADAPTER" = grok ]; then
jq -cn --arg reason "$MESSAGE" '{decision:"deny",reason:$reason}' || exit 0
exit 2
fi

DENY=$(jq -cn --arg reason "$MESSAGE" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
},
systemMessage: $reason
}') || exit 0
printf '%s\n' "$DENY" >&2
exit 2
115 changes: 115 additions & 0 deletions docs/decision-pretool-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Captain decision PreToolUse gate

This document is the authoritative human-readable contract for the captain-facing decision gate.
`bin/fm-decision-pretool-check.sh` is the single policy and denial-message owner.
Tracked harness adapters only forward exact native tool identities and map the checker's exit status into their native blocking mechanism.
The durable Lavish request and answer protocol remains owned by `tools/lavish/README.md`.

## Purpose and boundary

Firstmate has no legitimate route through a primary harness's built-in structured-question tool.
A multi-option captain decision goes through `lavish-axi create`, and a yes/no goes through plain chat.
The gate therefore denies a confident exact-name match unconditionally.

It never reads question prose, options, option counts, Bash command text, or any other tool arguments.
Text that merely mentions a protected name is irrelevant.
There is no bypass flag, environment escape, or agent-controlled opt-out.
Plain chat is the intentional escape path because it is not a tool call.

## Exact tool identities

| Harness | Exact structured-question identity | Registration |
| --- | --- | --- |
| Claude | `AskUserQuestion` | `.claude/settings.json` matcher `^AskUserQuestion$` |
| Codex | `request_user_input` | `.codex/hooks.json` matcher `^request_user_input$` |
| Grok | `ask_user_question` | `.grok/hooks/fm-primary-decision-check.json` simple exact matcher |
| OpenCode | `question` | `.opencode/plugins/fm-primary-decision-check.js` exact `input.tool` equality |
| Pi | None in the built-in registry | No gate is registered |

The checker recognizes only those four exact byte strings.
Harness-side matching narrows each invocation to its own native identity before the checker runs.

## Transport and output

Claude and Codex send the complete PreToolUse JSON payload on stdin with the canonical name in `.tool_name`.
Grok sends its payload with the name in `.toolName` and uses `--grok` for its stdout decision shape.
OpenCode passes its already-separated `input.tool` value as one `--tool-name` argument.

An exact protected identity returns exit 2.
Claude and Codex receive a PreToolUse deny JSON object on stderr while stdout remains empty.
Grok receives `{"decision":"deny","reason":"..."}` on stdout.
OpenCode throws only after checker exit 2, using the checker's stderr as the tool error.

Empty stdin, malformed JSON, a non-string or missing tool identity, an unrelated identity, missing `jq`, and malformed CLI transport all return exit 0 with both streams empty.
That is transport fail-open, not a policy exception.
Once an exact identity is positively known, the decision fails closed.

The denial tells the agent both legitimate routes.
It gives the complete `lavish-axi create --id ... --title ... --request ... --questions ... --destination ...` shape, the lowercase-slug question-key and `value`/`label` option schema, the plain-chat yes/no route, and the captain-facing `Run: lavish answer <id>` surface line.

## Harness evidence, 2026-08-01

The local version probes were:

```text
$ claude --version
2.1.220 (Claude Code)

$ codex --version
codex-cli 0.146.0-alpha.9.2

$ command -v grok opencode pi
no installed command for any of the three harnesses
```

Claude's [official complete tools reference](https://code.claude.com/docs/en/tools-reference) names `AskUserQuestion`, and its [hook reference](https://code.claude.com/docs/en/hooks) lists that exact name as a PreToolUse matcher and documents `tool_name` plus `tool_input` on stdin.
A live Claude 2.1.220 interactive session in an isolated scratch directory called the real tool after implementation.
The TUI displayed `PreToolUse:AskUserQuestion hook error`, the stderr deny JSON, and the complete `[lavish-only]` guidance instead of opening the question UI.
Claude's next model-visible response began by explaining that the session uses Lavish instead of inline questions, which proved the teaching message reached the agent.

Codex's [current hook manual](https://developers.openai.com/codex/config-advanced/#hooks) says PreToolUse matches canonical `tool_name`, covers local function tools, and blocks on exit 2 plus stderr.
The installed binary contains `core/src/tools/handlers/request_user_input.rs`, the exact `request_user_input` function name, and the explicit diagnostic `request_user_input is not supported in exec mode`, which bounds the tool to interactive surfaces rather than disproving it with an `exec --help` search.
The current primary session also exposes that exact function-tool identity.

Grok was not installed locally, so its live TUI was not tested.
The official [`xai-org/grok-build` source](https://github.com/xai-org/grok-build/blob/explainx/crates/codegen/xai-grok-tools/src/implementations/grok_build/ask_user_question/mod.rs) contains the built-in `ask_user_question` implementation and describes it as structured questions and option sets both inside and outside plan mode.
The same source defines simple hook matchers as exact tool-name matches and maps Claude aliases onto Grok's native identity.
Grok's [official hook documentation](https://docs.x.ai/build/features/hooks) confirms `toolName` and `toolInput` stdin fields, stdout deny JSON, exit 2 blocking, and fail-open behavior for malformed hook execution.

OpenCode was not installed locally, so its live TUI was not tested.
The [official built-in tools reference](https://opencode.ai/docs/tools/) presents a complete list and includes `question`, whose documented uses include implementation decisions and ordered option choices.
The tracked OpenCode adapter uses the `tool.execute.before` mechanism already live-verified for this repo's watcher-arm seatbelt.

Pi was not installed locally, so its live TUI and user-plugin surfaces were not tested.
The [current upstream built-in registry](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/tools/index.ts) is an exhaustive `ToolName` union and `allToolNames` set containing only `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`.
The tracked project extensions do not register a question tool.
Pi supports extension-defined interactive tools, but those are not built-ins and are outside this gate's declared scope.

The evidence commands were:

```sh
claude --version
codex --version
strings "$(command -v codex)" | grep -E 'request_user_input|requestUserInput'
gh-axi search code 'AskUserQuestionInput' --repo xai-org/grok-build
gh-axi search code 'ask_user_question' --repo xai-org/grok-build
gh-axi api /repos/xai-org/grok-build/contents/crates/codegen/xai-grok-tools/src/implementations/grok_build/ask_user_question/mod.rs --header 'Accept: application/vnd.github.raw+json'
gh-axi api /repos/xai-org/grok-build/contents/crates/codegen/xai-grok-hooks/src/matcher.rs --header 'Accept: application/vnd.github.raw+json'
gh-axi api /repos/earendil-works/pi/contents/packages/coding-agent/src/core/tools/index.ts --header 'Accept: application/vnd.github.raw+json'
```

The official references inspected were Claude's tools and hooks references, Codex's current hooks manual, Grok Build's hooks documentation and source, OpenCode's complete tools reference, and Pi's upstream tool registry and extension documentation.

## Automated validation

`tests/fm-decision-pretool-check.test.sh` is a pure stdin, stdout, stderr, and exit-status suite.
It never launches, signals, or inspects an agent session, tmux server, Herdr session, or worktree pool.

Run:

```sh
bash -n bin/fm-decision-pretool-check.sh
shellcheck bin/fm-decision-pretool-check.sh tests/fm-decision-pretool-check.test.sh
tests/fm-decision-pretool-check.test.sh
bin/fm-lint.sh
```
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The shared no-mistakes gate refusal used by every directly invocable mutating co
| `fm-arm-command-policy.mjs` | Semantic owner of the watcher-arm PreToolUse policy (docs/arm-pretool-check.md) |
| `fm-cd-pretool-check.sh` | Stable PreToolUse transport for the primary cd-guard command policy (docs/cd-guard.md) |
| `fm-cd-command-policy.mjs` | Semantic owner of the cd-guard's persistent-directory-change decision (docs/cd-guard.md) |
| `fm-decision-pretool-check.sh` | Exact-identity gate that routes structured captain decisions to Lavish (docs/decision-pretool-check.md) |
| `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction |
| `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` |
| `fm-spawn.sh` | Spawn, native-resume, or provider-neutrally continue crewmates on the resolved account and runtime backend |
Expand Down
Loading