Inside a Trust Harness, in Java 21

In short. Talos is a command-line assistant for developer workspaces that runs a local model through llama.cpp. The model only proposes. A deterministic runtime decides what a turn may do, a person approves changes where the policy asks, and a record says what happened. The runtime governs what Talos launches and writes. It is not an operating-system sandbox, and the boundary is drawn below.

The architecture overview states one rule, that "model output is never enough evidence by itself." The runtime's policy, approval, verification and trace code is organized around that sentence. The codebase is plain Java 21 with no application framework, 890 main source files and 193,597 lines, with a test source set of 935 files and a separate end-to-end test source set.

One turn, two authorities The runtime resolves a contract, applies the mode ceiling and chooses one tool surface. The model proposes a tool call. The runtime checks it against the surface and the permission policy, either of which can deny it, asks a person where the policy requires, and takes a checkpoint before a file change. The action then runs on the host, which is not a sandbox. A file operation stays inside the workspace, while a build command runs with the user permissions and an unisolated network. The runtime verifies the result and records the outcome. ONE TURN, TWO AUTHORITIES MODEL RUNTIME contract resolved in code posture the mode sets a ceiling tool surface one list, two checks surface check before approval or IO permission deny, ask or allow approval a person, where asked checkpoint before a file change proposes a tool call deny deny HOST, NOT A SANDBOX file operation inside the workspace build command user permissions, network not isolated verify readback and checks outcome and trace status from the records
Figure 1. One turn, two authorities and the host boundary. The model lane proposes. The runtime lane decides, executes and records. File operations stay in the workspace, while an approved build runs with the user's own permissions.

One request, end to end

Take a request such as "In settings.py, change TIMEOUT from 30 to 60."

A contract before the model. The runtime first resolves the request into a task contract, here a file edit with a change requested and allowed and settings.py as the expected target. The resolver is deterministic code. The contract's own documentation says it is "not a planner and not an LLM classifier."

A ceiling from the mode. The session's mode applies a posture to that contract. In a read-only mode the contract is rewritten so that no change is allowed, whatever the request said.

A tool surface. From the contract, the runtime picks the tools this turn may use, here the read tools plus the edit and write tools. The system prompt describes only those tools, and the model call is filtered to the same list.

The proposal. The model answers with a tool call, typically native tool_calls from llama.cpp, or a tool call written as text when native calling is unavailable. Either way it is a proposal.

The checks. The proposed call passes a fixed sequence of an unknown-tool check, the current-turn surface check, normalization of the payload, phase policy and validation. Then the permission policy decides allow, ask or deny.

The approval. For a file edit the default is ask. The terminal shows the call with a unified diff preview, capped at 60 lines and 6,000 characters, and waits. End of input or Ctrl-C denies.

A checkpoint. With checkpointing on, the default, a content-addressed checkpoint of the target is taken after approval and before the write. A failed checkpoint blocks the change.

The write and the check. The edit runs inside the workspace boundary, the file is read back, and a verifier checks that the approved replacement is present. The turn ends with a completion status, guards compare the model's final answer with the records, and a JSON trace of the turn is written.

Three of those steps carry most of the design. They are the contract, the tool surface and the way commands run.

The contract is code

Posture is applied to the contract as a record and an early return.

public record EffectiveTurn(
        TaskContract taskContract,
        ExecutionPhase phase,
        boolean forceNativeSurfaceRecompute
) {}

public static EffectiveTurn apply(CapabilityPosture posture, TaskContract contract) {
    CapabilityPosture safePosture = posture == null ? CapabilityPosture.AGENT : posture;
    if (!safePosture.readOnly()) {
        return new EffectiveTurn(contract, CurrentTurnPlan.defaultPhaseFor(contract), false);
    }
    return new EffectiveTurn(readOnly(contract), ExecutionPhase.INSPECT, true);
}

