code wiki / (root) / nx_tool_exec_allow.nx

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