SPLAT turns a request into a governed mission: one pre-approved tool, run once, inside limits it cannot widen, its output scrubbed before anything leaves the process, and every step sealed into a hash-chained ledger. Most of the code answers one question — who enforces this boundary? — and the answer is always one of four: a kernel object, a kernel LSM hook, a check inside SPLAT's own process, or nobody, because the boundary is only declared. This page is written so that you can tell which, for every boundary, without opening a file.
| Read | For |
|---|---|
| CONTEXT.md | Why the project is shaped this way, the rules it holds itself to, its vocabulary, the traps |
| ARCHITECTURE-control-plane.md | The 1:1:1 template, the mission pipeline, the model leg, the engines built on missions, the ledger and memory |
| ARCHITECTURE-windows-substrate.md | What a sensor process experiences on Windows — jail, job object, restricted token, AppContainer — and which apply when |
| ARCHITECTURE-linux-substrate.md | The BPF-LSM lease, the splat-agent daemon, the bridge on Windows, who authors a lease, and the evidence |
| ARCHITECTURE-agent-surface.md | How an external agent gets in: splat-mcp, the LangGraph adapter, the Copilot / Semantic Kernel adapter |
| SCHEMA.md | The lease's contracts byte for byte and frame by frame — map keys, the ring-buffer record, protocol 3, the C# records — and what none of them promise |
| RUNBOOK.md | What a kernel and a guest need for the daemon to enforce, the setup, the unit, and where developer shortcuts diverge from a hardened host |
| MODULE_INFO.md | Every buildable unit: target, references, what it owns, its tests, whether it ships |
flowchart LR
subgraph HOSTS["Hosts - the two composition roots"]
UI["MissionController.UI<br/>WinUI 3 console"]
MCP["splat-mcp<br/>stdio MCP server"]
end
subgraph CORE["Control plane - MissionController.Core"]
ENG["Investigation · projects · crews<br/>agent brain · workflows"]
MISSION["MissionExecutionEngine"]
ISLAND["Quality island"]
end
subgraph WIN["Windows substrate"]
JAIL["ACL jail"] --> JOB["Job object"] --> TOKEN["Restricted token<br/>+ AppContainer"]
end
subgraph EDGE["Network edge"]
SCRUB["Credential + DLP scrub"]
MODEL["Claude / Gemini clients"] --> FW["Egress firewall"]
end
subgraph DATA["Evidence"]
LEDGER["Chained audit ledger"]
MEMORY["Semantic memory"]
end
subgraph LNX["Linux substrate - WSL2 guest"]
BRIDGE["WslLeasedExecutor<br/>runs on Windows"] --> DAEMON["splat-agent<br/>root daemon"] --> LSM["BPF-LSM lease"]
end
UI --> ENG
UI --> MISSION
MCP --> ENG
MCP --> MISSION
ENG --> MISSION
MISSION --> JAIL
MISSION --> SCRUB --> ISLAND --> MODEL
MISSION --> LEDGER
MISSION --> MEMORY
MCP -- "run_command · run_code" --> BRIDGE
Two things the picture says on purpose.
- Every sensor a template runs goes through
MissionExecutionEngine. The engines that look like autonomy are policies over missions, not a second way to start a process. Three callers use the Windows launcher and job object without the mission sequence around them: remediation fixes, the workflow engine'sTransactionalScopenode, and the Copilot adapter. - Both hosts reach Linux, through the same code. Since 2026-09-24 a template whose tool names
the Linux lease runs through the daemon from either host
(
LinuxLeaseSubstrate), and since 2026-10-10 both check, mount and unmount the guest throughMissionController.Host. Onlysplat-mcplets a caller author a lease:run_commandandrun_codebuild one and call the bridge directly.
| Boundary | Enforced by | Kind | Holds against |
|---|---|---|---|
| A Windows sensor's CPU, memory and process count | Win32JobSandbox — armed at construction, no breakaway |
Kernel object | The tool and every child it spawns |
| A Windows sensor's privileges | RestrictedTokenProvider — privileges stripped, Administrators deny-only, Medium integrity |
Kernel object (token) | Authority borrowed from the operator's admin membership |
| A Windows sensor's network | SensorAppContainer — zero capability SIDs, so no network stack |
Kernel object | Only templates that are gateway-scoped and touch no WMI, Event Log, Defender or SCM; the rest keep a network stack |
| Which host a Windows sensor reaches | — | Declaration | Nothing yet. An AppContainer is network-or-none, so a ToolProcess-scoped template's FQDN is an audited claim |
| SPLAT's own outbound HTTP | EgressFirewallHandler — deny by default, one FQDN per async scope |
Check in SPLAT's process | Model, SIEM and OAuth calls made through the hardened client |
| What leaves a mission | Credential sanitizer, then regex DLP, before the first chunk is yielded | Transform in SPLAT's process | Every consumer of the stream: console, MCP result, model prompt, memory |
| The sensor command line | GovernedArgumentBinder — one pass, position-aware allow-lists, refuse rather than repair |
Check in SPLAT's process | Injection through operator- or agent-supplied parameters |
| Linux exec, file open, IPv4 connect and datagram | lease_lsm.bpf.c — every map key carries the lease's cgroup id |
Kernel LSM | A leased process of any uid, root included. The datagram hook closes the UDP path a connect hook never sees, DNS included |
| Linux CPU, memory, PIDs | cgroup v2, written then read back by the daemon | Kernel (cgroup) | The leased process tree |
| What a Linux workload can see on disk | jail.rs — a mount namespace and pivot_root in pre_exec: the system directories read-only, the working directory, a fresh /tmp, /proc, six device nodes |
Kernel (mount namespace) | Every read or write outside those; /home, /mnt, /var, /sys are absent. Built 2026-09-20, root proof pending |
| Linux workload identity | pre_exec: setgroups, setresgid, setresuid to nobody |
Kernel (credentials) | The workload keeping or regaining root |
| Linux lease lifetime | Daemon reaper — kill inside the policy, then withdraw it | Daemon | An overrunning workload |
| Who may request a lease | Root-owned 0600 shared secret, loopback TCP | Daemon check | Unprivileged guest processes — not another Windows process running as the same user |
| The most any lease may name | ceiling.rs — root-owned /etc/splat/policy.json in the guest, written by provision, intersected with every request |
Daemon check | A token holder asking for more than the operator allowed: executables outside the listed directories, endpoints outside the list, longer or larger than the file says |
Which endpoints a run_command / run_code lease names |
McpEgressPolicy — the operator's --egress list; a caller may request part of it, never more |
Check in splat-mcp |
An agent writing its own destinations into the lease the kernel then enforces |
MCP fs_read / fs_write |
WorkspaceGuard — the final path of the opened handle |
Check in splat-mcp |
.., string-prefix tricks, junctions, symlinks, hard links |
| Who acted | Windows logon session; in splat-mcp, optionally a validated credential |
Check in SPLAT's process | Caller-supplied names |
| The record of what happened | SqliteChainedAuditLedger — SHA-256 chain over salted commitments; HMAC-SHA256 under the operator's key file once one is given (SPLAT_LEDGER_KEY_FILE, or --ledger-key on splat-mcp) |
Tamper evidence | Unkeyed: editing a row in place, not a rewrite by anyone who can write the database file. Keyed: the rewrite too, since no one without the key can recompute a hash. Neither: the deletion of the newest rows, which only a saved anchor catches |
Both hosts build the same engines over the same libraries, and differ in what they wire around them.
Console — App.xaml.cs |
MCP server — Program.cs |
|
|---|---|---|
| Construction | Microsoft.Extensions.DependencyInjection container |
Hand-wired, in dependency order |
| Runtime | net10.0-windows10.0.19041.0, Windows App SDK 2.5.1, self-contained |
net10.0-windows |
| Database | <data root>\Data\missioncontroller.db |
<data root>\audit_ledger.db |
| Data root | Both resolve it through SplatDataRoot: SPLAT_DATA_DIR, else a splat.portable marker beside the executable or one directory above, else %LOCALAPPDATA%\SPLAT. Both adopt an older installation's %LOCALAPPDATA%\MissionController\{Data,Vault} once, and record what they moved |
Same, and doctor prints the root and which rule chose it |
| Operator identity | WindowsOperatorIdentityProvider("winui-console") |
McpOperatorIdentityProvider — the logon session, or a validated SPLAT_MCP_CLIENT_CREDENTIAL |
| Revocation check before a mission | Wired | Wired since 2026-09-19 (F19): Program.cs passes the same IncidentRevocationManager the tool-call gate uses. This row said "constructed with null" until 2026-09-23 |
| Remediation engine | Wired | Absent |
| Linux substrate | Not referenced | WslLeasedExecutor behind run_command and run_code |
| Workflow engine | Registered; no screen resolves it yet | Absent |
| Copilot safety substrate | Registered; no screen resolves it | Absent |
The two hosts share no state. An action taken through splat-mcp lands in a different hash chain
and a different incident memory from one taken in the console, and the console's Security Audit
screen verifies only its own.
MissionExecutionEngine.ExecuteMissionAsync
streams MissionChunks while it runs. In order:
- Validate the template's shape — one model, one tool, one FQDN, declared exit codes, bounded timeout and limits.
- Revocation — both hosts check the operator against the circuit breaker; an unreadable list refuses the mission rather than clearing it (F19, 2026-09-19).
- Bind the parameters into the command line. A value the binder cannot place refuses the
mission before anything is provisioned (
MISSION_BLOCKED_PARAMETER_REJECTED). - Record
MISSION_INITIATED, then provision the jail. From here afinallydrains the jail on every exit path — success, failure, cancellation, an abandoned enumerator. - Launch the sensor suspended inside a job object, with a restricted token and — when the
template qualifies — an AppContainer; assign it to the job, then resume. Containment that
cannot be applied throws, and the mission ends
MISSION_FAILED. - Scrub stdout and stderr — credentials, then DLP — before the first chunk is yielded
(
DLP_REDACTION_ENFORCEDwhen anything matched). No raw copy survives this step. - Judge the tool. An exit code outside the template's
AllowedExitCodes, or a timeout, fails the mission; nothing is analysed or indexed. - Recall a few similar past missions from semantic memory, fenced into the prompt as context
rather than instructions (
SEMANTIC_RECALL_APPLIED). - Analyse. With no model key the stream carries a visible
SimulatedNotice; otherwise the quality island generates, scores, escalates once if needed, and accepts or rejects (AI_ANALYSIS_COMPLETEDorAI_ANALYSIS_REJECTED). - Index only an accepted analysis, drain the jail, and finish —
Completed, orMISSION_FAILEDwith the telemetry still shown when the island rejected the analysis.
Everything that looks like autonomy composes missions. Each of these decides what runs next; none of them runs a process itself.
| Engine | Decides |
|---|---|
GovernedInvestigationLoop |
Whether step N's findings justify a step N+1, and which catalog template it uses — under a hard step ceiling, with no repeats |
ProjectExecutionEngine |
One to six ordered stages, passing bounded, fenced evidence forward |
GovernedCrewEngine |
Sequential or manager-routed crews; tasks with a template run as missions, and delegation cycles are halted |
AgentBrainEngine |
Which templates a natural-language goal decomposes into |
WorkflowExecutionEngine |
A node graph; GovernedMission nodes run through the mission engine and SandboxedScript nodes are refused |
The detail is in ARCHITECTURE-control-plane.md.
Every layer sits somewhere between two ends. At one end, coupled on purpose: one process, static dispatch, a struct laid out the way the kernel lays it out, because the boundary is on a latency- or safety-critical path and a seam there would only add a place to be wrong. At the other, decoupled behind an interface: because more than one implementation exists, or a test has to stand one in. The table says where each layer is, where it should be, and why. "Should be" is judged on what the layer has to hold, not on how enterprise it looks.
| Layer | Today | Should be | Why |
|---|---|---|---|
| BPF hooks ↔ kernel structs | Coupled: CO-RE reads of linux_binprm, file, inode and sockaddr_in; fixed-layout map keys; no serialization |
Coupled | The hooks run inside the syscall. Three straight-line programs, no loops, 79–116 instructions, 40 bytes of stack. Any abstraction here is instructions the verifier must prove and latency on every exec, open and connect on the host |
| Ring-buffer record ↔ daemon | Coupled: a #[repr(C)] cast of the record, its size asserted at compile time |
Coupled | A serializer between kernel and daemon would cost a copy per event and could not make the layout safer than the assertion already does |
| Lease keys ↔ maps | One trait, BpfMapSync, with a live implementation and a recording one |
As is | The one seam the daemon needs: the isolation tests exercise the real key construction against a fake map, and the rollback test needs a map that fails on purpose |
| Daemon ↔ bridge | Decoupled: JSON lines, a versioned protocol, proofs over exact bytes in both directions | Decoupled | Two languages, two machines, a trust boundary. Zero-copy would buy nothing at the rate a tool prints; what matters is that a frame is refused before it is parsed and that neither side can drift from the other's test vectors |
| Bridge ↔ control plane | Decoupled: ILeasedExecutor, ITemplatePolicyCompiler, IHostAddressResolver |
Decoupled | The wire tests need a stand-in daemon and the compiler tests a stand-in resolver; nothing on this path is hot |
| Mission engine | Coupled: one sequence in one process, a try/finally around the jail |
Coupled | The order — validate, bind, record, launch, scrub, judge — is the safety property. A pluggable pipeline would let a step be reordered or skipped by configuration |
| Engines that compose missions | Decoupled from each other, all coupled to IMissionExecutionEngine |
As is | They decide what runs next and never run a process; one execution tier is the invariant |
| Ledger | Behind IAuditLedger, one implementation, SQLite in the host's own process |
As is — not a remote sink | A hash chain's guarantee is local: an entry commits to the one before it in the same file, under one transaction. A remote ledger needs its own chain and an anchor to be worth anything, and this chain already cannot see its newest rows deleted; a network hop would widen that gap, not close it |
| Windows substrate | Coupled to Win32 — job object, restricted token, AppContainer — with one interface per object | As is | The kernel objects are the boundary; the interfaces exist for the doubles in tests, not for a second substrate |
No layer today is more coupled than its row says it should be. The temptation runs the other way: the ledger and the mission engine would each be weakened by the abstraction that would make them look more configurable.
The code holds itself to these; each one exists because its absence shipped once.
- Fail closed, and say so. Required containment that cannot be applied refuses the launch. The daemon exits rather than serve without live BPF maps. A cgroup that did not take its clamp refuses the request. None of them fall back to running uncontained.
- Refuse what cannot be enforced, rather than accept it and drop it. The daemon refuses
overlayFs.enableCow, version-1 envelopes, andreadOnlyFswhen it is not enforcing;SUPPORTED_POLICY.mdis the list. A tool whose parameter is not enforced says so in its description. - Scrub at the boundary. Tool output is sanitized before the stream yields it, because the
stream is itself an egress path —
splat-mcphands it to an external agent. - Every refusal leaves evidence. Refusals and failures seal a ledger row before the stream ends.
- Never present simulation as output. No model key means a
SimulatedNoticein the stream; a metric nobody measured isJobResourceMetrics.Unavailable, not a plausible number. - Measure, then claim. Clamps are read back from the kernel object that holds them. Kernel
evidence carries the SHA-256 of the source it exercised, and
DocumentationDriftTestsholds the README to it in both directions. - Name the tier in the message. A
WorkspaceGuardrefusal says it is a user-space policy check, not a kernel denial.
A class that no host constructs is not a capability, however well it is tested. As of 2026-09-10:
| Code | State |
|---|---|
TemplatePolicyCompiler |
Wired 2026-09-24, and kept in this table only so the row records the change: LinuxLeaseSubstrate compiles every catalog template whose tool names the Linux lease into that mission's LeaseSpec, from the console and splat-mcp alike. run_command still builds its own lease inline |
HMAC keying in SqliteChainedAuditLedger |
Since 2026-09-18, from a key file the operator keeps: --ledger-key on splat-mcp, SPLAT_LEDGER_KEY_FILE for either host. Unkeyed until one is given |
DataRetentionService |
Implemented and tested; no host schedules it |
VertexAiStreamClient, PkceLoopbackAuthService, ServiceAccountAuthService |
Constructed by neither host |
EmbeddedWebhookListener, OAuthCallbackCoordinator, WorkflowScheduler |
Trigger hosting, constructed by neither host — the orchestration plan's Phase 1, subtracted on 2026-09-09 |
InMemoryWorkflowRegistry, SqliteGovernedCrewRegistry |
Tests only |
IWorkflowExecutionEngine, ICopilotSafetySubstrate in the console |
Registered in the container; no view model resolves them |
cow.rs |
Overlay mount code, parked; the workload has had its own mount namespace since 2026-09-20, and mounting the overlay inside it as the working directory is the step that lifts the overlayFs.enableCow refusal |
To re-check a row: git grep -lw <TypeName> -- src. A type named only by its own file, and by
files under tests/, is not wired.