nx_tool_exec_allow.nx source
↩ module page · 330 lines · 18622 B
1// nx_tool_exec_allow.nx -- R1 of the executable-API rung: the EXECUTION allowlist that gates R0's raw
2// exec+capture primitive (nx_tool_run) so /mcp tools/call can only ever run VETTED, gate-GREEN organs.
3// This is the never-brick + no-confused-deputy boundary: a caller passes a tool NAME (already capability-
4// authorized upstream in ta_mcp_call); this layer maps that name -> an absolute ELF path ONLY if the name
5// matches an explicit allowlist row whose gate-status is the literal "GREEN". A caller can NEVER supply a
6// path, a "..", or a shell string -- those simply do not resolve.
7//
8// SEPARATE from nx_tool_registry.nx (the DISCOVERY registry that feeds /api/tools + tools/list): discovery
9// answers "what tools exist"; this answers "which of them may be EXECUTED, and from which exact ELF". Kept
10// apart on purpose -- listing a tool must never imply it is runnable. Data-driven (rule 11): the operator
11// curates `tool_allowlist.conf`, so vetting a tool for execution is a config edit + a gate run, not a code
12// change. Missing file / missing row / non-GREEN row -> refused (fail-closed).
13// Row format (TAB-separated; '#' comment lines and blank lines ignored):
14// <tool_name>\t<absolute_elf_path>\t<gate_status>
15// license_tier: ORIGINAL
16import "nx_tool_run.nx" // brings nx_syscalls (sys_read_file/sys_mmap/...) + tr_* transitively
17const TEA_MAGIC_4096: i64 = 4096
18const TEA_MAGIC_262144: i64 = 262144
19
20const TEA_CONF: *u8 = "tool_allowlist.conf" as *u8 // relative to the server CWD (same convention as cap_revoked.list)
21
22// resolve codes
23const TEA_OK: i64 = 1 // allowlisted AND gate-GREEN -> out_path filled, runnable
24const TEA_BLOCKED: i64 = 0 // allowlisted but gate-status != GREEN -> present, NOT runnable
25const TEA_NOTFOUND: i64 = 0 - 1 // no such tool in the allowlist
26const TEA_NOCONF: i64 = 0 - 2 // allowlist file absent/unreadable (fail-closed)
27
28// bytes [s,e) of buf equal the NUL-terminated key (exact length match)? -- for fixed literals (e.g. "GREEN").
29func tea_field_eq(buf: *u8, s: i64, e: i64, key: *u8) -> i64 {
30 var i: i64 = s
31 var k: i64 = 0
32 while i < e { if buf[i] != key[k] { return 0 } i = i + 1; k = k + 1 }
33 if key[k] != (0 as u8) { return 0 }
34 return 1
35}
36
37// bytes [s,e) of buf equal name[0..nlen)? -- LENGTH-EXPLICIT on both sides, so it is correct whether `name`
38// is a NUL-terminated literal (gate) OR a non-terminated slice into an HTTP body (the live API path). This is
39// the comparison used to match the caller's tool name against an allowlist row.
40func tea_field_eq_n(buf: *u8, s: i64, e: i64, name: *u8, nlen: i64) -> i64 {
41 if e - s != nlen { return 0 }
42 var i: i64 = 0
43 while i < nlen { if buf[s + i] != name[i] { return 0 } i = i + 1 }
44 return 1
45}
46
47// tea_name_ok: defence-in-depth on the NAME before it ever indexes the allowlist -- reject empty, over-long,
48// or anything containing '/', NUL, or whitespace. (The allowlist match already makes traversal impossible;
49// this refuses obviously-hostile names early with a clean verdict.)
50func tea_name_ok(name: *u8, nlen: i64) -> i64 {
51 if nlen <= 0 { return 0 }
52 if nlen > 128 { return 0 }
53 var i: i64 = 0
54 while i < nlen {
55 let c: u8 = name[i]
56 if c == (0x2f as u8) { return 0 } // '/'
57 if c == (0x00 as u8) { return 0 }
58 if c == (0x20 as u8) { return 0 } // space
59 if c == (0x09 as u8) { return 0 } // tab
60 if c == (0x0a as u8) { return 0 } // newline
61 i = i + 1
62 }
63 return 1
64}
65
66// tea_resolve_from: like tea_resolve but reads the allowlist from an explicit `conf` path (testable; the
67// production wrapper tea_resolve pins conf = TEA_CONF). On a GREEN match, copy the ELF path (NUL-terminated)
68// into out_path (bounded by out_cap) and return TEA_OK. Otherwise return a negative/blocked code and leave
69// out_path an empty string.
70func tea_resolve_from(conf: *u8, name: *u8, nlen: i64, out_path: *u8, out_cap: i64) -> i64 {
71 out_path[0] = 0 as u8
72 if tea_name_ok(name, nlen) == 0 { return TEA_NOTFOUND }
73 let szp: *i64 = sys_mmap(16) as *i64
74 let buf: *u8 = sys_read_file(conf, szp)
75 if (buf as i64) == 0 { return TEA_NOCONF }
76 let n: i64 = szp[0]
77 var found: i64 = TEA_NOTFOUND
78 var ls: i64 = 0
79 var i: i64 = 0
80 while i <= n {
81 var eol: i64 = 0
82 if i == n { eol = 1 } else { if buf[i] == (0x0a as u8) { eol = 1 } }
83 if eol == 1 {
84 let le: i64 = i // line = buf[ls, le)
85 if le > ls { if buf[ls] != (0x23 as u8) { // skip blank + '#' comment
86 // field 0 = name: [ls, f0e)
87 var f0e: i64 = ls
88 while f0e < le { if buf[f0e] == (0x09 as u8) { break } f0e = f0e + 1 }
89 if f0e < le { if tea_field_eq_n(buf, ls, f0e, name, nlen) == 1 {
90 // field 1 = path: [p0, f1e)
91 let p0: i64 = f0e + 1
92 var f1e: i64 = p0
93 while f1e < le { if buf[f1e] == (0x09 as u8) { break } f1e = f1e + 1 }
94 // field 2 = status: [s0, le)
95 let s0: i64 = f1e + 1
96 if tea_field_eq(buf, s0, le, "GREEN" as *u8) == 1 {
97 var w: i64 = 0
98 var pp: i64 = p0
99 while pp < f1e { if w < out_cap - 1 { out_path[w] = buf[pp]; w = w + 1 } pp = pp + 1 }
100 out_path[w] = 0 as u8
101 return TEA_OK
102 }
103 found = TEA_BLOCKED
104 } }
105 } }
106 ls = i + 1
107 }
108 i = i + 1
109 }
110 return found
111}
112
113// tea_resolve: production wrapper -- pins conf = TEA_CONF ("tool_allowlist.conf" in the server CWD).
114func tea_resolve(name: *u8, nlen: i64, out_path: *u8, out_cap: i64) -> i64 {
115 return tea_resolve_from(TEA_CONF, name, nlen, out_path, out_cap)
116}
117
118// BOUNDED EXEC (seq1442). Every path below runs a vetted organ under a DEADLINE, because this module is
119// the tools-daemon's exec path: an organ that hangs here does not just fail its own call, it wedges the
120// daemon's tools/call for EVERY MCP client, and the client cannot cancel it. A refused-late call is
121// recoverable; a wedged daemon is not.
122//
123// 120s: long jobs are supposed to take the _async=1 detached job lane, so a SYNCHRONOUS tools/call that
124// has not finished in two minutes has already lost its client. Named, not buried -- and deliberately
125// generous so it can only ever catch a genuine hang, never a merely slow organ.
126// NEXT: this is one global number for ~531 tools of wildly different cost. The honest end state is a
127// per-tool budget carried as a 5th allowlist field, derived from what each organ actually does.
128const TEA_EXEC_TIMEOUT_MS: i64 = 120000
129
130// ---- LANE-SCOPED EXEC BUDGETS (root fix 2026-07-30, ws=sev-eater) -----------------------------
131// MEASURED THIS SESSION, not inferred: the edge (sites.elf) abandons a proxied request at exactly
132// 15.02s, while a worker slot stayed held for the full 120s above. That is not one number being
133// wrong -- it is ONE GLOBAL TIMEOUT SERVING TWO LANES WITH OPPOSITE REQUIREMENTS. The synchronous
134// /mcp lane must give up INSIDE the edge window, because after it nobody is listening and the slot
135// is capacity spent on an answer that can never be delivered. The DETACHED async job lane must be
136// allowed to run long -- that is the entire reason it exists. Tuned for async, the sync lane
137// inherited a budget 8x its client's patience, so against a bounded 16-worker pool a trickle of
138// hung calls saturated the whole agent surface for every seat: 10 stuck handler pairs measured,
139// 4 of 8 requests 503ing, /proc showing the handler in pipe_wait behind an nx_mgmt_call parked in
140// sk_wait_data on a mgmt reply that never came.
141// LAW, already banked in this codebase for the reader/writer cap pair and it generalises: the two
142// ends of one pipe are a MATCHED PAIR. A timeout longer than the caller's patience is not safety,
143// it is capacity burned on nobody's behalf.
144// The budget is therefore a property of the LANE. TEA_EXEC_TIMEOUT_MS is left EXACTLY as it was for
145// the async/detached lane and all other existing callers (rule 19: no contract change); the sync
146// lane passes TEA_EXEC_TIMEOUT_SYNC_MS through the *_to entry points below. SYNC is DERIVED from
147// the window rather than typed as its own literal so the two can never drift apart (rule 11), and
148// it lands just inside so the DAEMON decides the failure and can answer before the edge gives up.
149const TEA_EDGE_WINDOW_MS: i64 = 15000
150const TEA_EXEC_TIMEOUT_SYNC_MS: i64 = TEA_EDGE_WINDOW_MS - 1000
151
152// tea_run_from: the composed R0+R1 call against an explicit conf -- resolve `name` through the allowlist,
153// then (only on TEA_OK) execute the vetted ELF via nx_tool_run's capture primitive with a single optional
154// arg. Returns the child exit code on success, or the negative TEA_* refusal code (so the API can map it to
155// a JSON-RPC error). *outlen receives captured byte count; *rc (if non-null) receives the resolve code.
156func tea_run_from(conf: *u8, name: *u8, nlen: i64, arg: *u8, out: *u8, out_cap: i64, outlen: *i64, rc: *i64) -> i64 {
157 let path: *u8 = sys_mmap(TEA_MAGIC_4096)
158 let code: i64 = tea_resolve_from(conf, name, nlen, path, TEA_MAGIC_4096)
159 if (rc as i64) != 0 { rc[0] = code }
160 if code != TEA_OK { if (outlen as i64) != 0 { outlen[0] = 0 } return code }
161 return tr_run1_to(path, arg, out, out_cap, outlen, TEA_EXEC_TIMEOUT_MS)
162}
163
164// tea_run: production wrapper -- pins conf = TEA_CONF.
165func tea_run(name: *u8, nlen: i64, arg: *u8, out: *u8, out_cap: i64, outlen: *i64, rc: *i64) -> i64 {
166 return tea_run_from(TEA_CONF, name, nlen, arg, out, out_cap, outlen, rc)
167}
168
169// tea_run_argv_from: like tea_run_from but executes with a FULL argv VECTOR (multi-arg tools/call). The caller
170// fills argv[1..] with the tool's positional args and NUL-terminates the vector; argv[0] is (re)set HERE to the
171// resolved absolute ELF path (the argv[0] convention). Resolution is unchanged -- only a GREEN-allowlisted NAME
172// resolves, so a caller still can never smuggle a path, "..", or shell string through argv[0]. Returns the child
173// exit code on TEA_OK, else the negative TEA_* refusal code. *outlen = captured bytes; *rc = the resolve code.
174func tea_run_argv_from(conf: *u8, name: *u8, nlen: i64, argv: *i64, out: *u8, out_cap: i64, outlen: *i64, rc: *i64) -> i64 {
175 let path: *u8 = sys_mmap(TEA_MAGIC_4096)
176 let code: i64 = tea_resolve_from(conf, name, nlen, path, TEA_MAGIC_4096)
177 if (rc as i64) != 0 { rc[0] = code }
178 if code != TEA_OK { if (outlen as i64) != 0 { outlen[0] = 0 } return code }
179 argv[0] = path as i64
180 return tr_run_capture_to(path, argv, out, out_cap, outlen, TEA_EXEC_TIMEOUT_MS)
181}
182
183// tea_run_argv: production wrapper -- pins conf = TEA_CONF.
184func tea_run_argv(name: *u8, nlen: i64, argv: *i64, out: *u8, out_cap: i64, outlen: *i64, rc: *i64) -> i64 {
185 return tea_run_argv_from(TEA_CONF, name, nlen, argv, out, out_cap, outlen, rc)
186}
187
188// ---- FIXED-ARG (pinned) execution ---------------------------------------------------------------------------
189// A GREEN allowlist row MAY carry a 4th TAB field = the tool's PINNED argv[1..] (space-separated). When present,
190// the caller's args are IGNORED and the tool runs with EXACTLY those fixed args. This safely exposes a multi-sub
191// control binary as a tool whose subcommand is FIXED: a cap for `nx_status` runs `nx_hostctl status` and can NEVER
192// run `nx_hostctl selfswap` -- the caller controls the tool NAME (cap-gated), never the argv. No 4th field -> the
193// caller's argv is used. Row: <name>\t<abs-elf>\t<GREEN>[\t<pinned args>]
194
195// bounded config read (rule 21): a config never needs sys_read_file's 4GB reservation; reading that per-request
196// accumulates reservations that starve fork() on a small VM. 256KB is ample for any allowlist.
197func tea_read_conf(path: *u8, out_len: *i64) -> *u8 {
198 out_len[0] = 0
199 let fd: i64 = sys_openat_rd(path)
200 if fd < 0 { return 0 as *u8 }
201 let cap: i64 = TEA_MAGIC_262144
202 let buf: *u8 = sys_mmap(cap + 16)
203 var total: i64 = 0; var go: i64 = 1
204 while go == 1 {
205 if total >= cap { go = 0 } else {
206 let r: i64 = sys_read(fd, ((buf as i64) + total) as *u8, cap - total)
207 if r <= 0 { go = 0 } else { total = total + r }
208 }
209 }
210 sys_close(fd)
211 let term: *u8 = ((buf as i64) + total) as *u8
212 term[0] = 0 as u8
213 out_len[0] = total
214 return buf
215}
216
217// tokenize NUL-terminated `s` by spaces into argv[base..]; each token NUL-terminated into `scratch`. Returns count.
218func tea_tokenize(s: *u8, argv: *i64, base: i64, scratch: *u8, scap: i64) -> i64 {
219 var k: i64 = 0; var w: i64 = 0; var i: i64 = 0; var intok: i64 = 0; var done: i64 = 0
220 while done == 0 {
221 let c: i64 = s[i] as i64
222 if c == 0 {
223 if intok == 1 { if w < scap { scratch[w] = 0 as u8; w = w + 1 } }
224 done = 1
225 } else {
226 if c == 32 {
227 if intok == 1 { if w < scap { scratch[w] = 0 as u8; w = w + 1 } intok = 0 }
228 } else {
229 if intok == 0 { let st: i64 = (scratch as i64) + w; argv[base + k] = st; k = k + 1; intok = 1 }
230 if w < scap - 1 { scratch[w] = c as u8; w = w + 1 }
231 }
232 i = i + 1
233 }
234 }
235 return k
236}
237
238// resolve a GREEN row -> path (field 1) AND its optional pinned args (field 3). out_haspin[0]=1 iff a non-empty 4th
239// field exists. Parses status as field 2 up to the NEXT tab (so a 4th field doesn't corrupt the GREEN match).
240func tea_resolve_pinned_from(conf: *u8, name: *u8, nlen: i64, out_path: *u8, out_cap: i64, out_pin: *u8, pin_cap: i64, out_haspin: *i64) -> i64 {
241 out_path[0] = 0 as u8; out_pin[0] = 0 as u8; out_haspin[0] = 0
242 if tea_name_ok(name, nlen) == 0 { return TEA_NOTFOUND }
243 let szp: *i64 = sys_mmap(16) as *i64
244 let buf: *u8 = tea_read_conf(conf, szp)
245 if (buf as i64) == 0 { return TEA_NOCONF }
246 let n: i64 = szp[0]
247 var found: i64 = TEA_NOTFOUND
248 var ls: i64 = 0
249 var i: i64 = 0
250 while i <= n {
251 var eol: i64 = 0
252 if i == n { eol = 1 } else { if buf[i] == (0x0a as u8) { eol = 1 } }
253 if eol == 1 {
254 let le: i64 = i
255 if le > ls { if buf[ls] != (0x23 as u8) {
256 var f0e: i64 = ls
257 while f0e < le { if buf[f0e] == (0x09 as u8) { break } f0e = f0e + 1 }
258 if f0e < le { if tea_field_eq_n(buf, ls, f0e, name, nlen) == 1 {
259 let p0: i64 = f0e + 1
260 var f1e: i64 = p0
261 while f1e < le { if buf[f1e] == (0x09 as u8) { break } f1e = f1e + 1 }
262 let s0: i64 = f1e + 1
263 var s2e: i64 = s0
264 while s2e < le { if buf[s2e] == (0x09 as u8) { break } s2e = s2e + 1 }
265 if tea_field_eq(buf, s0, s2e, "GREEN" as *u8) == 1 {
266 var w: i64 = 0
267 var pp: i64 = p0
268 while pp < f1e { if w < out_cap - 1 { out_path[w] = buf[pp]; w = w + 1 } pp = pp + 1 }
269 out_path[w] = 0 as u8
270 if s2e < le {
271 let p3: i64 = s2e + 1
272 var pw: i64 = 0
273 var qq: i64 = p3
274 while qq < le { if pw < pin_cap - 1 { out_pin[pw] = buf[qq]; pw = pw + 1 } qq = qq + 1 }
275 out_pin[pw] = 0 as u8
276 if pw > 0 { out_haspin[0] = 1 }
277 }
278 return TEA_OK
279 }
280 found = TEA_BLOCKED
281 } }
282 } }
283 ls = i + 1
284 }
285 i = i + 1
286 }
287 return found
288}
289
290// tea_run_pinned_from: pinning-aware exec. If the row pins args, build argv from them (caller args IGNORED); else
291// use the caller's argv (argv[0] set to the resolved path). Returns the child exit code / negative TEA_* code.
292// ★★★seq1611/1634 ROOT CAUSE: A DETACHED JOB MUST NOT INHERIT THE SYNCHRONOUS
293// REQUEST TIMEOUT. TEA_EXEC_TIMEOUT_MS (120s) is correct for a tools/call a
294// caller is waiting on, and wrong BY CONSTRUCTION for an async job whose entire
295// purpose is to outlive that window. The codewiki regen takes 3-6 minutes and
296// was killed at 2 minutes EVERY time, returning a negative rc that rendered as
297// an EMPTY exit slot -- which read like success to a careless poller.
298// tmo=0 selects the untimed path (tr_run_capture_to falls through).
299// ★The bound was not too SMALL; a fixed bound was the wrong SHAPE for this
300// caller -- the bounds law again: when a bound bites, change the architecture.
301func tea_run_pinned_from_to(conf: *u8, name: *u8, nlen: i64, callerargv: *i64, out: *u8, out_cap: i64, outlen: *i64, rc: *i64, tmo: i64) -> i64 {
302 let path: *u8 = sys_mmap(TEA_MAGIC_4096)
303 let pin: *u8 = sys_mmap(TEA_MAGIC_4096)
304 let haspin: *i64 = sys_mmap(16) as *i64
305 let code: i64 = tea_resolve_pinned_from(conf, name, nlen, path, TEA_MAGIC_4096, pin, TEA_MAGIC_4096, haspin)
306 if (rc as i64) != 0 { rc[0] = code }
307 if code != TEA_OK { if (outlen as i64) != 0 { outlen[0] = 0 } return code }
308 if haspin[0] == 1 {
309 let pav: *i64 = sys_mmap(TEA_MAGIC_4096) as *i64
310 let psc: *u8 = sys_mmap(TEA_MAGIC_4096)
311 pav[0] = path as i64
312 let nt: i64 = tea_tokenize(pin, pav, 1, psc, TEA_MAGIC_4096)
313 pav[1 + nt] = 0
314 return tr_run_capture_to(path, pav, out, out_cap, outlen, tmo)
315 }
316 callerargv[0] = path as i64
317 return tr_run_capture_to(path, callerargv, out, out_cap, outlen, tmo)
318}
319// back-compat wrappers: every existing SYNCHRONOUS caller keeps the 120s
320// request timeout byte-for-byte; only a caller that asks for tmo gets another.
321func tea_run_pinned_from(conf: *u8, name: *u8, nlen: i64, callerargv: *i64, out: *u8, out_cap: i64, outlen: *i64, rc: *i64) -> i64 {
322 return tea_run_pinned_from_to(conf, name, nlen, callerargv, out, out_cap, outlen, rc, TEA_EXEC_TIMEOUT_MS)
323}
324func tea_run_pinned_to(name: *u8, nlen: i64, callerargv: *i64, out: *u8, out_cap: i64, outlen: *i64, rc: *i64, tmo: i64) -> i64 {
325 return tea_run_pinned_from_to(TEA_CONF, name, nlen, callerargv, out, out_cap, outlen, rc, tmo)
326}
327func tea_run_pinned(name: *u8, nlen: i64, callerargv: *i64, out: *u8, out_cap: i64, outlen: *i64, rc: *i64) -> i64 {
328 return tea_run_pinned_from_to(TEA_CONF, name, nlen, callerargv, out, out_cap, outlen, rc, TEA_EXEC_TIMEOUT_MS)
329}
330