code wiki / (root) / nx_tool_run.nx

nx_tool_run.nx source

↩ module page · 195 lines · 9340 B

1// nx_tool_run.nx -- R0 of the EXECUTABLE-API rung: the sovereign exec+capture primitive the ecosystem 2// is missing. Today nishifamily.com/api/tools + /mcp only LIST tools (a read-only registry) and MCP 3// tools/call returns a SAFE STUB ("invoked X (capability-authorized)") -- there is NO way to actually 4// RUN an organ and hand back its real stdout. This organ is that missing capability, built hardware-up 5// from raw syscalls (fork/pipe/dup3/execve/wait4), reusing the EXACT proven wrappers from nx_hostctl's 6// hc_dfork_exec so it inherits the same never-brick discipline. NO /bin/sh, NO shell string, NO PATH 7// search -- callers pass an ABSOLUTE ELF path (the allowlist layer that maps tool-name -> path is R1, 8// nx_tool_registry). Synchronous (wait4), unlike hc_dfork_exec's detach -- because an API tools/call 9// needs the child's OUTPUT and EXIT CODE, not a fire-and-forget daemon. 10// license_tier: ORIGINAL 11import "nx_syscalls.nx" 12 13// tr_run_capture: fork -> child wires its stdout(+stderr) to a pipe and execve's `path` with `argv` 14// (a NUL-terminated *i64 array of *u8-as-i64, argv[0] conventionally = path) -> parent closes the write 15// end, drains the pipe into out[0..cap), wait4's the child, and returns wait_exit_code (0..255), or a 16// negative sentinel on a harness failure. On child execve failure the child exits 127 (captured as such). 17// *outlen (if non-null) receives the number of bytes captured. 18// 19// never-brick: path is an absolute ELF chosen by the caller's allowlist; a bad path can only produce a 20// 127 exit + empty capture, never a shell injection and never a write to persistent hardware state. 21func tr_run_capture(path: *u8, argv: *i64, out: *u8, cap: i64, outlen: *i64) -> i64 { 22 if (outlen as i64) != 0 { outlen[0] = 0 } 23 let fds: *i64 = sys_mmap(16) as *i64 24 if sys_pipe2(fds, 0) != 0 { return 0 - 2 } // TR_ERR_PIPE 25 // pipe2 writes int[2] (TWO 32-bit fds) into the first 8 bytes -> read end = low 32 bits of fds[0], 26 // write end = high 32 bits. Reading them as two i64 slots leaves the write fd un-tracked (never closed), 27 // so the reader never sees EOF and blocks forever. Unpack the 32-bit fds explicitly. 28 let packed: i64 = fds[0] 29 let rfd: i64 = packed & 0xFFFFFFFF 30 let wfd: i64 = (packed >> 32) & 0xFFFFFFFF 31 32 let pid: i64 = sys_fork() 33 if pid < 0 { sys_close(rfd); sys_close(wfd); return 0 - 3 } // TR_ERR_FORK 34 if pid == 0 { 35 // ---- CHILD ---- wire stdout(1) + stderr(2) to the pipe write end, close both raw ends, exec. 36 sys_dup3(wfd, 1, 0) 37 sys_dup3(wfd, 2, 0) 38 sys_close(rfd) 39 sys_close(wfd) 40 var fdc: i64 = 3 41 while fdc < 256 { sys_close(fdc); fdc = fdc + 1 } 42 let envp: *i64 = sys_mmap(16) as *i64 43 envp[0] = "PATH=/usr/bin:/bin" as *u8 as i64 44 envp[1] = 0 45 sys_execve_clean(path, argv, envp) 46 sys_exit(127) // execve failed (bad path / not ELF) -> 127, parent captures it 47 return 0 48 } 49 50 // ---- PARENT ---- close the write end (so read() sees EOF when the child exits), drain the pipe. 51 sys_close(wfd) 52 var total: i64 = 0 53 var run: i64 = 1 54 while run == 1 { 55 if total >= cap { run = 0 } else { 56 let r: i64 = sys_read(rfd, ((out as i64) + total) as *u8, cap - total) 57 if r <= 0 { run = 0 } else { total = total + r } 58 } 59 } 60 sys_close(rfd) 61 if (outlen as i64) != 0 { outlen[0] = total } 62 63 let stp: *i64 = sys_mmap(16) as *i64 64 let w: i64 = sys_wait4(pid, stp, 0) 65 if w < 0 { return 0 - 4 } // TR_ERR_WAIT 66 return wait_exit_code(stp[0]) 67} 68 69// ---- BOUNDED EXEC (seq1412) ---------------------------------------------------------------- 70// tr_run_capture has NO timeout: the parent blocks in the read() drain until EOF. A child that never 71// exits -- or that forks something holding stdout open -- hangs its caller forever. That primitive has 72// 51+ call sites including nx_seat (every session boot), nx_gate_rollup, and the tools-daemon exec path 73// (tea_run), so ONE hanging organ can wedge tools/call for every MCP client. 74// 75// ADDITIVE ON PURPOSE: tr_run_capture's signature and behaviour are untouched, so none of those 51 76// callers change. New/critical callers opt in here. 77// 78// WHY A WATCHDOG FORK AND NOT A POLL LOOP: bounding the drain would need O_NONBLOCK on the read end, 79// and there is no sys_fcntl in nx_syscalls -- so the non-blocking design is not buildable. Instead a 80// second child sleeps the deadline and SIGKILLs the worker; the dying worker drops the last write end, 81// the parent's blocking read() gets its EOF naturally, and no new syscall is required. 82// 83// ORDERING IS LOAD-BEARING: the parent MUST close wfd BEFORE forking the watchdog. Fork it first and 84// the watchdog inherits the write end, so the pipe never reaches EOF even after the worker dies -- the 85// exact hang this exists to remove, reintroduced by the fix. 86const TR_ERR_TIMEOUT: i64 = 0 - 5 87const TR_SIGKILL: i64 = 9 88 89// timeout_ms <= 0 -> delegates to the unbounded tr_run_capture (explicit opt-out, never a silent one). 90// Returns the child's exit code, TR_ERR_TIMEOUT if the deadline fired, or the -2/-3/-4 harness sentinels. 91func tr_run_capture_to(path: *u8, argv: *i64, out: *u8, cap: i64, outlen: *i64, timeout_ms: i64) -> i64 { 92 if timeout_ms <= 0 { return tr_run_capture(path, argv, out, cap, outlen) } 93 if (outlen as i64) != 0 { outlen[0] = 0 } 94 let fds: *i64 = sys_mmap(16) as *i64 95 if sys_pipe2(fds, 0) != 0 { return 0 - 2 } 96 let packed: i64 = fds[0] 97 let rfd: i64 = packed & 0xFFFFFFFF 98 let wfd: i64 = (packed >> 32) & 0xFFFFFFFF 99 100 let pid: i64 = sys_fork() 101 if pid < 0 { sys_close(rfd); sys_close(wfd); return 0 - 3 } 102 if pid == 0 { 103 // seq1463: clean signal slate for the child -- SIG_IGN is inherited across fork AND 104 // execve, so a gate launched by a SIGPIPE-ignoring daemon cannot observe its own 105 // disease-control. A harness must not change the state it verifies. 106 sys_default_signal(13) 107 sys_dup3(wfd, 1, 0) 108 sys_dup3(wfd, 2, 0) 109 sys_close(rfd) 110 sys_close(wfd) 111 var fdc: i64 = 3 112 while fdc < 256 { sys_close(fdc); fdc = fdc + 1 } 113 let envp: *i64 = sys_mmap(16) as *i64 114 envp[0] = "PATH=/usr/bin:/bin" as *u8 as i64 115 envp[1] = 0 116 sys_execve_clean(path, argv, envp) 117 sys_exit(127) 118 return 0 119 } 120 121 // the worker must be the ONLY holder of the write end before the watchdog exists (see above). 122 sys_close(wfd) 123 124 let wd: i64 = sys_fork() 125 if wd == 0 { 126 sys_close(rfd) // never hold the read end either 127 sys_sleep_ms(timeout_ms) 128 nx_kill(pid, TR_SIGKILL) 129 sys_exit(0) 130 return 0 131 } 132 133 var total: i64 = 0 134 var run: i64 = 1 135 while run == 1 { 136 if total >= cap { run = 0 } else { 137 let r: i64 = sys_read(rfd, ((out as i64) + total) as *u8, cap - total) 138 if r <= 0 { run = 0 } else { total = total + r } 139 } 140 } 141 sys_close(rfd) 142 if (outlen as i64) != 0 { outlen[0] = total } 143 144 let stp: *i64 = sys_mmap(16) as *i64 145 let w: i64 = sys_wait4(pid, stp, 0) 146 147 // Retire the watchdog whether or not it fired. Leaving it asleep would let it SIGKILL a RECYCLED 148 // pid later -- a timeout that kills an unrelated process is worse than no timeout at all. 149 if wd > 0 { 150 nx_kill(wd, TR_SIGKILL) 151 let wstp: *i64 = sys_mmap(16) as *i64 152 sys_wait4(wd, wstp, 0) 153 } 154 155 if w < 0 { return 0 - 4 } 156 // low 7 bits of the status word = terminating signal. SIGKILL here means the watchdog fired. 157 // (An external SIGKILL -- e.g. the OOM killer -- also reads as TIMEOUT; both mean "did not finish".) 158 let termsig: i64 = stp[0] & 0x7F 159 if termsig == TR_SIGKILL { return TR_ERR_TIMEOUT } 160 return wait_exit_code(stp[0]) 161} 162 163// tr_run1: convenience for the common "run ELF with a single string arg" case. Builds argv = [path, arg, 0]. 164// arg may be null -> argv = [path, 0]. 165func tr_run1(path: *u8, arg: *u8, out: *u8, cap: i64, outlen: *i64) -> i64 { 166 let argv: *i64 = sys_mmap(32) as *i64 167 argv[0] = path as i64 168 if (arg as i64) == 0 { argv[1] = 0 } else { argv[1] = arg as i64; argv[2] = 0 } 169 return tr_run_capture(path, argv, out, cap, outlen) 170} 171 172// tr_run1_to: bounded twin of tr_run1. Same argv shaping, with a deadline. 173func tr_run1_to(path: *u8, arg: *u8, out: *u8, cap: i64, outlen: *i64, timeout_ms: i64) -> i64 { 174 let argv: *i64 = sys_mmap(32) as *i64 175 argv[0] = path as i64 176 if (arg as i64) == 0 { argv[1] = 0 } else { argv[1] = arg as i64; argv[2] = 0 } 177 return tr_run_capture_to(path, argv, out, cap, outlen, timeout_ms) 178} 179 180// tr_contains: 1 if the NUL-terminated needle occurs in buf[0..n), else 0. For gates asserting on captured stdout. 181func tr_contains(buf: *u8, n: i64, needle: *u8) -> i64 { 182 var nl: i64 = 0 183 while needle[nl] != (0 as u8) { nl = nl + 1 } 184 if nl == 0 { return 1 } 185 if n < nl { return 0 } 186 var i: i64 = 0 187 while i <= n - nl { 188 var m: i64 = 1 189 var c: i64 = 0 190 while c < nl { if buf[i + c] != needle[c] { m = 0; c = nl } else { c = c + 1 } } 191 if m == 1 { return 1 } 192 i = i + 1 193 } 194 return 0 195}