1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
//! Interception: a say over tool calls, from the host's code or from the
//! workspace's.
//!
//! ARCHITECTURE.md §3 lists "event interception (block/modify tool calls)" as
//! something pi gets from in-process TypeScript extensions. ADR-0012's answer is
//! that the contract, not the binding, is the design: **one contract per seam,
//! and transports are adapters.** So this module is one interception contract
//! with two ways to speak it.
//!
//! - **In-process** — a host implements [`Interceptor`] and registers it with
//! [`RuntimeBuilder::with_interceptor`](crate::RuntimeBuilder::with_interceptor) —
//! host scope is runtime scope (ADR-0018).
//! Its code, its process, its dependencies; the case a subprocess answers
//! badly, because redacting a credential needs the vault handle the embedding
//! program is already holding.
//! - **Subprocess** — a workspace declares a command in `.basis/hooks.json` and
//! basis execs it with one JSON object on stdin, reading one JSON object back.
//! Process isolation and any language; no scripting runtime is embedded
//! (ADR-0001, and `docs/proposals/0001`, which stays deferred).
//!
//! Both answer in one vocabulary — allow, deny with a reason, modify with a
//! replacement input, replace with a different result — and both go through one
//! [`HookRunner`], so the ordering, the short-circuit and the threading of a
//! rewrite are decided once for the pair rather than twice.
//!
//! Both are asked at two moments, too. Before a call, the question is whether
//! it should happen and in what form; after it, the tool has run and the only
//! thing left to decide is what the model is shown — which is where some
//! questions can first be answered at all, because a command's output is not
//! knowable from its arguments. [`HookEvent`] names the two, a hook declares
//! which it wants, and an [`Interceptor`] implements one method or both.
//!
//! The other seam is approval ([`crate::approval`]), and the two are siblings
//! rather than one thing: an [`Approver`](crate::approval::Approver) answers
//! *may this happen* to whoever is watching, and an [`Interceptor`] answers
//! *may this happen, in this form* as part of a composing chain. mentra keeps
//! `ToolAuthorizer` and `PreExecutionHook` apart for that reason and basis binds
//! each of them once per binding, which is what ADR-0012's "hooks re-founded as
//! a binding of the authorizer seam" honestly amounts to.
//!
//! # Where the pieces are
//!
//! | what | where |
//! |---|---|
//! | what is asked, and what an answer may say | [`contract`] — both bindings |
//! | the in-process binding | [`Interceptor`] |
//! | the subprocess binding's JSON encoding | [`wire`] |
//! | how a subprocess is declared and found | [`HookSpec`], [`HooksConfig`] |
//! | the one chain both arrive at | [`HookRunner`] |
//!
//! # The seams underneath
//!
//! mentra's [`PreExecutionHook`](mentra::runtime::PreExecutionHook) fires
//! before the tool runs, and since mentra 0.24 before the rest of admission
//! too: hooks, then the tool's `input_schema` against what they left, then the
//! [`ToolAuthorizer`](mentra::tool::ToolAuthorizer). A participant is
//! therefore asked about every registered call, including ones the approver
//! goes on to refuse — being consulted is not being approved — and a rewrite
//! it returns is what the approver is then asked about.
//!
//! [`PostExecutionHook`](mentra::runtime::PostExecutionHook) fires after the
//! call and before the result reaches the model — before mentra's own pager,
//! so a participant sees the whole output rather than its first window, and
//! without touching `AgentEvent::ToolExecutionFinished`, so the audit trail
//! keeps what actually happened whatever the model is shown.
//!
//! basis registers exactly one implementation on each, [`HookRunner`], which
//! fans out to every interceptor and every configured command — not because it
//! must (`with_pre_hook` and `with_post_hook` both append) but because basis
//! wants to own the ordering and the short-circuit.
//!
//! What a post hook *cannot* do is un-run anything. By the time it speaks the
//! side effects have happened, which is why the two events are not
//! interchangeable and why a guard that must stop something belongs before the
//! call.
//!
//! # Configuration
//!
//! Hooks may be supplied directly as typed [`HookSpec`] values on
//! [`HooksConfig`], or discovered from `.basis/hooks.json` in the workspace and
//! `hooks.json` in the global config directory. Supplied hooks run before file
//! hooks; global file hooks still run before workspace file hooks, and every
//! same-name entry survives because hooks compose rather than shadow. JSON
//! rather than TOML because the wire contract is already JSON and basis already
//! parses it; `.basis/` because that is where basis's other workspace data
//! lives (`.basis/skills`).
//!
//! ```json
//! {
//! "schema": 1,
//! "hooks": [
//! {
//! "name": "no-force-push",
//! "command": ["./.basis/hooks/no-force-push.sh"],
//! "tools": ["spawn"],
//! "event": "pre_tool_use",
//! "timeout_ms": 5000,
//! "on_failure": "deny"
//! },
//! {
//! "name": "no-secrets",
//! "command": ["./.basis/hooks/no-secrets.sh"],
//! "event": "post_tool_use"
//! }
//! ]
//! }
//! ```
//!
//! `command` is an argv array, never a shell string: basis execs the program
//! directly, so nothing in a tool's input can be reinterpreted as shell syntax.
//! A relative program path is resolved against the workspace root; a bare name
//! is left to `PATH`, which is what a person writing the file expects. Omitting
//! `tools` means every tool; listing them matches on the exact tool name.
//! Omitting `event` means before the call, which is what every hooks file
//! written before there was a second event meant — one entry is asked at one
//! event, and a guard that wants both sides writes two.
//!
//! There is nothing equivalent for the in-process binding, because there is
//! nothing to discover: an interceptor is registered as a value, by code that
//! already exists.
//!
//! # Who speaks first
//!
//! Interceptors, in registration order; then supplied hooks; then global hooks;
//! then workspace ones.
//! The rule is that the further a participant is from the workspace's own data,
//! the earlier it speaks — so the host's own guard, and then the operator's,
//! can refuse before a program a repository chose is ever spawned. Since any
//! deny short-circuits, that ordering is the whole of what ordering decides.
//! [`HookRunner`] carries the argument in full.
//!
//! # When a participant breaks
//!
//! Every hook has a deadline ([`DEFAULT_HOOK_TIMEOUT`]) and is killed at it, so
//! a hanging hook costs a turn its budget rather than the turn. Past that,
//! a hook that cannot answer — killed, exited non-zero, printed nothing,
//! printed something that is not a decision, asked for a rewrite basis cannot
//! use, could not be started at all — **denies the call by default**, and says
//! so on stderr either way. An interceptor that returns an error or panics
//! denies on the same terms.
//!
//! That default is the one real judgement call in this module, and the
//! reasoning is on [`OnFailure`]. In short: a participant's power is over
//! whether the call happens, so a configured one is by construction something
//! whose opinion the operator wanted, and the two ways of being wrong are not
//! symmetric — failing open on a broken guard silently removes a control
//! someone believes is in place, while failing closed on a broken observer is
//! loud and gets fixed. A hook that would rather be ignored says
//! `"on_failure": "allow"`; an interceptor that would rather be ignored returns
//! `Allow` in code it already owns.
//!
//! After the call, denying is the same ruling with the only power still
//! available: the model is shown the failure in place of the output, marked as
//! an error. A guard that broke while checking an output for credentials has
//! not established that there were none in it — and the stream still carries
//! what the tool really returned, so nothing is lost to whoever is auditing.
//!
//! # A hook takes as long as it takes
//!
//! Consulting a hook means spawning a process and waiting for it. Since mentra
//! 0.24 that is an async primitive — [`mentra::process::BoundedCommand`], the
//! same code mentra's own shell executor runs on — so the wait is awaited on
//! the caller's runtime like any other future, on every flavor,
//! `current_thread` included. A turn cancelled mid-hook drops the future and
//! kills the program with it; the `spawn_blocking` this replaced could only
//! abandon a thread to it.
//!
//! The history is two steps. mentra's hook trait was synchronous until 0.16
//! ([oops-rs/mentra#16](https://github.com/oops-rs/mentra/issues/16)), which
//! forced a `block_in_place` dance that panicked on `current_thread`; the trait
//! went async and the wait moved to `spawn_blocking`; 0.24 made the spawn
//! itself async and the thread went too. It mattered most for ACP, where
//! ADR-0007 makes "the dispatch loop is never blocked" an invariant.
//!
//! [`DEFAULT_HOOK_TIMEOUT`] bounds how long any one hook can hold up the turn
//! it is vetting, and at the deadline the hook's whole process group is killed
//! — a script that backgrounds work leaves nothing behind. An interceptor is
//! bounded by nothing basis imposes: it is the host's own code on the host's
//! own runtime, and a deadline basis invented for it would be basis guessing at
//! a budget the host can state.
//!
//! # A hook is code from the workspace
//!
//! `.basis/hooks.json` is workspace data, so cloning a repository and running basis
//! on it can execute commands that repository chose, before any tool call. That
//! is the same exposure as [`crate::shell`] and is bounded the same way: by
//! whatever confines the process (ADR-0004, ADR-0013), not by a check in here.
//! An interceptor carries no such exposure, which is the other half of why the
//! host's own code speaks first.
//!
//! What a hook is *handed* is narrower than what it can reach. Its environment
//! is basis's baseline — `PATH`, `HOME`, the temp and locale variables that
//! make a program runnable, listed with their reasons in `crate::subprocess` —
//! and nothing else this process holds: not the provider key the run was
//! opened with, not a token the host exported. A hook is asked a question, not
//! handed a credential; the declared-tool binding is where a manifest names
//! the variables its program needs. This is hygiene rather than confinement: a
//! hook can still read `~/.netrc` with the account's authority. What stops
//! arriving is the *ambient* credential, the one nobody decided to pass.
pub use ;
pub use ;
pub use ;
pub use HookRunner;
pub use ;