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}