The turn plan built from that contract is also a record. Its compact constructor recompiles the task expectations from the contract and throws if a caller passed different ones, so the plan's expectations, request obligation and requested results cannot disagree with the contract. Its tool lists are not checked there. They are enforced at the point of use.

One surface, enforced twice

The tools the model is told about and the tools the runtime accepts come from the same plan. The prompt is the weaker enforcement, because prompt text is advice to a model. The second enforcement runs in the turn processor before approval and before any file is touched.

List<String> allowed = ctx.nativeToolSpecs().stream()
        .filter(Objects::nonNull)
        .map(ToolSpec::name)
        .filter(name -> name != null && !name.isBlank())
        .distinct()
        .sorted()
        .toList();
// ...
return ToolResult.fail(ToolError.denied(
        "Current-turn tool surface did not allow " + requested
                + ". Allowed tools: " + allowedText + "."));

When no surface has been set, this check passes. Every mode that calls the model sets one during turn preparation. Two tests pin the property for native calls and for calls written as JSON text, each outside the dispatched surface and each rejected before approval or IO. A retry cannot widen the surface. The dispatcher throws when an escalated retry would exceed "the captured current-turn authority".

Files the user pins into context are framed in the prompt as "untrusted workspace file content" and "reference data only". That framing is prompt wording, and the documentation's general rule applies to it. Prompt wording is not security. The enforcement is the surface check and the permission policy.

Commands, and where containment stops

Talos has a command tool and no general shell tool. talos.run_command takes a profile id. Raw command shapes are refused by key before planning.

private static final List<String> RAW_COMMAND_KEYS = List.of(
        "command", "cmd", "shell", "executable", "argv", "command_line");

A matching parameter ends with "Raw shell commands are not supported. Use an approved command profile." The built-in profiles that talos.run_command accepts are five Gradle tasks, each run with --no-daemon. Arguments are checked per profile in a switch expression, and the five Gradle profiles share one grammar. Gradle profiles accept only --tests <selector>, --stacktrace and --info, and caller arguments are screened against fixed lists of shell operators, network tokens and destructive tokens. The command reaches ProcessBuilder as an argument list, with standard input closed and the process tree killed on timeout.

A workspace can declare its own profiles in .talos/profiles.yaml. They register only when the file's raw bytes match a SHA-256 pin stored outside the workspace, in the Talos home directory. A byte change returns them to untrusted, a corrupt pin fails closed, and trust is checked again at planning and before execution. The declaration file is also a protected path. A model that edits the workspace cannot add a profile by editing it.

Profiles narrow what Talos launches and with which arguments. They do not contain what the launched program does.

There is no container, restricted token or network namespace. The approval prompt is the boundary before a command runs, and the operating system's normal permissions are the boundary after.

Permission as an ordered ladder

The permission policy is read top to bottom. A path that escapes the workspace is denied. A change to a protected path is denied without a prompt. Then come configured deny rules, protected reads that require asking, configured ask rules and configured allow rules. Read-only tools default to allow. Everything else asks.

Remembered approval is narrow. A session can remember approval for file writes inside the workspace, apart from writes under .github, while destructive calls, typed workspace operations and command execution always ask again. A command never rides on an approval given to a file write. A configured allow rule, however, does skip the prompt, which is the point of configuring one, unless a scope warning or a change to the file's line endings, byte order mark or final newline forces an ask.

What Java 21 contributes

Records carry the turn's data. The main source tree has 815 record declarations. The task contract has 11 components, the turn plan 19 and the trace record 20, and compact constructors are where some of their invariants are checked.

Four sealed hierarchies exist. The most used is Result, the output of a turn. Switches over it are exhaustive with no default branch, so a new result kind is a compile error at every switch that forgot it. In this excerpt the comments are trimmed.

return switch (r) {
    case Result.Ok ok                -> isAbortMarker(ok.text) ? "aborted" : "ok";
    case Result.Streamed s           -> statusOfStreamed(s.fullText);
    case Result.Error ignored        -> "error";
    case Result.Info ignored         -> "info";
    case Result.TrustedInfo ignored  -> "info";
    case Result.Table ignored        -> "info";
    case Result.StreamStart ignored  -> "stream";
    case Result.StreamChunk ignored  -> "stream";
    case Result.StreamEnd ignored    -> "stream";
    case Result.ToolProgress ignored -> "stream";
};

