code wiki / _hdl_build / nx_retire_path.nx

nx_retire_path.nx source

↩ module page · 218 lines · 12053 B

1// nx_retire_path.nx -- the missing sovereign primitive: take a file or directory OUT of a served tree 2// without ever deleting it. The estate had governance/atlas/debt sweeps but nothing that could retire a 3// path, so scratch artifacts accumulated on PUBLIC surfaces and the only "fix" anyone had was ssh rm. 4// 5// SAFE BY CONSTRUCTION (mirrors the established md_promote_deny substring-deny pattern rather than 6// inventing a new safety model): 7// * NEVER deletes -- sys_renameat only, so the bytes survive and the move is reversible (rule 13). 8// * moves INTO knowledge/retired/ (outside every docroot) so a retired path stops being served. 9// * REFUSES: any path containing ".." (traversal), a bare top-level name (must be >=2 segments deep, 10// so it can never retire a whole docroot or a hub-root file), and any path whose name carries a 11// dangerous substring (elf, key, cap, secret, opaque, .reg, .conf, daemon, serve, vault, mint). 12// * REFUSES a target that does not exist -- a no-op must never look like a success. 13// verbs: retire <path> | selftest 14// expect_exit: 0 license_tier: ORIGINAL 15import "nx_syscalls.nx" 16import "nx_emit_guard.nx" 17 18const RP_NAMEBUF: i64 = 1024 19 20func rt_say(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 } 21func rt_num(v: i64) -> i64 { if v == 0 { sys_write(1, "0" as *u8, 1); return 0 } var m: i64 = v; if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m } let t: *u8 = sys_mmap(28); var k: i64 = 0; while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } while k > 0 { k = k - 1; sys_write(1, (((t as i64) + k) as *u8), 1) } return 0 } 22 23func rt_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 24// case-sensitive substring test (the deny list is lowercase and so are our paths) 25func rt_has(s: *u8, lit: *u8) -> i64 { 26 let sl: i64 = rt_len(s) 27 let ll: i64 = rt_len(lit) 28 if ll == 0 { return 0 } 29 var i: i64 = 0 30 while i + ll <= sl { 31 var j: i64 = 0 32 var ok: i64 = 1 33 while j < ll { if s[i + j] != lit[j] { ok = 0; j = ll } else { j = j + 1 } } 34 if ok == 1 { return 1 } 35 i = i + 1 36 } 37 return 0 38} 39// does s end with lit? (suffix test -- the staged-artifact exception below is a SUFFIX rule, never a 40// substring rule: a substring test would also match a LIVE path that merely mentions '.elf.new') 41func rt_ends(s: *u8, lit: *u8) -> i64 { 42 let sl: i64 = rt_len(s) 43 let ll: i64 = rt_len(lit) 44 if ll == 0 { return 0 } 45 if ll > sl { return 0 } 46 var i: i64 = 0 47 while i < ll { if s[sl - ll + i] != lit[i] { return 0 } i = i + 1 } 48 return 1 49} 50// SECRET-MATERIAL guards, applied to EVERY path including the staged-artifact exception. These are the 51// checks that protect CREDENTIALS; they are deliberately separate from the artifact-TYPE checks (.elf/ 52// .conf/.reg) and the depth rule, because those two protect LIVE-SERVING paths -- a distinction the 53// staged exception depends on. 54func rt_refuse_secret(path: *u8) -> i64 { 55 if rt_has(path, ".key" as *u8) == 1 { return 4 } 56 if rt_has(path, ".cap" as *u8) == 1 { return 4 } 57 if rt_has(path, "secret" as *u8) == 1 { return 4 } 58 if rt_has(path, "opaque" as *u8) == 1 { return 4 } 59 if rt_has(path, "vault" as *u8) == 1 { return 4 } 60 if rt_has(path, "mint" as *u8) == 1 { return 4 } 61 return 0 62} 63func rt_slashes(s: *u8) -> i64 { 64 var c: i64 = 0 65 var i: i64 = 0 66 while s[i] != (0 as u8) { if s[i] == (47 as u8) { c = c + 1 } i = i + 1 } 67 return c 68} 69// 0 = allowed, otherwise a refusal code naming the reason 70func rt_refuse(path: *u8) -> i64 { 71 if rt_len(path) < 3 { return 1 } 72 if rt_has(path, ".." as *u8) == 1 { return 2 } 73 // ---- STAGED-ARTIFACT EXCEPTION (2026-08-03, debt 1785771205) ---------------------------------- 74 // WHY IT IS SAFE, not a loosened guard: `<name>.elf.new` is a STAGED artifact. The LIVE binary is 75 // `<name>.elf` (or the extensionless `<name>`), so a `.elf.new` is BY CONSTRUCTION not serving any 76 // request -- retiring one can never stop a running service, which is exactly what the depth rule and 77 // the `.elf` name rule exist to prevent. Those two guards protect LIVE-SERVING paths; they were 78 // ALSO, as a side effect, making the estate's 512 stray staged artifacts unretirable -- including a 79 // 202,843 B `nx_hostctl.elf.new` sitting in the SUPERVISOR's deploy slot matching neither the live 80 // binary nor any source build. A guard that blocks the cleanup of the very hazard class it cannot 81 // see is protecting the wrong noun. 82 // SUFFIX, never substring: a live path that merely CONTAINS '.elf.new' is not exempted. 83 // Secret-material checks STILL APPLY (a `*.key.new` stays refused), and rename-not-delete means the 84 // bytes survive in knowledge/retired/ -- so a staged artifact retired in error is restorable. 85 if rt_ends(path, ".elf.new" as *u8) == 1 { return rt_refuse_secret(path) } 86 // ...and the BANKED twin. /api/build now renames an existing staged artifact to `<t>.sov.elf.new.prev` 87 // before recompiling (debt 1785772241) -- a remedy that CREATES artifacts must also create the means to 88 // sweep them, or the fix becomes the next litter source. Same reasoning as above: a banked staged copy 89 // is even further from live than the staged one, so it can never be serving anything. 90 if rt_ends(path, ".elf.new.prev" as *u8) == 1 { return rt_refuse_secret(path) } 91 if rt_slashes(path) < 2 { return 3 } 92 if rt_has(path, ".elf" as *u8) == 1 { return 4 } 93 if rt_has(path, ".key" as *u8) == 1 { return 4 } 94 if rt_has(path, ".cap" as *u8) == 1 { return 4 } 95 if rt_has(path, ".conf" as *u8) == 1 { return 4 } 96 if rt_has(path, ".reg" as *u8) == 1 { return 4 } 97 if rt_has(path, "secret" as *u8) == 1 { return 4 } 98 if rt_has(path, "opaque" as *u8) == 1 { return 4 } 99 if rt_has(path, "vault" as *u8) == 1 { return 4 } 100 if rt_has(path, "mint" as *u8) == 1 { return 4 } 101 if rt_has(path, "daemon" as *u8) == 1 { return 4 } 102 if rt_has(path, "serve" as *u8) == 1 { return 4 } 103 return 0 104} 105// flatten "a/b/c" -> "a_b_c" into dst 106func rt_flatten(dst: *u8, src: *u8) -> i64 { 107 var o: i64 = 0 108 var i: i64 = 0 109 while src[i] != (0 as u8) { 110 if src[i] == (47 as u8) { dst[o] = 95 as u8 } else { dst[o] = src[i] } 111 o = o + 1 112 i = i + 1 113 } 114 return o 115} 116func rt_cat(dst: *u8, off: i64, s: *u8) -> i64 { 117 var o: i64 = off 118 var i: i64 = 0 119 while s[i] != (0 as u8) { dst[o] = s[i]; o = o + 1; i = i + 1 } 120 return o 121} 122 123// returns 0 on success; prints the refusal and returns nonzero otherwise 124func rt_retire(path: *u8) -> i64 { 125 let why: i64 = rt_refuse(path) 126 if why != 0 { 127 rt_say("RETIRE-REFUSED code=" as *u8); rt_num(why) 128 rt_say(" path=" as *u8); rt_say(path) 129 rt_say(" (1=too short 2=traversal 3=must be >=2 segments deep 4=protected name)\n" as *u8) 130 return why 131 } 132 if eg_have(path) == 0 { 133 // a no-op must never look like a success 134 rt_say("RETIRE-ABSENT path=" as *u8); rt_say(path); rt_say("\n" as *u8) 135 return 9 136 } 137 sys_mkdir("knowledge/retired" as *u8, 493) 138 let dst: *u8 = eg_buf(RP_NAMEBUF, "retire destination" as *u8) 139 var o: i64 = rt_cat(dst, 0, "knowledge/retired/" as *u8) 140 o = o + rt_flatten((((dst as i64) + o) as *u8), path) 141 o = rt_cat(dst, o, "-" as *u8) 142 // stamp so repeated retires of the same name never collide 143 var ts: i64 = sys_now_realtime_sec() 144 let tb: *u8 = eg_buf(32, "retire stamp" as *u8) 145 var k: i64 = 0 146 while ts > 0 { tb[k] = (48 + (ts % 10)) as u8; ts = ts / 10; k = k + 1 } 147 while k > 0 { k = k - 1; dst[o] = tb[k]; o = o + 1 } 148 dst[o] = 0 as u8 149 let rc: i64 = sys_renameat(path, dst) 150 if rc != 0 { 151 rt_say("RETIRE-FAIL rename rc=" as *u8); rt_num(rc) 152 rt_say(" path=" as *u8); rt_say(path); rt_say("\n" as *u8) 153 return 3 154 } 155 rt_say("{\"organ\":\"nx_retire_path\",\"v\":1,\"retired\":\"" as *u8); rt_say(path) 156 rt_say("\",\"to\":\"" as *u8); rt_say(dst) 157 rt_say("\",\"deleted\":0,\"reversible\":1}\n" as *u8) 158 return 0 159} 160 161func main(argc: i64, argv: *i64) -> i64 { 162 if argc < 2 { 163 rt_say("usage: nx_retire_path {retire <path> | selftest}\n" as *u8) 164 rt_say(" moves a path into knowledge/retired/ (never deletes; bytes preserved, reversible)\n" as *u8) 165 sys_exit(2) 166 return 2 167 } 168 let verb: *u8 = argv[1] as *u8 169 if rt_has(verb, "selftest" as *u8) == 1 { 170 var fails: i64 = 0 171 // the refusals are the whole safety story, so they are what the selftest proves 172 if rt_refuse("nx_mgmt_api.elf" as *u8) == 0 { fails = fails + 1 } 173 if rt_refuse("tools_cap_secret.key" as *u8) == 0 { fails = fails + 1 } 174 if rt_refuse("sites/../../etc/passwd" as *u8) == 0 { fails = fails + 1 } 175 if rt_refuse("daemons.reg" as *u8) == 0 { fails = fails + 1 } 176 if rt_refuse("sites" as *u8) == 0 { fails = fails + 1 } 177 if rt_refuse("sites/nishifamily" as *u8) == 0 { fails = fails + 1 } 178 if rt_refuse("nx_sites_daemon_v2.elf" as *u8) == 0 { fails = fails + 1 } 179 // and a legitimate scratch path must be ALLOWED (a guard that refuses everything is useless) 180 if rt_refuse("sites/nishifamily/factory/gen-coffee/d1" as *u8) != 0 { fails = fails + 1 } 181 if rt_refuse("knowledge/status/uigen_tabprobe.txt" as *u8) != 0 { fails = fails + 1 } 182 // ---- STAGED-ARTIFACT EXCEPTION: it must ADMIT the hazard class and REFUSE every neighbour ---- 183 // ADMIT: root-level staged elfs (both staging shapes) -- the 512-strong stray class 184 if rt_refuse("nx_hostctl.elf.new" as *u8) != 0 { fails = fails + 1 } 185 if rt_refuse("nx_daemon_supervisor.sov.elf.new" as *u8) != 0 { fails = fails + 1 } 186 // ADMIT the BANKED twin that /api/build's new anti-clobber bank creates (debt 1785772241) 187 if rt_refuse("nx_tool_argecho.sov.elf.new.prev" as *u8) != 0 { fails = fails + 1 } 188 // REFUSE a LIVE .prev -- promote/deploy bank the RUNNING binary under this name, and that copy is 189 // the rollback source. Only the STAGED lineage (.elf.new.prev) is exempt, never `<name>.elf.prev`. 190 if rt_refuse("nx_mgmt_api.elf.prev" as *u8) == 0 { fails = fails + 1 } 191 // REFUSE: the LIVE binary of the same organ (the one-character difference that matters most) 192 if rt_refuse("nx_hostctl.elf" as *u8) == 0 { fails = fails + 1 } 193 // REFUSE: staged SECRET material -- the secret guards survive the exception 194 if rt_refuse("tools_cap_secret.key.new" as *u8) == 0 { fails = fails + 1 } 195 if rt_refuse("opaque_keys.bin.elf.new" as *u8) == 0 { fails = fails + 1 } 196 // REFUSE: traversal is never exempted, even wearing the staged suffix 197 if rt_refuse("../../etc/evil.elf.new" as *u8) == 0 { fails = fails + 1 } 198 // REFUSE: SUFFIX not substring -- a live path merely CONTAINING the marker is not exempt 199 if rt_refuse("nx_thing.elf.new.elf" as *u8) == 0 { fails = fails + 1 } 200 // absent target must not report success 201 if rt_retire("sites/nishifamily/factory/definitely-not-here-xyz" as *u8) != 9 { fails = fails + 1 } 202 // and an absent STAGED artifact must also refuse (the exception must not fake a success) 203 if rt_retire("nx_definitely_not_staged_xyz.elf.new" as *u8) != 9 { fails = fails + 1 } 204 rt_say("NX-RETIRE-PATH selftest fails=" as *u8); rt_num(fails) 205 if fails == 0 { rt_say(" verdict=GREEN (12 protected paths refused incl live-elf/staged-secret/traversal/suffix-not-substring, 4 retirable paths allowed incl 2 staged artifacts, 2 absent targets refused)\n" as *u8); sys_exit(0); return 0 } 206 rt_say(" verdict=RED\n" as *u8) 207 sys_exit(1) 208 return 1 209 } 210 if argc < 3 { 211 rt_say("usage: nx_retire_path retire <path>\n" as *u8) 212 sys_exit(2) 213 return 2 214 } 215 let rc: i64 = rt_retire(argv[2] as *u8) 216 sys_exit(rc) 217 return rc 218}