Repository navigation
Expand file tree
/
Copy pathSubstrateProvisioner.cs
More file actions
804 lines (717 loc) · 40.9 KB
/
Copy pathSubstrateProvisioner.cs
File metadata and controls
804 lines (717 loc) · 40.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MissionController.Infrastructure.Linux;
namespace MissionController.Host;
/// <summary>
/// Gets this host from "WSL2 is installed" to "a lease reaches the kernel", with no shell step.
///
/// <see cref="SubstrateDoctor"/> reports; this repairs. They read the same machine in the same order,
/// so a `provision` run and a `doctor` run agree by construction rather than by two authors
/// remembering the same facts.
///
/// Two rules shape it. Nothing changes without <c>--apply</c>, because the steps write to /etc inside
/// the guest and to the user's own .wslconfig, and a tool that edits those on sight of a typo is
/// worse than one that prints what it would do. And what it cannot do it names rather than
/// approximates: `wsl --install` needs administrator rights and a reboot, and `wsl --shutdown` ends
/// every session the user has open, so both stay theirs.
/// </summary>
public static class SubstrateProvisioner
{
private const string AgentPath = "/opt/splat/splat-agent";
private const string UnitPath = "/etc/systemd/system/splat-agent.service";
/// <summary>
/// Root, because loading a BPF-LSM program and writing the cgroup tree need it — the WORKLOAD is
/// dropped to nobody by the daemon itself, which is the property the probe checks. Restart on
/// failure but never on a clean exit: the daemon exits deliberately when it cannot enforce, and
/// restarting that would turn a fail-closed refusal into a crash loop nobody reads.
/// </summary>
private const string UnitFile =
"[Unit]\n" +
"Description=SPLAT resident BPF-LSM lease daemon\n" +
"After=network.target\n" +
"\n" +
"[Service]\n" +
"Type=simple\n" +
// bpffs, before the daemon that needs it. Since the links and maps are pinned so the hooks
// outlive the daemon, /sys/fs/bpf must be a bpffs mount or the daemon refuses to start and
// says so - correctly, because a daemon that cannot pin cannot promise a kill -9 stops
// freeing what it was policing. Nothing mounted it: on 2026-09-29 a freshly installed daemon
// sat in a restart loop 26 deep, and the service was `enabled`, so the unit's own promise that
// it "comes back on its own after a restart" was false on any guest that had rebooted.
//
// Here rather than in a mount unit or fstab: the requirement belongs to this daemon, so it
// travels with the unit that runs it and a stranger installing SPLAT gets it without reading
// anything. `mountpoint -q` first because mounting bpffs twice succeeds and stacks; the
// absent-then-mount pair is not atomic, which is why the mount is allowed to fail.
"ExecStartPre=/bin/sh -c 'mountpoint -q /sys/fs/bpf || mount -t bpf bpffs /sys/fs/bpf'\n" +
"ExecStart=" + AgentPath + " --listen 127.0.0.1:50051\n" +
// The cpu controller, which the daemon cannot enable for itself. It creates its leases under
// /sys/fs/cgroup/splat_leases and refuses one it cannot clamp. That cgroup receives a
// controller only if the root's subtree_control enables it, and systemd enables cpu there only
// for a unit that needs it. What needed it was root's user session: user@.service delegates
// cpu, so user.slice turned it on at the root. That was incidental. Under the managed
// service's account on the Debian guest nothing turned it on, and every lease of the
// 2026-10-20 gate run was refused. A weight makes this unit need cpu, so systemd enables it
// from the root down. 100 is the default weight, so no task is scheduled differently.
"CPUWeight=100\n" +
"Restart=on-failure\n" +
"RestartSec=2\n" +
"\n" +
"[Install]\n" +
"WantedBy=multi-user.target\n";
public static async Task<int> RunAsync(
ProvisionOptions provision,
TextWriter output,
SubstrateDoctor.WslRunner? wsl = null,
WslSubstrateHealthChecker? health = null,
string? wslConfigPath = null,
string? agentBinaryPath = null,
string? manifestPath = null,
CancellationToken ct = default,
int daemonPort = 50051)
{
ArgumentNullException.ThrowIfNull(provision);
ArgumentNullException.ThrowIfNull(output);
wsl ??= SubstrateDoctor.RunWslAsync;
if (daemonPort is < 1024 or > 65535) throw new ArgumentOutOfRangeException(nameof(daemonPort));
health ??= new WslSubstrateHealthChecker(port: daemonPort);
bool apply = provision.Apply;
// Every change this run makes to the host, in the order it makes them, so that
// `splat-mcp unmount` can play them backwards. A mount is honest only if it can be undone,
// and "undone" cannot mean a second list of steps maintained by hand beside this one.
var changes = new List<MountChange>();
string distro = string.Empty;
try
{
output.WriteLine(apply
? "SPLAT provision - making this host enforce"
: "SPLAT provision - what this host still needs (nothing is changed; add --apply)");
output.WriteLine();
// 1. The one step that is not ours: WSL2 itself.
var (wslExit, wslOut) = await wsl(new[] { "-e", "sh", "-c", "echo ok" }, ct);
if (wslExit != 0)
{
Line(output, "FAIL", "WSL",
$"wsl.exe did not answer ({Short(wslOut)}). Run `wsl --install` yourself: it needs "
+ "administrator rights and a reboot, which this command will not take.");
return Verdict(output, false, apply);
}
var (_, distroName) = await wsl(new[] { "-e", "sh", "-c", ". /etc/os-release; echo $PRETTY_NAME" }, ct);
distro = distroName.Trim();
Line(output, "ok", "WSL", distro);
// 2. systemd, because the daemon has to come back after a restart without anyone typing.
var (_, pid1) = await wsl(new[] { "-e", "ps", "-p", "1", "-o", "comm=" }, ct);
if (pid1.Trim() != "systemd")
{
if (apply)
{
var (confExit, confOut) = await wsl(Root("printf '[boot]\\nsystemd=true\\n' >> /etc/wsl.conf"), ct);
if (confExit == 0)
{
changes.Add(new MountChange(
MountChange.Lines, MountChange.Guest, "/etc/wsl.conf",
Appended: "[boot]\nsystemd=true\n"));
}
Line(output, confExit == 0 ? "todo" : "FAIL", "systemd",
confExit == 0
? "wrote [boot] systemd=true to /etc/wsl.conf. `wsl --shutdown` is yours - it ends every WSL session you have open - then re-run this."
: $"could not write /etc/wsl.conf: {Short(confOut)}");
}
else
{
Line(output, "todo", "systemd",
$"pid 1 is '{pid1.Trim()}', so nothing would start the daemon at boot. --apply writes [boot] systemd=true to /etc/wsl.conf.");
}
return Verdict(output, false, apply);
}
Line(output, "ok", "systemd", "pid 1, so a unit can start the daemon at boot");
// 3. The kernel's own LSM list, set on the Windows side and taking effect only on a restart.
var (_, cmdline) = await wsl(new[] { "-e", "cat", "/proc/cmdline" }, ct);
bool bpf = cmdline
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(token => token.StartsWith("lsm=", StringComparison.Ordinal))
.SelectMany(token => token["lsm=".Length..].Split(','))
.Any(lsm => lsm.Trim() == "bpf");
if (!bpf)
{
Line(output, "todo", "BPF-LSM", WslConfigAdvice(apply, wslConfigPath));
return Verdict(output, false, apply);
}
Line(output, "ok", "BPF-LSM", "bpf is on the boot command line, so the kernel initialised it");
// 3a. bpffs, which the daemon needs in order to pin. Reported on its own line because on
// 2026-09-29 a guest had bpf on the boot line and no bpffs mount, so this checklist read
// "BPF-LSM ok" and then failed three lines later on the service, the token and enforcement -
// none of which was the cause. Finding it took the journal. The unit now mounts it in an
// ExecStartPre, so `--apply` fixes this by rewriting the unit; the line exists so a host
// that is one restart away from a dead daemon says so before the daemon dies.
var (_, bpffsType) = await wsl(new[] { "-e", "stat", "-f", "-c", "%T", "/sys/fs/bpf" }, ct);
bool bpffs = bpffsType.Trim().Equals("bpffs", StringComparison.OrdinalIgnoreCase);
Line(output, bpffs ? "ok" : "todo", "bpffs",
bpffs
? "/sys/fs/bpf is a bpffs mount, so the daemon can pin its links and maps"
: "/sys/fs/bpf is not bpffs, so the daemon will refuse to start - it cannot pin, and a "
+ "kill -9 would free what it was policing. "
+ (apply
? "The unit written below mounts it before starting the daemon."
: "--apply rewrites the unit so it mounts bpffs first."));
// 3b. The Windows drives. WSL mounts them with every mode bit open, so a leased command
// running as nobody read the operator's whole profile through /mnt/c - SSH keys included,
// checked on 2026-09-18. The jail leaves /mnt out of a workload's root; this closes the
// same door for anything outside a jail, and it was a runbook line every operator had to
// remember. It is measured, as the mode of /mnt/c, rather than read from the config,
// because the config takes effect only on a restart that is the user's to take.
if (await WindowsDrivesStepAsync(output, wsl, apply, ct) is MountChange automount)
{
changes.Add(automount);
}
// 4. The daemon binary, at a path that survives a restart. /tmp does not: it is tmpfs, and the
// distro stops seconds after its last process exits, taking the build with it.
//
// A published splat-mcp.exe carries the daemon inside it, so a stranger needs neither this
// repository nor a Rust toolchain. A developer checkout carries none, and builds it with cargo.
(string Path, string Sha256)? extracted = agentBinaryPath is null ? ExtractShippedAgent() : null;
(string Path, string Sha256)? shipped = agentBinaryPath is null ? extracted : (agentBinaryPath, Sha256Of(agentBinaryPath));
AgentStep agent;
string? replacedAgentSha = null;
try
{
(agent, replacedAgentSha) = await AgentStepAsync(output, wsl, apply, shipped, ct);
}
finally
{
if (extracted is not null)
{
File.Delete(extracted.Value.Path);
}
}
if (agent == AgentStep.Stop)
{
return Verdict(output, false, apply);
}
bool reinstalled = agent == AgentStep.Installed;
if (reinstalled)
{
changes.Add(new MountChange(
MountChange.File, MountChange.Guest, AgentPath, PreviousSha256: replacedAgentSha,
Note: replacedAgentSha is null ? null : "this mount replaced an existing daemon binary"));
changes.Add(new MountChange(MountChange.Directory, MountChange.Guest, "/opt/splat"));
}
// 4b. The lease ceiling: what any lease may name on this host, written where a stolen token
// cannot reach around it. The daemon reads it at startup, so a change restarts the unit.
var (ceilingChanged, replacedCeilingSha) = await CeilingStepAsync(output, wsl, apply, provision.CeilingEgress, ct);
if (ceilingChanged)
{
changes.Add(new MountChange(
MountChange.File, MountChange.Guest, CeilingPath, PreviousSha256: replacedCeilingSha,
Note: replacedCeilingSha is null ? null : "this mount replaced an existing lease ceiling"));
changes.Add(new MountChange(MountChange.Directory, MountChange.Guest, "/etc/splat"));
}
// 5. The unit, rewritten on every --apply: a stale ExecStart pointing at a path that no longer
// exists fails at boot, quietly, and nobody reads a unit they did not write today.
var (unitExit, _) = await wsl(Root($"test -f {UnitPath}"), ct);
if (unitExit != 0 && !apply)
{
Line(output, "todo", "service", $"{UnitPath} is not there. --apply writes it and enables it.");
return Verdict(output, false, apply);
}
if (apply)
{
// Asked before the unit starts: the daemon writes its own token at first start, so a token
// that is already there belonged to an earlier mount and is not this one's to claim.
var (tokenExit, _) = await wsl(Root($"test -f {WslAgentToken.DefaultPath}"), ct);
bool tokenExisted = tokenExit == 0;
var (writeExit, writeOut) = await wsl(Root(
$"install -d /opt/splat && printf '%s' \"$SPLAT_UNIT\" > {UnitPath} && "
+ "systemctl daemon-reload && " + StartCommand(reinstalled || ceilingChanged),
("SPLAT_UNIT", UnitFile.Replace("127.0.0.1:50051", $"127.0.0.1:{daemonPort}", StringComparison.Ordinal))), ct);
if (writeExit != 0)
{
Line(output, "FAIL", "service", $"could not install the unit: {Short(writeOut)}");
return Verdict(output, false, apply);
}
changes.Add(new MountChange(MountChange.Unit, MountChange.Guest, UnitPath));
changes.Add(new MountChange(MountChange.Directory, MountChange.Guest, "/opt/splat"));
if (!tokenExisted)
{
changes.Add(new MountChange(
MountChange.File, MountChange.Guest, WslAgentToken.DefaultPath,
Note: "written by the daemon at its first start"));
}
}
var (activeExit, activeOut) = await wsl(
Root("systemctl is-active splat-agent; systemctl is-enabled splat-agent"), ct);
bool running = activeOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Any(line => line.Trim() == "active");
Line(output, running, "service",
running
? $"splat-agent is {activeOut.ReplaceLineEndings(" and ").Trim()} - it comes back on its own after a restart"
: $"splat-agent is not running: {Short(activeOut)} (exit {activeExit})");
// 6. The verdict is the doctor's question, answered through the same authenticated health
// check: reachable is not enforcing, and a daemon that will not talk to this host is not
// provisioned however many files are in place.
var status = await health.CheckHealthAsync(ct);
Line(output, status.Authenticated, "daemon token",
status.Authenticated
? $"accepted: a request from this host authenticated against {WslAgentToken.DefaultPath}"
: $"REFUSED: {status.Message}");
Line(output, status.Enforcing, "enforcement",
status.Enforcing
? $"live: a lease reaches the kernel ({status.ActiveLeaseCount} active)"
: "not live: the daemon holds no BPF maps, so nothing it runs is contained");
return Verdict(output, status.Enforcing, apply);
}
finally
{
// Written whatever the outcome, and only when something was actually changed. A run that
// installed the binary and then failed to install the unit has still changed this host,
// and an unmount that could not see the binary would leave a root-owned daemon behind.
if (apply && changes.Count > 0)
{
string path = manifestPath ?? MissionController.Core.SplatDataRoot.Layout.MountManifest;
try
{
var existing = File.Exists(path) ? MountManifest.Read(path).Changes : Array.Empty<MountChange>();
var merged = existing
.Concat(changes.Where(c => !existing.Any(e => e.Kind == c.Kind && e.Path == c.Path)))
.ToList();
MountManifest.Create(distro, merged).Write(path);
output.WriteLine();
output.WriteLine($"Mount manifest: {path} ({merged.Count} change(s)). `splat-mcp unmount` plays it backwards.");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
{
output.WriteLine();
output.WriteLine(
$"This host was changed but the mount manifest could not be written to {path}: {ex.Message} " +
"Unmounting will have to be done by hand, from the steps above.");
}
}
}
}
private const string ShippedAgentResource = "splat-agent";
/// <summary>Where step 4 left the daemon: already right, freshly installed, or blocked.</summary>
private enum AgentStep
{
InPlace,
Installed,
Stop,
}
private static async Task<(AgentStep Step, string? ReplacedSha256)> AgentStepAsync(
TextWriter output, SubstrateDoctor.WslRunner wsl, bool apply, (string Path, string Sha256)? shipped, CancellationToken ct)
{
string? replaced = null;
var (agentExit, _) = await wsl(Root($"test -x {AgentPath}"), ct);
bool installed = agentExit == 0;
if (installed)
{
if (shipped is null)
{
Line(output, "ok", "daemon binary", AgentPath);
return (AgentStep.InPlace, null);
}
// Kept only when it is byte-for-byte the daemon this executable carries. A newer splat-mcp.exe
// over an older daemon is two halves of a wire protocol from different builds. A checksum
// that cannot be read counts as different, and the replacement is verified before it lands.
var (_, sums) = await wsl(Root($"sha256sum {AgentPath}"), ct);
string existing = sums.Trim().Split(' ', 2)[0];
if (existing.Equals(shipped.Value.Sha256, StringComparison.OrdinalIgnoreCase))
{
Line(output, "ok", "daemon binary", $"{AgentPath}, byte-for-byte the daemon built into this executable");
return (AgentStep.InPlace, null);
}
// Recorded before it is overwritten. Nothing keeps a copy of the binary, so unmount can
// only name what was there - which is the difference between an operator who knows a file
// is gone and one who finds out later.
replaced = existing.Length == 64 ? existing : null;
}
if (!apply)
{
Line(output, "todo", "daemon binary",
shipped is null
? $"{AgentPath} is not there. --apply builds it from this repo and installs it."
: installed
? $"{AgentPath} is not the daemon built into this executable. --apply replaces it and restarts the service."
: $"{AgentPath} is not there. --apply installs the daemon built into this executable - no Rust toolchain needed.");
return (AgentStep.Stop, null);
}
bool done = shipped is null
? await InstallAgentAsync(output, wsl, ct)
: await InstallShippedAgentAsync(output, wsl, shipped.Value, ct);
return (done ? AgentStep.Installed : AgentStep.Stop, done ? replaced : null);
}
/// <summary>
/// Installs the daemon this executable carries: staged beside the live binary, run once, and only then
/// renamed over it. The run is the check that matters for a binary built on another machine - a guest
/// whose glibc predates the build's, or that lacks a library, fails here with the loader naming what is
/// missing, not at boot in a unit log nobody reads. Running the staged copy rather than the file on the
/// Windows mount means a DrvFs mount without exec permission cannot pass for a binary that will not
/// start, and the rename means the service never sees a half-written file. Before any of that the copy
/// is hashed against the bytes this executable carries, because the file it was copied from is on the
/// Windows mount, and every process in the guest can write there.
/// </summary>
private static async Task<bool> InstallShippedAgentAsync(
TextWriter output, SubstrateDoctor.WslRunner wsl, (string Path, string Sha256) shipped, CancellationToken ct)
{
var (pathExit, guestPath) = await wsl(new[] { "-e", "wslpath", "-a", shipped.Path }, ct);
if (pathExit != 0 || string.IsNullOrWhiteSpace(guestPath))
{
Line(output, "FAIL", "daemon binary", $"could not resolve {shipped.Path} inside the guest: {Short(guestPath)}");
return false;
}
// The check's stderr is kept in a variable, not in /tmp. It went to a fixed file there, and the
// guest's /tmp is world-writable: a file planted under that name made root's write fail, so the
// check failed and `cat` gave the planter's text as the reason.
string staged = AgentPath + ".new";
var (installExit, installOut) = await wsl(Root(
"install -D -m 0755 \"$SPLAT_AGENT\" " + staged
+ " && { [ \"$(sha256sum " + staged + " | cut -d ' ' -f 1)\" = \"$SPLAT_AGENT_SHA256\" ]"
+ " || { rm -f " + staged + "; exit 4; }; }"
+ " && { err=$(" + staged + " --help 2>&1 >/dev/null)"
+ @" || { printf '%s\n' ""$err""; rm -f " + staged + "; exit 3; }; }"
+ " && mv -f " + staged + " " + AgentPath,
("SPLAT_AGENT", guestPath.Trim()), ("SPLAT_AGENT_SHA256", shipped.Sha256)), ct);
if (installExit == 4)
{
Line(output, "FAIL", "daemon binary",
"the copy that reached the guest does not match the daemon built into this executable, so nothing "
+ "was replaced. It came through the Windows mount, which every process in the guest can write.");
return false;
}
if (installExit == 3)
{
// Not shortened: this is the one line the reader has to act on, and the version the loader
// names is exactly what Short() would cut off.
Line(output, "FAIL", "daemon binary",
"the daemon built into this executable will not start in this guest, so nothing was replaced: "
+ installOut.ReplaceLineEndings(" ").Trim());
return false;
}
if (installExit != 0)
{
Line(output, "FAIL", "daemon binary", $"could not install the daemon built into this executable: {Short(installOut)}");
return false;
}
Line(output, "ok", "daemon binary", $"installed the daemon built into this executable to {AgentPath} - no Rust toolchain involved");
return true;
}
/// <summary>
/// The daemon publish-splat-mcp.ps1 builds into this executable, written out to a temp file, with the hash
/// of the bytes written; or null for a build that carries none - a developer checkout, where this
/// repository and cargo are the way in.
/// </summary>
private static (string Path, string Sha256)? ExtractShippedAgent()
{
using Stream? resource = typeof(SubstrateProvisioner).Assembly.GetManifestResourceStream(ShippedAgentResource);
if (resource is null)
{
return null;
}
// Hashed as it is written, never read back: the guest reaches the file through /mnt/c, so by the
// time it was read again anything in the guest could have changed it.
string path = Path.Combine(Path.GetTempPath(), $"splat-agent-{Environment.ProcessId}");
using var sha256 = SHA256.Create();
using (var file = File.Create(path))
using (var hashing = new CryptoStream(file, sha256, CryptoStreamMode.Write))
{
resource.CopyTo(hashing);
}
return (path, Convert.ToHexString(sha256.Hash!).ToLowerInvariant());
}
private static string Sha256Of(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
/// <summary>
/// Restarted, not merely enabled, when step 4 put a new binary in place: <c>enable --now</c> leaves a
/// running service alone, so the old daemon would go on serving the old code until the next boot.
/// When nothing changed it is only enabled, because a restart drops every lease the daemon holds.
/// </summary>
private static string StartCommand(bool reinstalled) => reinstalled
? "systemctl enable splat-agent && systemctl restart splat-agent"
: "systemctl enable --now splat-agent";
private static async Task<bool> InstallAgentAsync(TextWriter output, SubstrateDoctor.WslRunner wsl, CancellationToken ct)
{
// The crate as this host sees it. wslpath rather than string surgery on C:\ — a repo on
// another drive, or under a junction, is not a /mnt/c path.
string crate = Path.Combine(RepositoryRoot(), "native", "splat-agent");
var (pathExit, guestCrate) = await wsl(new[] { "-e", "wslpath", "-a", crate }, ct);
if (pathExit != 0 || string.IsNullOrWhiteSpace(guestCrate))
{
Line(output, "FAIL", "daemon binary", $"could not resolve {crate} inside the guest: {Short(guestCrate)}");
return false;
}
// rustup puts cargo in ~/.cargo/bin and adds it to PATH from .bashrc, which a non-login shell
// never reads — which is how preflight once reported a missing cargo on a machine that had it.
// Look in both places before concluding anything.
var (cargoExit, cargo) = await wsl(
new[] { "-e", "sh", "-c", "command -v cargo || ls \"$HOME\"/.cargo/bin/cargo 2>/dev/null" }, ct);
if (cargoExit != 0 || string.IsNullOrWhiteSpace(cargo))
{
Line(output, "FAIL", "daemon binary",
"no cargo in the guest, so the daemon cannot be built here. Install Rust in WSL "
+ "(`curl https://sh.rustup.rs -sSf | sh`) and re-run.");
return false;
}
// The building user's own cache, never /tmp. Root installs what this build leaves, and the guest's
// /tmp is world-writable: whoever created /tmp/splat_agent_target before the first build owned every
// file in it from then on. $HOME is read here because root's, below, is a different one.
var (homeExit, home) = await wsl(new[] { "-e", "sh", "-c", "printf %s \"$HOME\"" }, ct);
if (homeExit != 0 || !home.StartsWith('/'))
{
Line(output, "FAIL", "daemon binary", $"could not find the guest user's home directory: {Short(home)}");
return false;
}
string target = home + "/.cache/splat-agent-target";
Line(output, "...", "daemon binary", $"building from {guestCrate.Trim()} - about a minute the first time");
var (buildExit, buildOut) = await wsl(new[]
{
"-e", "sh", "-c",
// cargo's exit status, not tail's. Piped straight into tail, a failed build exited 0 - and with a
// target directory that persists, root would then have installed whatever an earlier build left.
$"out=$(cd '{guestCrate.Trim()}' && EXTRA_CFLAGS=-Wno-error CFLAGS=-Wno-error "
+ $"CARGO_TARGET_DIR='{target}' '{cargo.Trim()}' build --release 2>&1); status=$?; "
+ @"printf '%s\n' ""$out"" | tail -3; exit $status",
}, ct);
if (buildExit != 0)
{
Line(output, "FAIL", "daemon binary", $"the build failed: {Short(buildOut)}");
return false;
}
var (installExit, installOut) = await wsl(
Root("install -D -m 0755 \"$SPLAT_AGENT\" " + AgentPath, ("SPLAT_AGENT", target + "/release/splat-agent")), ct);
if (installExit != 0)
{
Line(output, "FAIL", "daemon binary", $"built, but could not install to {AgentPath}: {Short(installOut)}");
return false;
}
Line(output, "ok", "daemon binary", $"built and installed to {AgentPath}");
return true;
}
private const string CeilingPath = "/etc/splat/policy.json";
/// <summary>
/// The ceiling this host writes: the standard binary directories, the shadow files always
/// protected, five minutes at most, and the operator's --egress list when one was given. With no
/// list the key is left out, which the daemon reads as "not bounded by the ceiling"; the server's
/// own --egress then remains the only network policy, and doctor says so.
/// </summary>
public static string CeilingDocument(McpEgressPolicy egress)
{
var document = new Dictionary<string, object>
{
["schema"] = 1,
["allowedExecPrefixes"] = new[] { "/bin/", "/sbin/", "/usr/bin/", "/usr/sbin/", "/usr/local/bin/", "/usr/local/sbin/" },
["alwaysProtectedPaths"] = new[] { "/etc/shadow", "/etc/gshadow" },
["maxLeaseDurationSeconds"] = 300,
};
if (!egress.IsEmpty)
{
document["allowedEgress"] = egress.ToString().Split(", ");
}
return JsonSerializer.Serialize(document, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>Writes the ceiling when it differs from what the guest holds. Returns whether it was written.</summary>
private static async Task<(bool Changed, string? ReplacedSha256)> CeilingStepAsync(
TextWriter output, SubstrateDoctor.WslRunner wsl, bool apply, McpEgressPolicy egress, CancellationToken ct)
{
string wanted = CeilingDocument(egress);
string egressNote = egress.IsEmpty
? "egress not bounded by it - pass --egress to provision to bound the network from inside the guest too"
: $"egress bounded to {egress}";
var (readExit, current) = await wsl(new[] { "-e", "sh", "-c", $"cat {CeilingPath} 2>/dev/null" }, ct);
if (readExit == 0 && current.ReplaceLineEndings("\n").Trim() == wanted.ReplaceLineEndings("\n").Trim())
{
Line(output, "ok", "lease ceiling", $"{CeilingPath} bounds every lease; {egressNote}");
return (false, null);
}
if (!apply)
{
Line(output, "todo", "lease ceiling",
readExit == 0
? $"{CeilingPath} differs from what this command would write. --apply rewrites it and restarts the daemon; {egressNote}"
: $"{CeilingPath} is not there, so the token alone authorizes what a lease may name. --apply writes it; {egressNote}");
return (false, null);
}
// Asked of the guest rather than computed here: a hash of the text this side read back would
// not be the hash `sha256sum` prints, and a receipt nobody can reproduce is not a receipt.
string? replaced = null;
if (readExit == 0)
{
var (sumExit, sum) = await wsl(Root($"sha256sum {CeilingPath}"), ct);
string digest = sumExit == 0 ? sum.Trim().Split(' ', 2)[0] : string.Empty;
replaced = digest.Length == 64 ? digest : null;
}
// Staged and renamed, so the daemon never reads a half-written file; 0644 root, which is what
// the daemon accepts - anything group- or world-writable it refuses to start under.
var (writeExit, writeOut) = await wsl(Root(
"install -d -m 0755 /etc/splat"
+ " && printf '%s\\n' \"$SPLAT_CEILING\" > " + CeilingPath + ".new"
+ " && chmod 0644 " + CeilingPath + ".new"
+ " && mv -f " + CeilingPath + ".new " + CeilingPath,
("SPLAT_CEILING", wanted)), ct);
if (writeExit != 0)
{
Line(output, "FAIL", "lease ceiling", $"could not write {CeilingPath}: {Short(writeOut)}");
return (false, null);
}
Line(output, "ok", "lease ceiling", $"wrote {CeilingPath}; {egressNote}");
return (true, replaced);
}
/// <summary>
/// Closes the Windows drives to a leased command's uid, or says what stands in the way. The
/// [automount] stanza is appended only when /etc/wsl.conf has none; a section that exists is the
/// operator's, and an edit inside it is theirs to make. The line never changes the verdict: it is
/// about what a leased command can read, not whether a lease reaches the kernel.
/// </summary>
private static async Task<MountChange?> WindowsDrivesStepAsync(TextWriter output, SubstrateDoctor.WslRunner wsl, bool apply, CancellationToken ct)
{
var (modeExit, mode) = await wsl(new[] { "-e", "stat", "-c", "%a", "/mnt/c" }, ct);
string bits = mode.Trim();
if (modeExit != 0 || bits.Length == 0)
{
Line(output, "ok", "Windows drives", "no /mnt/c in the guest, so nothing there for a leased command to read");
return null;
}
// 700 or 000: whatever the owner keeps, the other bits are closed.
if (bits.EndsWith("00", StringComparison.Ordinal))
{
Line(output, "ok", "Windows drives", $"/mnt/c is mode {bits}: closed to a leased command's uid");
return null;
}
var (_, conf) = await wsl(new[] { "-e", "sh", "-c", "cat /etc/wsl.conf 2>/dev/null" }, ct);
string[] lines = conf.ReplaceLineEndings("\n").Split('\n');
int section = Array.FindIndex(lines, l => l.Trim().Equals("[automount]", StringComparison.OrdinalIgnoreCase));
bool asked = section >= 0 && lines
.Skip(section + 1)
.TakeWhile(l => !l.TrimStart().StartsWith('['))
.Select(l => l.Trim())
.Where(l => !l.StartsWith('#') && !l.StartsWith(';'))
.Any(l => l.StartsWith("options", StringComparison.OrdinalIgnoreCase) && l.Contains("umask=077", StringComparison.Ordinal));
if (asked)
{
Line(output, "todo", "Windows drives",
$"/mnt/c is mode {bits}, and /etc/wsl.conf already asks for umask=077. `wsl --shutdown` is yours - it "
+ "ends every WSL session you have open - and then it takes effect.");
return null;
}
if (section >= 0)
{
Line(output, "todo", "Windows drives",
$"/mnt/c is mode {bits}. /etc/wsl.conf has an [automount] section without umask=077: add "
+ "`options = \"umask=077\"` under it yourself, then `wsl --shutdown`.");
return null;
}
if (!apply)
{
Line(output, "todo", "Windows drives",
$"/mnt/c is mode {bits}, so a leased command running as nobody can read your Windows profile through it. "
+ "--apply appends [automount] options = \"umask=077\" to /etc/wsl.conf; `wsl --shutdown` then applies it.");
return null;
}
var (writeExit, writeOut) = await wsl(Root("printf '\\n[automount]\\noptions = \"umask=077\"\\n' >> /etc/wsl.conf"), ct);
Line(output, writeExit == 0 ? "todo" : "FAIL", "Windows drives",
writeExit == 0
? "wrote [automount] umask=077 to /etc/wsl.conf. `wsl --shutdown` is yours - it ends every WSL session you "
+ "have open - and then /mnt/c closes to a leased command."
: $"could not write /etc/wsl.conf: {Short(writeOut)}");
return writeExit == 0
? new MountChange(
MountChange.Lines, MountChange.Guest, "/etc/wsl.conf",
Appended: "\n[automount]\noptions = \"umask=077\"\n")
: null;
}
/// <summary>
/// The kernel command line is a Windows-side setting, and merging one that already exists is not
/// something to guess at: an operator's boot line may carry options this tool has never heard of.
/// So it appends the stanza only when the file has no [wsl2] section at all, and otherwise prints
/// the one edit to make.
///
/// "Already sets kernelCommandLine" is read the way WSL reads it: a live key under [wsl2]. The
/// substring test this replaced also matched a commented-out line and the same key under any other
/// section - both ignored by WSL - and told the user to add bpf to a line that could never take
/// effect, so the restart they were sent to make came back with the same answer. Nor is a second
/// [wsl2] appended beside an existing one: whether WSL honours a key in a repeated section is its
/// parser's business, and this tool should not depend on an answer nobody here has observed.
/// </summary>
private static string WslConfigAdvice(bool apply, string? path)
{
path ??= Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".wslconfig");
const string line = "kernelCommandLine = lsm=landlock,lockdown,yama,loadpin,safesetid,integrity,selinux,apparmor,tomoyo,bpf";
string existing = File.Exists(path) ? File.ReadAllText(path) : string.Empty;
string[] lines = existing.ReplaceLineEndings("\n").Split('\n');
int section = Array.FindIndex(lines, l => l.Trim().Equals("[wsl2]", StringComparison.OrdinalIgnoreCase));
bool liveKey = section >= 0 && lines
.Skip(section + 1)
.TakeWhile(l => !l.TrimStart().StartsWith('['))
.Select(l => l.Trim())
.Where(l => !l.StartsWith('#') && !l.StartsWith(';'))
.Any(l => l.Contains('=') && l[..l.IndexOf('=')].Trim().Equals("kernelCommandLine", StringComparison.OrdinalIgnoreCase));
if (liveKey)
{
return $"bpf is NOT on the boot command line, and {path} already sets kernelCommandLine under [wsl2]. "
+ "Add `bpf` to the end of its lsm= list yourself, then run `wsl --shutdown`.";
}
if (section >= 0)
{
return $"bpf is NOT on the boot command line, and {path} has a [wsl2] section without a kernelCommandLine. "
+ $"Add this line under [wsl2] yourself, then run `wsl --shutdown`: {line}";
}
if (!apply)
{
return $"bpf is NOT on the boot command line. --apply appends a [wsl2] section to {path}, after which "
+ $"`wsl --shutdown` is yours: [wsl2] {line}";
}
File.AppendAllText(path, (existing.Length > 0 && !existing.EndsWith('\n') ? "\n" : string.Empty) + "[wsl2]\n" + line + "\n");
return $"appended the lsm= stanza to {path}. `wsl --shutdown` is yours - it ends every WSL "
+ "session you have open - then re-run this.";
}
/// <summary>Everything that touches /etc or /opt in the guest, as root.</summary>
private static string[] Root(string script, params (string Name, string Value)[] environment)
{
var args = new List<string> { "-u", "root", "-e", "env" };
foreach (var (name, value) in environment)
{
// The unit file travels as an environment value rather than inside the script: a heredoc
// would have to survive two levels of quoting, and this way nothing in the content can be
// read as shell at all.
args.Add($"{name}={value}");
}
args.Add("sh");
args.Add("-c");
args.Add(script);
return args.ToArray();
}
private static int Verdict(TextWriter output, bool enforcing, bool apply)
{
output.WriteLine();
output.WriteLine(enforcing
? "Verdict: provisioned. A lease on this host reaches the kernel, and `splat-mcp doctor` will say the same."
: apply
? "Verdict: NOT provisioned yet. The first line above that is not `ok` is the step to take."
: "Verdict: NOT provisioned. Nothing was changed - re-run with --apply to do the steps marked `todo`.");
return enforcing ? 0 : 1;
}
private static string RepositoryRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MissionController.sln")))
{
dir = dir.Parent;
}
// Published beside no repository: the build step then says it cannot find the crate, rather
// than this guessing a path that happens to exist.
return dir?.FullName ?? AppContext.BaseDirectory;
}
private static void Line(TextWriter output, string mark, string what, string detail) =>
output.WriteLine($" {mark,-6}{what,-18}{detail}");
private static void Line(TextWriter output, bool ok, string what, string detail) =>
Line(output, ok ? "ok" : "FAIL", what, detail);
private static string Short(string text) =>
text.Length <= 90 ? text.ReplaceLineEndings(" ") : text.ReplaceLineEndings(" ")[..90] + "...";
}