Unnamed patterns are a preview feature in Java 21, so each unused binding carries a name.

Concurrency is plain. A turn runs synchronously. Virtual threads appear in four auxiliary places, such as indexing. A child process's output is drained by daemon platform threads in a fixed pool, and a comment in the runner explains that pipe reads and close() can ignore interruption, so cleanup runs on those workers and never on the caller. Record patterns, structured concurrency and scoped values are not used.

The main dependencies are Lucene 10.2.2 for retrieval, JLine 3.30.13 and picocli 4.7.6 for the terminal, Jackson 2.17.1 for JSON and YAML, PDFBox and POI for document text, HtmlUnit for static web checks, sqlite-jdbc, SLF4J with Logback, java-diff-utils for approval diffs and ArchUnit 1.4.2 for architecture tests, built with Gradle 8.14. The release build produces a per-user Windows MSI through jpackage, an app zip, a Linux app-image archive, a CycloneDX SBOM and checksums files.

Boundaries the build enforces

Package direction is checked twice in gradle check. A source scan applies six dependency rules against a baseline file of accepted violations that is currently empty. An ArchUnit test applies eleven rules to the compiled bytecode. One of them keeps the tool-call loop from depending on the terminal layer. Its reason clause is trimmed here.

@ArchTest
static final ArchRule runtime_toolcall_must_not_depend_on_cli_repl =
        noClasses().that().resideInAPackage(RUNTIME_TOOLCALL)
                .should().dependOnClassesThat().resideInAPackage(CLI_REPL);

The runtime does not depend on the terminal packages. The terminal layer implements interfaces the runtime owns instead, among them RuntimeTurnContext, TurnRouter and ApprovalGate.

The package map presents its diagram as a target shape, and the code has not fully reached it. The turn executor, the model dispatcher and the outcome classifier still live in the terminal modes package, and the turn executor alone is 3,444 lines. A guard against new violations is not a claim that the old structure is finished.

The model at arm's length

Talos runs one model backend, llama.cpp. By default it launches and owns the server, which binds only to 127.0.0.1. Before launch it refuses a port that already has a listener, with "Another server is not restarted by Talos." After launch it checks that the listener belongs to the process it started, by process id and start time, and an observation it cannot make fails closed. The probe's documentation disclaims a hostile kernel and a race between the last check and the request. A connect_only mode attaches to an existing server instead of launching one.

For GPT-OSS, the Harmony chat format is applied by llama.cpp's embedded template. Talos does not parse Harmony tokens.

The trace

With session persistence on, the default, each turn writes a local JSON trace, schema version 5. It holds the contract, the tool surfaces, permission decisions, approvals, checkpoints, executions, verification and the rendered outcome. In the trace, prompts, answers and file contents are reduced to hashes and counts, while file paths, tool names and the parts of the request that name a target are kept. The turn log written beside it stores the request and the committed answer as plain text.

The trace is evidence for review, not an integrity guarantee. The documentation states that traces and logs are not tamper-evident. There is no hash chain, no signature and no append-only enforcement. A documentation test fails the build if the maintained docs drop that statement or use the word "tamper-proof".

Where to look in the code

StepOwner
ContractTaskContractResolver, TaskContract
PostureCapabilityPosturePolicy
Tool surfaceToolSurfacePlanner, CurrentTurnPlan
Model callTurnModelDispatcher
Tool loopToolCallLoop, capped at 10 iterations
Checks and executionTurnProcessor
PermissionDeclarativePermissionPolicy
CommandsCommandToolPlanner, CommandArgumentPolicy, ProcessCommandRunner
VerificationStaticTaskVerifier
TraceLocalTurnTrace

What transfers