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}