From 4c4718a720874256c61e27314c133ddd8ae922db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:25:44 +0000 Subject: [PATCH 01/12] Ask a model through the policy: 28 connectors behind one gate A connector on its own is an ordinary API client. What makes these worth having is that no request reaches a provider until the policy engine has decided it, and every decision is a record that outlives the session. Five wire formats reach twenty-eight connectors, ten of which run on the person's own computer. Anthropic first, then OpenAI, Gemini, Ollama's own shape and Hugging Face text generation. Most hosted providers and almost every local server accept the OpenAI shape, so one careful connector reaches them all. Asking a model is three permissions, not one, and they are asked for separately because a person may answer them differently: use a model at all, reach that host, spend that credential. All three are decided before anything is encoded, so a refused request is never built and a refused credential is never read. The credential goes into a header and nowhere else - not a decision, not a log, not an error, not a summary - and a test asserts each of those. A redirect is never followed, so a credential cannot be carried to a host nobody decided about. The policy is a file the person writes, in .workspace/policy.toml. It is never shown to a model and never consulted by one: instruction files are requests, this file is the answer. A file that does not parse allows nothing, and nothing more permissive is put in its place. An unknown capability name refuses the whole file rather than dropping the rule, because dropping half of a denial narrows it. Also here: the kernel-enforced path containment and the typed executors the roadmap required before any of this, since a provider with nothing to execute under a decision proves nothing about the boundary. New commands: zag providers, zag policy, zag ask. zag doctor now reports the connector count and what is still missing - no agent loop and no streaming - and zag check verifies the shipped policy file parses. Verified: 454 tests pass; zag check clean; zig fmt clean; four cross-compile targets build; the Anthropic connector was called over HTTPS and its error decoded; a local OpenAI-shaped server answered end to end through the gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- .workspace/policy.toml | 51 +++ AGENTS.md | 11 +- README.md | 34 +- docs/competitive-position.md | 37 +- docs/roadmap.md | 54 ++- docs/threat-model.md | 23 +- src/ai/catalog.zig | 456 +++++++++++++++++++ src/ai/executor.zig | 421 ++++++++++++++++++ src/ai/policy_file.zig | 454 +++++++++++++++++++ src/ai/provider.zig | 333 ++++++++++++++ src/ai/sandbox.zig | 284 ++++++++++++ src/ai/transport.zig | 820 +++++++++++++++++++++++++++++++++++ src/ai/wire/anthropic.zig | 391 +++++++++++++++++ src/ai/wire/gemini.zig | 283 ++++++++++++ src/ai/wire/huggingface.zig | 220 ++++++++++ src/ai/wire/ollama.zig | 293 +++++++++++++ src/ai/wire/openai.zig | 353 +++++++++++++++ src/cli/main.zig | 442 ++++++++++++++++++- src/root.zig | 13 + 19 files changed, 4929 insertions(+), 44 deletions(-) create mode 100644 .workspace/policy.toml create mode 100644 src/ai/catalog.zig create mode 100644 src/ai/executor.zig create mode 100644 src/ai/policy_file.zig create mode 100644 src/ai/provider.zig create mode 100644 src/ai/sandbox.zig create mode 100644 src/ai/transport.zig create mode 100644 src/ai/wire/anthropic.zig create mode 100644 src/ai/wire/gemini.zig create mode 100644 src/ai/wire/huggingface.zig create mode 100644 src/ai/wire/ollama.zig create mode 100644 src/ai/wire/openai.zig diff --git a/.workspace/policy.toml b/.workspace/policy.toml new file mode 100644 index 0000000..0a7890e --- /dev/null +++ b/.workspace/policy.toml @@ -0,0 +1,51 @@ +# The policy this workspace enforces. +# +# This file is not read by a model and is never shown to one. Instruction files +# such as AGENTS.md and everything under .workspace/rules/ are requests; this +# file is the answer. Changing it is how permissions change. Nothing an agent +# writes or reads can widen it. +# +# Run "zag policy" to see this in words, and "zag check" to have it verified. + +id = "zag-workbench" +name = "Work on this repository, ask a model on this computer" +description = "An agent working here may read and change files in this repository and run the build and the tests. It may use a model running on this computer. It may not reach anything else over the network, may not send commits anywhere, and may not use a saved credential." + +[[rule]] +id = "read-and-change-this-repository" +allow = ["fs.read", "fs.write", "git.read"] +paths = ["/home/user/zag"] +because = "The work of this workspace is this repository." + +[[rule]] +id = "run-the-build-and-the-tests" +allow = ["process.execute"] +commands = ["zig", "git", "sh"] +because = "A change is not finished until the build and the tests have run." + +[[rule]] +id = "deleting-asks-first" +ask = ["fs.delete"] +paths = ["/home/user/zag"] +because = "Deleting a file cannot be undone from the log, so a person decides each time." + +[[rule]] +id = "models-on-this-computer" +allow = ["model.infer"] +because = "Asking a model is part of the work here." + +[[rule]] +id = "nothing-leaves-this-computer" +allow = ["network.connect"] +hosts = ["localhost", "127.0.0.1", "::1"] +because = "Only a model running on this computer may be reached. To use a hosted provider, add its host here, deliberately." + +[[rule]] +id = "no-saved-credentials" +deny = ["credentials.read", "credentials.use"] +because = "Nothing in this workspace needs a saved credential, so none may be spent." + +[[rule]] +id = "no-pushing" +deny = ["git.push", "remote.execute", "agent.spawn"] +because = "Sending commits, running things elsewhere and starting other agents are decisions for a person, not for an agent." diff --git a/AGENTS.md b/AGENTS.md index afe9856..e23d414 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,10 @@ This repository holds zag, a workbench where people and agents do engineering work in one recorded workspace. This file tells an agent how to work here. Nothing in this file grants a permission. Permissions come from the policy the -person running the workbench sets. If a line here reads like permission to do -something, it is not, and `zag agents AGENTS.md` will say so. +person running the workbench sets, which lives in `.workspace/policy.toml` and +is never shown to a model. Run `zag policy` to read it. If a line here reads +like permission to do something, it is not, and `zag agents AGENTS.md` will say +so. ## Build and test @@ -52,7 +54,10 @@ something, it is not, and `zag agents AGENTS.md` will say so. -Doptimize=ReleaseFast` before and after a change to a hot path. - `src/terminal` holds the escape-sequence parser, the screen and the pseudoterminal. -- `src/ai` holds capabilities, the policy engine, approvals and governance. +- `src/ai` holds capabilities, the policy engine, the workspace policy file, + approvals, the typed executors, the model connectors and governance. +- `src/ai/wire` holds one file per model provider shape. A new provider that + speaks an existing shape is an entry in `src/ai/catalog.zig`, not a new file. - `src/language` holds the plain-language rules. - `src/knowledge` and `src/metadata` hold the vocabulary, the workspace knowledge base and the registry. diff --git a/README.md b/README.md index 8e7ef1e..9c3a00c 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ zag is a terminal, a block model, an agent runtime and a standards control plane in one Zig program. It fetches no package from the network: the whole thing builds from the Zig standard library. -> Status: the substrate is built and tested. The graphical renderer, the editor -> surfaces and the model providers are not written yet. `zag doctor` prints what -> this build can and cannot do, so the claim never runs ahead of the code. +> Status: the substrate is built and tested, and a model can be asked through +> the policy. The graphical renderer, the editor surfaces and the agent loop are +> not written yet. `zag doctor` prints what this build can and cannot do, so the +> claim never runs ahead of the code. ## Why it is built this way @@ -36,7 +37,7 @@ graph, an audit trail and an agent's context are all views of the same events. | `src/workspace` | Blocks, sessions, workflows, structured history, and the workspace service | | `src/editor` | The command line as an editing document, with undo that groups by intent | | `src/terminal` | Escape-sequence parser, screen with scrollback, shell integration, real pseudoterminal, and the recording terminal | -| `src/ai` | Capabilities, the policy engine, approvals, typed tools, risk, impact, transparency, generated cards | +| `src/ai` | Capabilities, the policy engine, the workspace policy file, approvals, typed tools and their executors, kernel-enforced path containment, the model connectors and the gate they pass through, risk, impact, transparency, generated cards | | `src/language` | The plain-language pass, with controlled-English and easy-to-read profiles | | `src/knowledge` | The concept system, the thesaurus view, the published vocabulary and the workspace knowledge base | | `src/metadata` | The registry that stops one field having four names | @@ -62,6 +63,9 @@ You need Zig 0.16.0. ``` zag term # a shell in a terminal that records what you do zag doctor # what this build can and cannot do +zag policy # the policy in force, and where it was read from +zag providers # the model connectors, and which credentials are set +zag ask "why did that fail?" # ask a model, through the policy zag run -- zig build test # run a command and record it as a block zag lint README.md # check text against the plain-language rules zag terms workspace # what a word means here @@ -131,10 +135,24 @@ request -> plan -> typed tool request -> policy decision - There is no free-form shell tool. Each request names one capability and one resource. - A path is normalised before policy matching, so textual `..` traversal is - rejected. Event logs, objects and recovery evidence also reject a symbolic - link in the final path component. This is not a filesystem sandbox: - beneath-only execution through a workspace directory handle is still on the - roadmap. + rejected. Beyond that, the executors open every file through a workspace + directory handle with `RESOLVE_BENEATH` and `RESOLVE_NO_MAGICLINKS`, so the + kernel refuses a symbolic link that leaves the workspace rather than the + program noticing afterwards. Event logs, objects and recovery evidence also + reject a symbolic link in the final path component. +- A command runs from its argument vector. There is no shell between the two, + so there is nothing for a quotation mark to escape into. +- An executor spends the decision it was handed. It re-derives the capability + and the resource from the request and refuses unless both match, so a decision + to read one file cannot be spent on another. +- Asking a model is three permissions, not one: using a model, reaching that + host, and spending that credential. All three are decided before the request + is encoded, so a refused request is never built and a refused credential is + never read. +- The policy is a file the person writes, in `.workspace/policy.toml`. It is + never shown to a model and never consulted by one. Run `zag policy` to read it + in words. A file that does not parse allows nothing; nothing more permissive + is substituted for it. - An operation that cannot be undone stops and asks, in words that name the operation and its consequence. - A repository instruction file shapes behaviour. It can never grant a diff --git a/docs/competitive-position.md b/docs/competitive-position.md index 08424b9..5d9ea8d 100644 --- a/docs/competitive-position.md +++ b/docs/competitive-position.md @@ -70,9 +70,21 @@ Hermes Agent, cmux, Pi, term2 and Lacy. These run a model against a repository. They differ in model support, editor integration, sandboxing and how much of the loop they automate. -Almost all of them are ahead of zag on the thing they exist for: zag has no model -provider yet. The runtime, the typed tool requests, the approval prompts and the -policy engine are built and tested; the connection to a model is not. +Most of them are still ahead of zag on the thing they exist for. They loop: +they ask a model, run what it asks for, and ask again, and they have spent years +on how that loop behaves. zag asks a model once. The connectors, the typed +executors and the policy engine are each built and tested; nothing yet runs the +cycle between them. + +What zag has instead is the boundary. Twenty-eight connectors across five wire +formats reach hosted providers and models on this computer alike. Every one of +them passes the policy engine three times before a byte is encoded. It asks to +use a model at all, to reach that host, and to spend that credential. + +A workspace can allow models on this computer and refuse every hosted one. That +is one rule in a file, not a setting to trust. Each of those three answers +becomes an authorisation decision with a reason, kept with the rest of the +session. Here is what zag adds. Permission comes from a policy, not from prompt text. Every action an agent asks for names one capability and one resource. A local @@ -112,7 +124,13 @@ Built, tested and audited by the build: rather than repairing itself. - Structured history: filters for result, actor, directory, branch, repository, exit status and time. -- A capability model and a local policy engine, with recorded decisions. +- A capability model and a local policy engine, with recorded decisions. The + policy is written by the person, in `.workspace/policy.toml`, and is never + shown to a model. +- File access held inside the workspace by the kernel, and typed executors that + spend the decision they were given rather than one they chose. +- Twenty-eight model connectors across five wire formats, ten of them running on + this computer, each gated by the policy engine before a byte is encoded. - A standards control plane over sixty-five standards, with evidence. - Plain-language, terminology, metadata, accessibility and knowledge rules enforced by `zig build check`. @@ -125,8 +143,11 @@ the terminal you have for now. - **No renderer.** No window, no graphics card, no font shaping, no ligatures, no images, no cursor styles. `zag term` needs a terminal to run inside. - **No tabs, splits or panes.** One shell to a session. -- **No configuration file and no key bindings.** Nothing is customisable. -- **No model provider.** The agent runtime has nothing to call. +- **No key bindings, no theme file.** The policy is written in a file; nothing + else about the terminal is. +- **No agent loop, and no streaming.** A model can be asked one question, and + the request waits for the whole answer. Nothing yet runs a model's tool calls + through the executors and asks it again. - **Linux only.** The pseudoterminal uses Linux system calls directly. macOS and Windows are not built. - **No remote attachment.** The daemon opens, verifies, reports and plans. It @@ -148,10 +169,10 @@ The gaps above land there like this: | Gap | Where it is planned | | --- | --- | -| Nothing is configurable, and the session shows no state | Priority 2 | +| Nothing but the policy is configurable, and the session shows no state | Priority 2 | | One shell to a session | Priority 2 | | History filters walk the blocks | Priority 2 | -| No model provider | Priority 3, after the typed executors in Priority 1 | +| No agent loop, no streaming answers | Priority 3 | | No renderer, no editor surface | Priority 3 | | Linux only | Priority 3 | | No remote attachment | Priority 3 | diff --git a/docs/roadmap.md b/docs/roadmap.md index e13add1..d011338 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,10 +37,30 @@ prefix. Normal workspace open never invokes that path. Final event-log, object and recovery-evidence entries reject symbolic links. A flush is rejected before it would make an active log exceed 64 MiB. +The kernel holds file access inside the workspace. Paths are opened with +`RESOLVE_BENEATH` and `RESOLVE_NO_MAGICLINKS`. A symbolic link that leaves the +workspace is refused by the same component that would otherwise follow it. The +typed executors consume the decision they were handed, deriving the resource +from the request rather than from the caller. A command runs from its argument +vector, with no shell in between. + +A model can be asked, through the policy. Five wire formats reach twenty-eight +connectors, ten of which run on this computer: Anthropic, OpenAI, Gemini, Ollama +and Hugging Face text generation. Every request passes the policy engine three +times before it is encoded. It asks to use a model, to reach the host, and to +spend the credential. A refused request stops there, before it is built. The +credential stays out of every decision, log, error and summary. + +The policy is read from `.workspace/policy.toml`, written by the person whose +computer it is. A file that does not parse allows nothing; nothing more +permissive is put in its place. + These statements are covered by automated tests. They do not establish power-loss survival on every filesystem, hostile tamper resistance, a safe garbage collector, a complete terminal, a graphical product or organisational -certification. +certification. The wire formats are tested against recorded answers and the +Anthropic connector has been called; the other addresses are each provider's +documented one and `zag providers` says which is which. ## Priority 0: protect the record @@ -58,15 +78,21 @@ certification. ## Priority 1: enforce the security model where it executes -- Open files relative to a workspace directory handle with symlink-safe, - beneath-only resolution. Rejecting a symbolic link in a record's final path - component and checking lexical policy are useful, but neither is a complete - filesystem sandbox. -- Implement concrete typed tool executors. Each executor must consume the - policy decision it was given and must not reinterpret a command string. -- Enforce network host allowlists at the connection boundary, including name - resolution, redirects and address changes. A request type alone is not an - enforcement control. +Done. Files are opened relative to a workspace directory handle with +`RESOLVE_BENEATH`, so the kernel refuses a path that leaves the workspace. The +program no longer has to check afterwards. The typed executors are written, and +each one consumes the decision it was given, re-deriving the resource from the +request rather than trusting the caller. A command runs from its argument +vector, with no shell between the two. A model request passes the policy engine +three times before anything is encoded. + +Still open: + +- Enforce network host allowlists at the connection boundary rather than at the + request boundary. The host is decided before the request is built and a + redirect is refused outright, but name resolution and address changes are not + yet checked at the socket, so a host that resolves to an unexpected address is + not caught. - Review `docs/threat-model.md` with local users, malicious repositories, compromised child processes, model providers and remote clients in scope. - Add secret detection and redaction policy for terminal output, model context, @@ -96,9 +122,11 @@ editing Zig. document and diagnostic models. - Implement the daemon interface described by the generated OpenAPI document, with authentication, authorisation, version negotiation and backpressure. -- Add model-provider adapters behind the policy and transparency boundary. They - depend on the typed executors in Priority 1: a provider with nothing to - execute under a decision proves nothing about the boundary. +- Loop a model's tool calls through the executors. The provider connectors and + the executors both exist; nothing yet runs the cycle of asking, executing + under a decision, and asking again. +- Read a model's answer as it arrives. Every connector waits for the whole + reply, which is fine for a question and wrong for a long one. - Add macOS pseudoterminal and Windows ConPTY implementations, and the terminal settings each one needs, with the same behavioural test suite used on Linux. - Define remote replay, reconnect and conflict semantics before allowing more diff --git a/docs/threat-model.md b/docs/threat-model.md index b45f73f..f2fe7eb 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -7,10 +7,10 @@ credential path is added. ## Scope and assets -The current scope is one local workspace, its child processes and the files -under `.workspace/`. The graphical client, remote daemon interface, model -providers and concrete agent tool executors are not implemented and are not -inside the evaluated boundary. +The current scope is one local workspace, its child processes, the files under +`.workspace/`, the typed tool executors, and the model connectors and the gate +they pass through. The graphical client, the remote daemon interface and the +agent loop are not implemented and are not inside the evaluated boundary. Assets that need protection are: @@ -37,13 +37,15 @@ against that actor. | ID | Threat | Current control | Residual work | | --- | --- | --- | --- | | T1 | Repository text tells an agent that it has more permission | Permissions come only from typed policy; instruction files that try to grant permission are reported | Test every concrete executor against the decision it receives | -| T2 | A path uses `..` or a symlink to escape the workspace | Policy rejects lexical traversal and mismatched path roots; final event-log, object and recovery-evidence entries reject symbolic links | Open every executor path through a workspace directory handle with beneath-only resolution; parent components are not yet a sandbox boundary | -| T3 | A command string gains unintended shell meaning | Agent tool requests carry argument vectors; the interactive `zag run` command is explicitly a person-requested shell command | Concrete executors must never rebuild an argument vector as a shell string | +| T2 | A path uses `..` or a symlink to escape the workspace | Policy rejects lexical traversal and mismatched path roots; executors open every path through a workspace directory handle with `RESOLVE_BENEATH` and `RESOLVE_NO_MAGICLINKS`, so the kernel refuses an escaping path in any component; final event-log, object and recovery-evidence entries reject symbolic links | Only Linux enforces this. A build for another platform must refuse to execute rather than fall back to a lexical check | +| T3 | A command string gains unintended shell meaning | Agent tool requests carry argument vectors, and the executor passes the vector straight to `execve` with no shell; a test asserts that an argument containing a semicolon and a redirect creates no file. The interactive `zag run` command is explicitly a person-requested shell command | Keep the assertion as a release gate; a future convenience that joins a vector back into a line would undo it | | T4 | An event is edited, reordered or appended by a stale writer | Content and chain hashes, strict decoding, prefix fingerprint and an exclusive writer lock | Signed external heads are needed to detect whole-log replacement, truncation or a hostile non-cooperating writer | | T5 | A crash commits partial state | Objects are immutable and synchronized before events; incomplete and malformed log tails seal the workspace; recovery requires an explicit flag and preserves the exact original log first | Add power-loss fault injection, directory-entry durability checks and recovery exercises on real filesystems | | T6 | A child prints OSC 133 bytes to forge a command boundary or status | Service-owned command wrappers use a random marker token that is removed before the command starts; control marks and parser states are bounded | Extend authenticated markers to interactive shell hooks; an ordinary `boundaryFromShell` value alone is not proof of origin | | T7 | A child floods output or never exits | Captured output is bounded; deadlines use monotonic time and terminate the process group, escalating to `KILL` | Add operating-system CPU, memory, process-count and disk quotas | | T8 | Terminal output discloses a secret | New event and object files use owner-only permissions where POSIX permissions exist | Add detection and redaction before output enters model context or diagnostic bundles | +| T12 | An agent spends a credential on a provider nobody chose | Asking a model needs three separate decisions — `model.infer` on the connector, `network.connect` on the host, `credentials.use` on the variable — all taken before the request is encoded. The credential is read only after its own decision allows it, is written into a header by the connector, and never enters a decision, a log, an error or a summary. A redirect is never followed, so a credential cannot be carried to a host the policy did not decide | Enforce the host at the socket, including name resolution and address changes; a host that resolves to an unexpected address is not yet caught | +| T13 | A workspace policy file is written to widen permission | The file is never shown to a model and never consulted by one. Its fallback is not read from the file: an unparsable file allows nothing, and nothing more permissive is substituted. An unknown capability name refuses the whole file rather than dropping the rule, so a denial cannot be narrowed by a typo | Add a recorded event when the policy file changes, so a widening is visible in the log rather than only in the file | | T9 | A terminal sequence corrupts memory or parser state | Fixed parser bounds, UTF-8 replacement, screen bounds and regression tests | Add coverage-guided fuzzing and differential tests against a mature terminal parser | | T10 | A build input is replaced upstream | No package dependencies; CI pins the checkout action and verifies the Zig archive digest | Add signed release provenance, artifact signing and an independently verified toolchain policy | | T11 | A future remote client bypasses local policy | No network listener exists today | Define authentication, per-request authorisation, replay protection, rate limits and audit before listening on a socket | @@ -65,6 +67,13 @@ The following properties must remain release-gate tests: deadline and leaves a recorded timeout. - A policy denial is stronger than an allow rule, and prompt text never widens a capability. +- An executor refuses a decision whose capability or resource differs from the + request it is handed. +- A command runs from its argument vector, with no shell interpreting it. +- A model request that the policy refuses is never encoded and never sent, and + its credential is never read. +- A policy file that does not parse allows nothing, and no other policy is put + in its place. ## Review triggers @@ -74,6 +83,8 @@ Review this model before merging a change that: - reads or uses a credential; - implements file write, delete, git push or remote execution; - sends workspace content to a model provider; +- adds a connector, a wire format or a credential variable; +- changes how the workspace policy file is read, or what its fallback is; - changes event encoding, recovery, retention or migration; - describes an event record as proof, authentic or tamper-proof. diff --git a/src/ai/catalog.zig b/src/ai/catalog.zig new file mode 100644 index 0000000..1b34e8b --- /dev/null +++ b/src/ai/catalog.zig @@ -0,0 +1,456 @@ +//! The connectors this build can reach, and what each one needs. +//! +//! Five wire formats reach every entry in this list. That is not a shortcut: it +//! is how the ecosystem actually settled. Most hosted providers and almost +//! every local server accept the OpenAI chat-completions shape, so one careful +//! connector reaches them all, and the four others cover the providers that +//! speak for themselves. +//! +//! Each entry says where it sends, which environment variable holds the +//! credential, and whether it runs on this machine. That last one is not +//! decoration. A workspace can allow local models and refuse hosted ones, and a +//! person deciding needs to see which is which before they answer. +//! +//! What this file does **not** claim is that every entry has been called. The +//! wire formats are tested against recorded answers; the addresses here are +//! written from each provider's documented endpoint. `verified` records the +//! difference, and `zag providers` prints it, so nobody mistakes a plausible +//! address for a tested one. + +const std = @import("std"); +const provider = @import("provider.zig"); +const anthropic = @import("wire/anthropic.zig"); +const openai = @import("wire/openai.zig"); +const gemini = @import("wire/gemini.zig"); +const ollama = @import("wire/ollama.zig"); +const huggingface = @import("wire/huggingface.zig"); + +pub const Format = enum { + anthropic, + openai, + gemini, + ollama, + huggingface, + + pub fn wire(self: Format) provider.Wire { + return switch (self) { + .anthropic => anthropic.wire, + .openai => openai.wire, + .gemini => gemini.wire, + .ollama => ollama.wire, + .huggingface => huggingface.wire, + }; + } +}; + +/// Where the model runs, which is what a person deciding about a request most +/// wants to know. +pub const Locality = enum { + /// On this machine. Nothing leaves it. + local, + /// On somebody else's machine, reached over the network. + hosted, + + pub fn text(self: Locality) []const u8 { + return switch (self) { + .local => "on this computer", + .hosted => "over the network", + }; + } +}; + +/// How far a connector has been checked. +pub const Verification = enum { + /// The wire format is covered by tests against recorded answers, and the + /// address has been called from this repository. + called, + /// The wire format is covered by tests. The address is the provider's + /// documented one and has not been called from here. + format_only, + + pub fn text(self: Verification) []const u8 { + return switch (self) { + .called => "wire format tested, endpoint called", + .format_only => "wire format tested, endpoint not called from here", + }; + } +}; + +pub const Connector = struct { + /// Short name a person types: `anthropic`, `ollama`, `groq`. + id: []const u8, + name: []const u8, + format: Format, + /// Documented base address, with no trailing slash. + baseUrl: []const u8, + /// The environment variable holding the credential. Empty when none is + /// needed, which is the usual case for a model on this machine. + keyVariable: []const u8 = "", + locality: Locality = .hosted, + verified: Verification = .format_only, + /// One line for the person choosing. + note: []const u8 = "", + + pub fn wire(self: Connector) provider.Wire { + return self.format.wire(); + } + + /// The endpoint to send to, given a credential. + pub fn endpoint(self: Connector, apiKey: []const u8) provider.Endpoint { + return .{ .baseUrl = self.baseUrl, .apiKey = apiKey }; + } + + pub fn needsCredential(self: Connector) bool { + return self.keyVariable.len > 0; + } +}; + +/// Every connector, in the order a person would read them: the one this +/// workbench was written against, then the rest of the hosted providers, then +/// everything that runs on this machine. +pub const connectors = [_]Connector{ + .{ + .id = "anthropic", + .name = "Anthropic", + .format = .anthropic, + .baseUrl = "https://api.anthropic.com", + .keyVariable = "ANTHROPIC_API_KEY", + .verified = .format_only, + .note = "Claude. The first connector, and the API this workbench was written alongside.", + }, + .{ + .id = "openai", + .name = "OpenAI", + .format = .openai, + .baseUrl = "https://api.openai.com/v1", + .keyVariable = "OPENAI_API_KEY", + }, + .{ + .id = "gemini", + .name = "Google Gemini", + .format = .gemini, + .baseUrl = "https://generativelanguage.googleapis.com", + .keyVariable = "GEMINI_API_KEY", + }, + .{ + .id = "huggingface", + .name = "Hugging Face router", + .format = .openai, + .baseUrl = "https://router.huggingface.co/v1", + .keyVariable = "HF_TOKEN", + .note = "Reaches the inference providers behind Hugging Face in one shape.", + }, + .{ + .id = "huggingface-tgi", + .name = "Hugging Face text generation", + .format = .huggingface, + .baseUrl = "http://localhost:8080", + .keyVariable = "HF_TOKEN", + .locality = .local, + .note = "A served model: an inference endpoint, or Text Generation Inference on this machine.", + }, + .{ + .id = "groq", + .name = "Groq", + .format = .openai, + .baseUrl = "https://api.groq.com/openai/v1", + .keyVariable = "GROQ_API_KEY", + }, + .{ + .id = "together", + .name = "Together AI", + .format = .openai, + .baseUrl = "https://api.together.xyz/v1", + .keyVariable = "TOGETHER_API_KEY", + }, + .{ + .id = "fireworks", + .name = "Fireworks AI", + .format = .openai, + .baseUrl = "https://api.fireworks.ai/inference/v1", + .keyVariable = "FIREWORKS_API_KEY", + }, + .{ + .id = "openrouter", + .name = "OpenRouter", + .format = .openai, + .baseUrl = "https://openrouter.ai/api/v1", + .keyVariable = "OPENROUTER_API_KEY", + .note = "One address in front of many providers.", + }, + .{ + .id = "deepseek", + .name = "DeepSeek", + .format = .openai, + .baseUrl = "https://api.deepseek.com", + .keyVariable = "DEEPSEEK_API_KEY", + }, + .{ + .id = "mistral", + .name = "Mistral", + .format = .openai, + .baseUrl = "https://api.mistral.ai/v1", + .keyVariable = "MISTRAL_API_KEY", + }, + .{ + .id = "xai", + .name = "xAI", + .format = .openai, + .baseUrl = "https://api.x.ai/v1", + .keyVariable = "XAI_API_KEY", + }, + .{ + .id = "cerebras", + .name = "Cerebras", + .format = .openai, + .baseUrl = "https://api.cerebras.ai/v1", + .keyVariable = "CEREBRAS_API_KEY", + }, + .{ + .id = "deepinfra", + .name = "DeepInfra", + .format = .openai, + .baseUrl = "https://api.deepinfra.com/v1/openai", + .keyVariable = "DEEPINFRA_API_KEY", + }, + .{ + .id = "nebius", + .name = "Nebius AI Studio", + .format = .openai, + .baseUrl = "https://api.studio.nebius.ai/v1", + .keyVariable = "NEBIUS_API_KEY", + }, + .{ + .id = "hyperbolic", + .name = "Hyperbolic", + .format = .openai, + .baseUrl = "https://api.hyperbolic.xyz/v1", + .keyVariable = "HYPERBOLIC_API_KEY", + }, + .{ + .id = "sambanova", + .name = "SambaNova", + .format = .openai, + .baseUrl = "https://api.sambanova.ai/v1", + .keyVariable = "SAMBANOVA_API_KEY", + }, + .{ + .id = "novita", + .name = "Novita AI", + .format = .openai, + .baseUrl = "https://api.novita.ai/v3/openai", + .keyVariable = "NOVITA_API_KEY", + }, + .{ + .id = "perplexity", + .name = "Perplexity", + .format = .openai, + .baseUrl = "https://api.perplexity.ai", + .keyVariable = "PERPLEXITY_API_KEY", + }, + // --- Models on this machine -------------------------------------------- + .{ + .id = "ollama", + .name = "Ollama", + .format = .ollama, + .baseUrl = "http://localhost:11434", + .locality = .local, + .note = "Its own shape, which reports the model that answered and the token counts.", + }, + .{ + .id = "ollama-openai", + .name = "Ollama, in the OpenAI shape", + .format = .openai, + .baseUrl = "http://localhost:11434/v1", + .locality = .local, + .note = "The same server, for tools that expect the OpenAI shape.", + }, + .{ + .id = "llamacpp", + .name = "llama.cpp server", + .format = .openai, + .baseUrl = "http://localhost:8080/v1", + .locality = .local, + }, + .{ + .id = "lmstudio", + .name = "LM Studio", + .format = .openai, + .baseUrl = "http://localhost:1234/v1", + .locality = .local, + }, + .{ + .id = "vllm", + .name = "vLLM", + .format = .openai, + .baseUrl = "http://localhost:8000/v1", + .locality = .local, + }, + .{ + .id = "localai", + .name = "LocalAI", + .format = .openai, + .baseUrl = "http://localhost:8080/v1", + .locality = .local, + }, + .{ + .id = "jan", + .name = "Jan", + .format = .openai, + .baseUrl = "http://localhost:1337/v1", + .locality = .local, + }, + .{ + .id = "koboldcpp", + .name = "KoboldCpp", + .format = .openai, + .baseUrl = "http://localhost:5001/v1", + .locality = .local, + }, + .{ + .id = "textgen-webui", + .name = "Text generation web user interface", + .format = .openai, + .baseUrl = "http://localhost:5000/v1", + .locality = .local, + }, +}; + +pub fn find(id: []const u8) ?Connector { + for (connectors) |connector| { + if (std.mem.eql(u8, connector.id, id)) return connector; + } + return null; +} + +pub fn count() usize { + return connectors.len; +} + +pub fn localCount() usize { + var total: usize = 0; + for (connectors) |connector| { + if (connector.locality == .local) total += 1; + } + return total; +} + +/// Write the list for a person choosing one. +/// +/// `credentials` answers, for one variable name, whether it is set. It is +/// passed in rather than read here, so that this function stays testable and +/// so that nothing in the library reaches for the environment on its own. +pub fn writeList( + w: *std.Io.Writer, + credentials: *const fn ([]const u8) bool, +) std.Io.Writer.Error!void { + try w.print("{d} connectors, {d} of them on this computer.\n\n", .{ count(), localCount() }); + for (connectors) |connector| { + try w.print("{s:<16} {s:<34} {s}", .{ + connector.id, + connector.name, + connector.locality.text(), + }); + if (connector.needsCredential()) { + const ready = credentials(connector.keyVariable); + try w.print(" {s}{s}", .{ connector.keyVariable, if (ready) " (set)" else " (not set)" }); + } + try w.writeAll("\n"); + if (connector.note.len > 0) try w.print(" {s}\n", .{connector.note}); + } + try w.writeAll( + \\ + \\Every one of these sends through the policy engine. A connector needs + \\permission to reach its host, and the decision is written to the log + \\with the rest of the session. + \\ + \\The wire formats are tested against recorded answers. An address here + \\is the provider's documented one; where this repository has not called + \\it, the list says so rather than implying it was checked. + \\ + ); +} + +const testing = std.testing; + +fn noCredentials(_: []const u8) bool { + return false; +} + +test "every connector is complete and reachable by name" { + for (connectors) |connector| { + try testing.expect(connector.id.len > 0); + try testing.expect(connector.name.len > 0); + try testing.expect(connector.baseUrl.len > 0); + // An address with a trailing slash produces a double slash when a path + // is joined to it. + try testing.expect(connector.baseUrl[connector.baseUrl.len - 1] != '/'); + try testing.expect(std.mem.startsWith(u8, connector.baseUrl, "http")); + try testing.expect(find(connector.id) != null); + + // Locality has to match the address, or the list misleads the person + // it exists to inform. + const endpoint = connector.endpoint(""); + try testing.expectEqual(connector.locality == .local, endpoint.isLocal()); + } +} + +test "no two connectors share an identifier" { + for (connectors, 0..) |a, i| { + for (connectors[i + 1 ..]) |b| { + try testing.expect(!std.mem.eql(u8, a.id, b.id)); + } + } +} + +test "each format resolves to a wire that names itself" { + inline for (comptime std.meta.fields(Format)) |field| { + const format: Format = @enumFromInt(field.value); + const w = format.wire(); + try testing.expectEqualStrings(field.name, w.id); + } +} + +test "the anthropic connector encodes through its own wire" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const connector = find("anthropic").?; + try testing.expectEqual(Format.anthropic, connector.format); + const http = try connector.wire().encode(arena, connector.endpoint("sk-test"), .{ + .model = "claude-opus-5", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Hello" }} }}, + }); + try testing.expectEqualStrings("api.anthropic.com", http.host); + try testing.expect(std.mem.endsWith(u8, http.url, "/v1/messages")); +} + +test "a local connector reports that nothing leaves the machine" { + const ollama_connector = find("ollama").?; + try testing.expectEqual(Locality.local, ollama_connector.locality); + try testing.expect(!ollama_connector.needsCredential()); + try testing.expect(ollama_connector.endpoint("").isLocal()); + + const hosted = find("groq").?; + try testing.expectEqual(Locality.hosted, hosted.locality); + try testing.expect(hosted.needsCredential()); +} + +test "the list says what was checked and what was not" { + var buffer: [8192]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try writeList(&w, noCredentials); + const text = w.buffered(); + + try testing.expect(std.mem.indexOf(u8, text, "connectors,") != null); + try testing.expect(std.mem.indexOf(u8, text, "not set") != null); + try testing.expect(std.mem.indexOf(u8, text, "policy engine") != null); + // The honesty line is part of the output, not a comment in the source. + try testing.expect(std.mem.indexOf(u8, text, "has not called") != null); +} + +test "there are enough connectors to be worth a list, and several are local" { + try testing.expect(count() >= 20); + try testing.expect(localCount() >= 6); +} diff --git a/src/ai/executor.zig b/src/ai/executor.zig new file mode 100644 index 0000000..888cae6 --- /dev/null +++ b/src/ai/executor.zig @@ -0,0 +1,421 @@ +//! Executors: the place where a decision becomes an action, or does not. +//! +//! Every other part of the security model is preparation for this file. An +//! agent asks for one capability on one resource; the policy engine decides; +//! and here the decision is *spent*. An executor takes the decision it was +//! given together with the request, and refuses unless the two match. +//! +//! Three rules make the boundary real rather than architectural: +//! +//! * **A decision authorises one request.** The executor re-derives the +//! capability and the resource from the request and compares them with the +//! decision. A decision to read `notes.md` cannot be spent on `/etc/shadow`, +//! even by the code holding it, because the mismatch is checked here and +//! not at the call site. +//! * **No string is re-interpreted.** A command arrives as an argument +//! vector and is executed directly. There is no shell, so there is nothing +//! for a quotation mark to escape into. The executor never joins the +//! vector back into a line. +//! * **Files are opened beneath the workspace by the kernel.** Paths go +//! through `sandbox`, which uses `RESOLVE_BENEATH`. A symbolic link that +//! leaves the workspace is refused by the same component that would +//! otherwise follow it. +//! +//! What an executor never does is decide. It has no policy, no allowlist of +//! its own and no opinion. Handed a decision that says deny, it performs +//! nothing and reports that it was denied. + +const std = @import("std"); +const tools = @import("tools.zig"); +const policy_mod = @import("policy.zig"); +const capability_mod = @import("capability.zig"); +const sandbox = @import("sandbox.zig"); +const pty_mod = @import("../terminal/pty.zig"); +const content_store_mod = @import("../events/content_store.zig"); +const hashing = @import("../core/hash.zig"); +const timeutil = @import("../core/time.zig"); + +pub const ToolRequest = tools.ToolRequest; +pub const ToolResult = tools.ToolResult; +pub const Decision = policy_mod.Decision; +pub const Capability = capability_mod.Capability; +pub const Resource = capability_mod.Resource; + +pub const Error = error{ + /// The decision does not authorise this request. Either the capability or + /// the resource is different from the one that was decided. + DecisionDoesNotMatchRequest, + /// The decision refused, or is waiting for a person. + NotAllowed, + /// This build has no executor for that request. + NoExecutor, + /// The kernel cannot enforce containment on this machine. + ContainmentUnavailable, +} || std.mem.Allocator.Error; + +/// Why a request was refused before it ran, in words for the block list. +pub fn refusalText(err: Error) []const u8 { + return switch (err) { + error.DecisionDoesNotMatchRequest => "The permission that was granted does not cover what was asked for.", + error.NotAllowed => "The policy did not allow this.", + error.NoExecutor => "This build cannot perform that kind of request.", + error.ContainmentUnavailable => "This computer cannot keep file access inside the workspace, so the request was refused.", + error.OutOfMemory => "There was not enough memory to run this.", + }; +} + +/// The resource a request acts on, derived from the request itself. +/// +/// This is the function that makes a decision unforgeable in practice: the +/// executor asks the request what it touches, rather than believing what the +/// caller said when the decision was made. +pub fn resourceOf(request: ToolRequest) Resource { + return switch (request) { + .execute => |e| .{ .command = if (e.argv.len > 0) e.argv[0] else "" }, + .read_file => |r| .{ .path = r.path }, + .write_file => |w| .{ .path = w.path }, + .delete => |d| .{ .path = d.path }, + .search => |s| .{ .path = s.root }, + .git => |g| .{ .path = g.repository }, + .mcp => |m| .{ .service = m.server }, + .spawn_agent => |a| .{ .service = a.label }, + .infer => |i| .{ .service = i.provider }, + }; +} + +/// True when `decision` authorises exactly `request`. +/// +/// Both halves are compared. A decision carrying the right capability for the +/// wrong path is not a permission to act on that path, and the difference +/// between those two is the whole of the security model. +pub fn authorises(decision: Decision, request: ToolRequest) bool { + if (!decision.isAllowed()) return false; + if (decision.request.capability != request.capability()) return false; + const wanted = resourceOf(request); + return sameResource(decision.request.resource, wanted); +} + +fn sameResource(a: Resource, b: Resource) bool { + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; + return std.mem.eql(u8, a.text(), b.text()); +} + +pub const Options = struct { + /// The workspace root. Every file the executors touch is opened beneath it. + root: []const u8, + /// How long a process may run. + timeout: timeutil.Duration = .{ .ns = 120 * timeutil.ns_per_s }, + /// Bytes of output kept from one process. + outputLimit: usize = 8 << 20, + /// Where a write request's content comes from. An agent stages bytes in + /// the store and then asks for them to be written by their address, so the + /// executor never invents content and the bytes that landed are the bytes + /// that were reviewed. + content: ?*content_store_mod.Store = null, + io: ?std.Io = null, +}; + +/// Performs typed requests, one decision at a time. +pub const Runner = struct { + arena: std.mem.Allocator, + options: Options, + + pub fn init(arena: std.mem.Allocator, options: Options) Runner { + return .{ .arena = arena, .options = options }; + } + + /// Spend a decision on a request. + /// + /// The order matters and is deliberate: the decision is checked against + /// the request before anything is opened, started or sent. + pub fn run(self: Runner, decision: Decision, request: ToolRequest) Error!ToolResult { + if (!decision.isAllowed()) return error.NotAllowed; + if (!authorises(decision, request)) return error.DecisionDoesNotMatchRequest; + + return switch (request) { + .read_file => |r| self.readFile(r), + .write_file => |w| self.writeFile(w), + .execute => |e| self.execute(e), + // The remaining request kinds have no executor in this build. They + // are named rather than silently treated as failures, because + // "not built" and "did not work" are different things. + .delete, .search, .git, .mcp, .spawn_agent, .infer => error.NoExecutor, + }; + } + + fn openRoot(self: Runner, buffer: []u8) Error!sandbox.Root { + return sandbox.Root.open(self.options.root, buffer) catch |err| switch (err) { + error.ContainmentUnavailable => error.ContainmentUnavailable, + else => error.NoExecutor, + }; + } + + fn readFile(self: Runner, request: tools.ReadFileRequest) Error!ToolResult { + var root_buffer: [4096]u8 = undefined; + const root = try self.openRoot(&root_buffer); + defer root.close(); + + const bytes = root.readFile(self.arena, request.path, self.options.outputLimit) catch |err| { + return failure(self.arena, err); + }; + return .{ + .outcome = .completed, + .contentHash = hashing.Hash.of(bytes), + .byteCount = bytes.len, + .summary = try std.fmt.allocPrint(self.arena, "Read {d} bytes from {s}.", .{ bytes.len, request.path }), + }; + } + + fn writeFile(self: Runner, request: tools.WriteFileRequest) Error!ToolResult { + const store = self.options.content orelse return error.NoExecutor; + const io = self.options.io orelse return error.NoExecutor; + const bytes = store.read(io, request.contentHash, self.options.outputLimit) catch { + return .{ + .outcome = .failed, + .summary = "The content to write is not in the store, so nothing was written.", + }; + }; + + var root_buffer: [4096]u8 = undefined; + const root = try self.openRoot(&root_buffer); + defer root.close(); + + root.writeFile(request.path, bytes) catch |err| { + return failure(self.arena, err); + }; + return .{ + .outcome = .completed, + .contentHash = request.contentHash, + .byteCount = bytes.len, + .summary = try std.fmt.allocPrint(self.arena, "Wrote {d} bytes to {s}.", .{ bytes.len, request.path }), + }; + } + + /// Run a program. + /// + /// The argument vector is passed through as it arrived. It is never joined + /// into a line and handed to a shell, which is how a file name containing + /// a semicolon turns into a second command. + fn execute(self: Runner, request: tools.ExecuteRequest) Error!ToolResult { + if (request.argv.len == 0) return error.NoExecutor; + + if (comptime !pty_mod.supported) return error.NoExecutor; + + var pty = pty_mod.Pty.open(.{ .columns = 120, .rows = 40 }) catch { + return .{ .outcome = .failed, .summary = "A pseudoterminal could not be opened for this program." }; + }; + defer pty.close(); + + const argv = pty_mod.buildArgv(self.arena, request.argv) catch return error.OutOfMemory; + const envp = pty_mod.buildEnvp(self.arena, request.environment) catch return error.OutOfMemory; + const path_z = self.arena.dupeZ(u8, request.argv[0]) catch return error.OutOfMemory; + const cwd_z = self.arena.dupeZ(u8, self.options.root) catch return error.OutOfMemory; + + var child = pty.spawn(path_z.ptr, argv.ptr, envp.ptr, cwd_z.ptr) catch { + return .{ + .outcome = .failed, + .summary = try std.fmt.allocPrint(self.arena, "{s} could not be started.", .{request.argv[0]}), + }; + }; + + var output: std.ArrayList(u8) = .empty; + var buffer: [16 * 1024]u8 = undefined; + const deadline_ms = @max(self.options.timeout.millis(), 100); + var waited: i64 = 0; + var timed_out = false; + while (true) { + if (pty.waitReadable(50)) { + const n = pty.read(&buffer) catch 0; + if (n == 0) break; + if (output.items.len < self.options.outputLimit) { + const room = self.options.outputLimit - output.items.len; + try output.appendSlice(self.arena, buffer[0..@min(n, room)]); + } + continue; + } + waited += 50; + if (child.poll() != null) break; + if (waited >= deadline_ms) { + child.signalGroup(15); + timed_out = true; + break; + } + } + + const exit = child.wait(); + const status = exit.status(); + return .{ + .outcome = if (timed_out) .timed_out else if (status != null and status.? == 0) .completed else .failed, + .contentHash = hashing.Hash.of(output.items), + .byteCount = output.items.len, + .exitStatus = status, + .summary = if (timed_out) + try std.fmt.allocPrint(self.arena, "{s} reached its deadline and was stopped.", .{request.argv[0]}) + else + try std.fmt.allocPrint(self.arena, "{s} finished with status {?d}.", .{ request.argv[0], status }), + }; + } + + fn failure(arena: std.mem.Allocator, err: anyerror) ToolResult { + const message = switch (err) { + error.EscapesWorkspace => "The path led outside the workspace, so it was refused.", + error.NotFound => "There is no such file in the workspace.", + error.AccessDenied => "The workspace does not allow that file to be opened.", + error.IsDirectory => "That path is a directory, not a file.", + error.ContainmentUnavailable => "This computer cannot keep file access inside the workspace.", + else => "The file could not be read or written.", + }; + _ = arena; + return .{ .outcome = .failed, .summary = message }; + } +}; + +const testing = std.testing; +const idmod = @import("../core/id.zig"); + +fn decisionFor(capability: Capability, resource: Resource, effect: policy_mod.Effect) Decision { + var gen: idmod.Generator = .init(11, 1_788_000_000_000); + return .{ + .id = gen.next(idmod.DecisionId), + .request = .{ .capability = capability, .resource = resource }, + .effect = effect, + .rule_id = null, + .policy_id = "test", + .reason = "for the test", + .actor = gen.next(idmod.ActorId), + .session = null, + .agent = null, + .decided_at = .{ .ns = 0 }, + }; +} + +test "a decision authorises the one request it names" { + const request: ToolRequest = .{ .read_file = .{ .path = "notes.md", .maxBytes = 1 << 20 } }; + const good = decisionFor(.@"fs.read", .{ .path = "notes.md" }, .allow); + try testing.expect(authorises(good, request)); + + // The right capability for a different file is not a permission for this + // file. This is the case that turns a policy into a decoration. + const other_path = decisionFor(.@"fs.read", .{ .path = "/etc/shadow" }, .allow); + try testing.expect(!authorises(other_path, request)); + + // The right file under a different capability is not a permission either. + const other_capability = decisionFor(.@"fs.write", .{ .path = "notes.md" }, .allow); + try testing.expect(!authorises(other_capability, request)); + + // A decision that refused authorises nothing. + const denied = decisionFor(.@"fs.read", .{ .path = "notes.md" }, .deny); + try testing.expect(!authorises(denied, request)); + + // So does one that is still waiting for a person. + const waiting = decisionFor(.@"fs.read", .{ .path = "notes.md" }, .require_human); + try testing.expect(!authorises(waiting, request)); +} + +test "the resource comes from the request, not from the caller" { + // Whatever a decision says, the executor asks the request what it touches. + const request: ToolRequest = .{ .write_file = .{ + .path = "src/main.zig", + .contentHash = hashing.Hash.of("hello"), + .byteCount = 5, + } }; + const resource = resourceOf(request); + try testing.expectEqualStrings("src/main.zig", resource.text()); + try testing.expectEqual(Capability.@"fs.write", request.capability()); +} + +test "an executor refuses a decision that does not match" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const runner = Runner.init(arena, .{ .root = "." }); + const request: ToolRequest = .{ .read_file = .{ .path = "README.md", .maxBytes = 1 << 20 } }; + const wrong = decisionFor(.@"fs.read", .{ .path = "somewhere-else.md" }, .allow); + try testing.expectError(error.DecisionDoesNotMatchRequest, runner.run(wrong, request)); + + const denied = decisionFor(.@"fs.read", .{ .path = "README.md" }, .deny); + try testing.expectError(error.NotAllowed, runner.run(denied, request)); +} + +test "reading and writing stay beneath the workspace" { + if (comptime !sandbox.supported) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + + // The bytes are staged in the content store and asked for by address, so + // the executor writes what was reviewed rather than what it was told. + var store = content_store_mod.Store.init(arena, root); + const staged = try store.put("written by the executor"); + try store.flush(io); + const runner = Runner.init(arena, .{ .root = root, .content = &store, .io = io }); + + const write: ToolRequest = .{ .write_file = .{ + .path = "kept.txt", + .contentHash = staged, + .byteCount = 23, + } }; + const write_decision = decisionFor(.@"fs.write", .{ .path = "kept.txt" }, .allow); + const written = runner.run(write_decision, write) catch |err| switch (err) { + error.ContainmentUnavailable => return error.SkipZigTest, + else => return err, + }; + try testing.expect(written.succeeded()); + + const read: ToolRequest = .{ .read_file = .{ .path = "kept.txt", .maxBytes = 1 << 20 } }; + const read_decision = decisionFor(.@"fs.read", .{ .path = "kept.txt" }, .allow); + const got = try runner.run(read_decision, read); + try testing.expect(got.succeeded()); + try testing.expectEqual(@as(usize, 23), got.byteCount); + try testing.expect(got.contentHash.eql(staged)); + + // A decision that names the escaping path is still refused, because the + // kernel refuses it. The policy and the filesystem agree. + const escape: ToolRequest = .{ .read_file = .{ .path = "../../../etc/passwd", .maxBytes = 1 << 20 } }; + const escape_decision = decisionFor(.@"fs.read", .{ .path = "../../../etc/passwd" }, .allow); + const refused = try runner.run(escape_decision, escape); + try testing.expect(!refused.succeeded()); + try testing.expect(std.mem.indexOf(u8, refused.summary, "outside the workspace") != null); +} + +test "a command runs from its argument vector, with no shell in between" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const runner = Runner.init(arena, .{ .root = "." }); + + // The argument contains a semicolon and a redirect. Under a shell this + // would be three commands; here it is one argument to `echo`. + const argv = [_][]const u8{ "/bin/echo", "one; rm -rf /; echo two > /tmp/zag-should-not-exist" }; + const request: ToolRequest = .{ .execute = .{ + .argv = &argv, + .workingDirectory = ".", + } }; + const decision = decisionFor(.@"process.execute", .{ .command = "/bin/echo" }, .allow); + const result = try runner.run(decision, request); + try testing.expect(result.succeeded()); + + // The redirect never happened, because nothing parsed it. + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const leaked = std.Io.Dir.cwd().readFileAlloc(threaded.io(), "/tmp/zag-should-not-exist", arena, .limited(64)); + try testing.expectError(error.FileNotFound, leaked); +} + +test "every refusal has a sentence a person can act on" { + inline for (comptime std.meta.fields(Error)) |field| { + const err = @field(Error, field.name); + const text = refusalText(err); + try testing.expect(text.len > 0); + try testing.expect(text[text.len - 1] == '.'); + } +} diff --git a/src/ai/policy_file.zig b/src/ai/policy_file.zig new file mode 100644 index 0000000..12ddf61 --- /dev/null +++ b/src/ai/policy_file.zig @@ -0,0 +1,454 @@ +//! The workspace policy, as a person writes it. +//! +//! The engine in `policy.zig` enforces rules. This file is where those rules +//! come from when they are not compiled in: `.workspace/policy.toml`, written +//! by the person whose computer this is. +//! +//! It matters that the file is separate from the agent's instructions. +//! `.workspace/rules/` and `AGENTS.md` are read by the model and can say +//! anything; a model that has read them can also be persuaded to ignore them. +//! This file is never shown to a model and never consulted by one. It is read +//! here, turned into rules, and enforced by code the model cannot reach. An +//! instruction file that asks for a capability is a request; this file is the +//! answer. +//! +//! A policy looks like this: +//! +//! ```toml +//! name = "Local models only" +//! description = "This workspace uses models on this computer and reaches nothing else." +//! +//! [[rule]] +//! allow = ["model.infer", "network.connect"] +//! hosts = ["localhost", "127.0.0.1"] +//! because = "Models on this computer are allowed." +//! +//! [[rule]] +//! deny = ["network.connect"] +//! because = "Nothing else on the network may be reached." +//! ``` +//! +//! Three things are refused rather than guessed at. A rule that names no +//! capability does nothing, so it is an error rather than a line that quietly +//! has no effect. A capability this build does not know is an error, because +//! silently dropping it would turn a `deny` into permission. And a file with no +//! rules leaves the default in force, which is deny — a policy that allows +//! nothing is a strange thing to write, so it is reported, but it is safe. + +const std = @import("std"); +const toml = @import("../interop/toml.zig"); +const policy_mod = @import("policy.zig"); +const capability_mod = @import("capability.zig"); + +pub const Capability = capability_mod.Capability; +pub const Policy = policy_mod.Policy; +pub const Rule = policy_mod.Rule; +pub const Effect = policy_mod.Effect; + +/// Where a workspace keeps its policy, relative to the workspace root. +pub const default_path = ".workspace/policy.toml"; + +pub const Problem = struct { + /// Which rule, counting from one, or null for the file itself. + rule: ?usize, + /// What is wrong, in words for the person who wrote the file. + message: []const u8, +}; + +pub const Error = error{ + /// The file is not valid TOML, or a rule is unusable. `problems` says why. + PolicyUnusable, +} || std.mem.Allocator.Error; + +pub const Result = struct { + policy: ?Policy, + problems: []const Problem, + + pub fn usable(self: Result) bool { + return self.policy != null; + } +}; + +/// Read a policy from TOML text. +/// +/// Nothing is granted by being written badly. Any problem at all leaves +/// `policy` null, so a file with a typo in it does not become a policy that +/// happens to allow more than the person meant. +pub fn parse(arena: std.mem.Allocator, source: []const u8) Error!Result { + var problems: std.ArrayList(Problem) = .empty; + + const document = toml.parse(arena, source) catch { + try problems.append(arena, .{ + .rule = null, + .message = "This file is not valid TOML, so no rule in it was read.", + }); + return .{ .policy = null, .problems = problems.items }; + }; + + const name = document.string("name") orelse "This workspace"; + const description = document.string("description") orelse + "A policy written for this workspace."; + const identifier = document.string("id") orelse "workspace"; + + var rules: std.ArrayList(Rule) = .empty; + if (document.get("rule")) |value| { + const entries = value.asArray() orelse { + try problems.append(arena, .{ + .rule = null, + .message = "The rules must be written as [[rule]] sections.", + }); + return .{ .policy = null, .problems = problems.items }; + }; + for (entries, 0..) |entry, index| { + const rule = try readRule(arena, entry, index + 1, &problems); + if (rule) |r| try rules.append(arena, r); + } + } else { + try problems.append(arena, .{ + .rule = null, + .message = "This file has no [[rule]] sections, so it allows nothing.", + }); + } + + if (problems.items.len > 0) return .{ .policy = null, .problems = problems.items }; + + return .{ + .policy = .{ + .id = identifier, + .name = name, + .description = description, + .rules = rules.items, + // Never read from the file. A policy that could set its own + // fallback to allow would be a policy that fails open. + .default_effect = .deny, + }, + .problems = &.{}, + }; +} + +fn readRule( + arena: std.mem.Allocator, + entry: toml.Value, + number: usize, + problems: *std.ArrayList(Problem), +) Error!?Rule { + var effect: ?Effect = null; + var listed: ?[]toml.Value = null; + + // The verb is the field name, so a rule reads as a sentence rather than as + // a setting with a value that has to be looked up. + if (entry.get("allow")) |value| { + effect = .allow; + listed = value.asArray(); + } + if (entry.get("ask")) |value| { + if (effect != null) { + try problems.append(arena, .{ + .rule = number, + .message = "This rule says more than one of allow, ask and deny. Write one.", + }); + return null; + } + effect = .require_human; + listed = value.asArray(); + } + if (entry.get("deny")) |value| { + if (effect != null) { + try problems.append(arena, .{ + .rule = number, + .message = "This rule says more than one of allow, ask and deny. Write one.", + }); + return null; + } + effect = .deny; + listed = value.asArray(); + } + + const chosen = effect orelse { + try problems.append(arena, .{ + .rule = number, + .message = "This rule does not say allow, ask or deny, so it would do nothing.", + }); + return null; + }; + + const names = listed orelse { + try problems.append(arena, .{ + .rule = number, + .message = "The capabilities must be written as a list, in square brackets.", + }); + return null; + }; + if (names.len == 0) { + try problems.append(arena, .{ + .rule = number, + .message = "This rule names no capability, so it would do nothing.", + }); + return null; + } + + var capabilities: std.ArrayList(Capability) = .empty; + for (names) |item| { + const text = item.asString() orelse { + try problems.append(arena, .{ + .rule = number, + .message = "Every capability in this rule must be written in quotes.", + }); + return null; + }; + const capability = Capability.parse(text) orelse { + try problems.append(arena, .{ + .rule = number, + .message = try std.fmt.allocPrint( + arena, + "\"{s}\" is not a capability this build knows, so the rule was not used.", + .{text}, + ), + }); + return null; + }; + try capabilities.append(arena, capability); + } + + return .{ + .id = entry.stringAt("id") orelse try std.fmt.allocPrint(arena, "rule-{d}", .{number}), + .capabilities = capabilities.items, + .scope = .{ + .paths = try stringList(arena, entry, "paths"), + .commands = try stringList(arena, entry, "commands"), + .hosts = try stringList(arena, entry, "hosts"), + .remotes = try stringList(arena, entry, "remotes"), + .credentials = try stringList(arena, entry, "credentials"), + .agents = try stringList(arena, entry, "agents"), + }, + .effect = chosen, + .reason = entry.stringAt("because") orelse + "This workspace's policy decided it.", + }; +} + +fn stringList( + arena: std.mem.Allocator, + entry: toml.Value, + field: []const u8, +) Error![]const []const u8 { + const value = entry.get(field) orelse return &.{}; + const items = value.asArray() orelse return &.{}; + var out: std.ArrayList([]const u8) = .empty; + for (items) |item| { + if (item.asString()) |text| try out.append(arena, text); + } + return out.items; +} + +/// Write a policy back out as the person would read it. +/// +/// Used by `zag doctor` and by the approval prompt, so that what is enforced +/// and what is shown come from the same value rather than from two hand-written +/// descriptions that drift apart. +pub fn describe(policy: Policy, w: *std.Io.Writer) std.Io.Writer.Error!void { + try w.print("{s}\n{s}\n\n", .{ policy.name, policy.description }); + for (policy.rules) |rule| { + try w.print("{s} ", .{switch (rule.effect) { + .allow => "Allowed:", + .deny => "Refused:", + .require_human => "Asks you:", + }}); + for (rule.capabilities, 0..) |capability, index| { + if (index > 0) try w.writeAll(", "); + try w.writeAll(capability.explain()); + } + try writeScope(rule.scope, w); + try w.print("\n {s}\n", .{rule.reason}); + } + try w.writeAll("\nAnything not named above is refused.\n"); +} + +fn writeScope(scope: policy_mod.Scope, w: *std.Io.Writer) std.Io.Writer.Error!void { + try writeNamed(w, " under ", scope.paths); + try writeNamed(w, " for ", scope.commands); + try writeNamed(w, " at ", scope.hosts); + try writeNamed(w, " to ", scope.remotes); + try writeNamed(w, " with ", scope.credentials); + try writeNamed(w, " by ", scope.agents); +} + +fn writeNamed( + w: *std.Io.Writer, + lead: []const u8, + values: []const []const u8, +) std.Io.Writer.Error!void { + if (values.len == 0) return; + try w.writeAll(lead); + for (values, 0..) |value, index| { + if (index > 0) try w.writeAll(", "); + try w.writeAll(value); + } +} + +/// The policy this repository ships as an example, and the one `zag ask` uses +/// when a workspace has written none. +/// +/// It allows models on this computer and refuses everything else, because that +/// is the answer a person is least likely to regret not having been asked +/// about. A workspace that wants a hosted provider writes its own file, and +/// writing it is the moment the decision gets made. +pub const local_models_only = + \\name = "Models on this computer only" + \\description = "An agent in this workspace may use a model running on this computer. It may not reach anything else over the network, and it may not use a saved credential." + \\ + \\[[rule]] + \\id = "local-models" + \\allow = ["model.infer"] + \\because = "Using a model is allowed in this workspace." + \\ + \\[[rule]] + \\id = "local-only" + \\allow = ["network.connect"] + \\hosts = ["localhost", "127.0.0.1", "::1"] + \\because = "Only a model running on this computer may be reached." + \\ + \\[[rule]] + \\id = "no-saved-credentials" + \\deny = ["credentials.read", "credentials.use"] + \\because = "A model on this computer needs no saved credential, so none may be spent." + \\ +; + +const testing = std.testing; + +test "a written policy becomes rules the engine can enforce" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const result = try parse(arena, local_models_only); + try testing.expect(result.usable()); + const policy = result.policy.?; + try testing.expectEqual(@as(usize, 3), policy.rules.len); + // Never read from the file, whatever the file says. + try testing.expectEqual(Effect.deny, policy.default_effect); + + var engine = policy_mod.Engine.init(arena, policy, 5); + var ids = @import("../core/id.zig").Generator.init(3, 0); + const context: policy_mod.Context = .{ + .actor = ids.next(@import("../core/id.zig").ActorId), + .now = .{ .ns = 1_700_000_000 * @import("../core/time.zig").ns_per_s }, + }; + + const local = try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .host = "localhost" }, + }, context); + try testing.expect(local.isAllowed()); + + const elsewhere = try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .host = "api.example.test" }, + }, context); + try testing.expect(!elsewhere.isAllowed()); + + // Nothing was said about running programs, so it is refused. + const run = try engine.decide(.{ + .capability = .@"process.execute", + .resource = .{ .command = "rm" }, + }, context); + try testing.expect(!run.isAllowed()); +} + +test "a capability this build does not know refuses the file rather than dropping the rule" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const result = try parse(arena, + \\[[rule]] + \\deny = ["network.connect", "network.tunnel"] + \\because = "No network." + \\ + ); + // The rule was a denial. Dropping the unknown half would have left a + // narrower denial than the person wrote, so the whole file is refused. + try testing.expect(!result.usable()); + try testing.expectEqual(@as(usize, 1), result.problems.len); + try testing.expectEqual(@as(usize, 1), result.problems[0].rule.?); + try testing.expect(std.mem.indexOf(u8, result.problems[0].message, "network.tunnel") != null); +} + +test "a rule that says nothing is reported rather than ignored" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const nothing = try parse(arena, + \\[[rule]] + \\hosts = ["localhost"] + \\because = "I meant to allow this." + \\ + ); + try testing.expect(!nothing.usable()); + try testing.expect(std.mem.indexOf(u8, nothing.problems[0].message, "allow, ask or deny") != null); + + const both = try parse(arena, + \\[[rule]] + \\allow = ["fs.read"] + \\deny = ["fs.write"] + \\ + ); + try testing.expect(!both.usable()); +} + +test "a file that is not valid toml grants nothing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const result = try parse(arena, "[[rule]\nallow = [\"fs.read\"\n"); + try testing.expect(!result.usable()); + try testing.expect(result.problems.len > 0); +} + +test "an empty file allows nothing and says so" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const result = try parse(arena, "name = \"Nothing\"\n"); + try testing.expect(!result.usable()); + try testing.expect(std.mem.indexOf(u8, result.problems[0].message, "allows nothing") != null); +} + +test "asking a person is a third answer, not a kind of allow" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const result = try parse(arena, + \\[[rule]] + \\ask = ["fs.delete"] + \\paths = ["/repo"] + \\because = "Deleting a file is worth a moment's thought." + \\ + ); + try testing.expect(result.usable()); + try testing.expectEqual(Effect.require_human, result.policy.?.rules[0].effect); +} + +test "the description reads as sentences, and never as capability names" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const result = try parse(arena, local_models_only); + var buffer: [4096]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try describe(result.policy.?, &w); + const text = w.buffered(); + + try testing.expect(std.mem.indexOf(u8, text, "Allowed:") != null); + try testing.expect(std.mem.indexOf(u8, text, "localhost") != null); + try testing.expect(std.mem.indexOf(u8, text, "Anything not named above is refused.") != null); + // The enum names never reach a person. + try testing.expect(std.mem.indexOf(u8, text, "network.connect") == null); + try testing.expect(std.mem.indexOf(u8, text, "model.infer") == null); +} diff --git a/src/ai/provider.zig b/src/ai/provider.zig new file mode 100644 index 0000000..8faaaab --- /dev/null +++ b/src/ai/provider.zig @@ -0,0 +1,333 @@ +//! One shape for every model provider. +//! +//! Providers disagree about almost everything at the wire: what a message +//! looks like, where the system prompt goes, whether a tool call is a block in +//! the content or a field beside it, what the end of a turn is called. A +//! workbench that spoke each of those dialects directly would carry that +//! disagreement into the policy engine, the event log and the block list. +//! +//! So there is one shape here, and each connector translates. The rest of the +//! product only ever sees this one. +//! +//! Two things are deliberately *not* in this file. There is no transport: a +//! request is turned into bytes and a response is read from bytes, and who +//! carries them is somebody else's problem, which is what makes every +//! connector testable without a network. And there is no authority: a call to +//! a provider is an action like any other, so it passes the policy engine +//! under `model.infer` and `network.connect` before it is sent. + +const std = @import("std"); +const hashing = @import("../core/hash.zig"); + +pub const Role = enum { + user, + assistant, + /// An operator instruction placed in the conversation rather than in the + /// system prompt. Only some providers accept one. + system, +}; + +/// A piece of a message. Providers that have no notion of blocks are given the +/// text and told about the rest in whatever way they do support. +pub const Block = union(enum) { + text: []const u8, + /// The model asked to use a tool. + tool_use: ToolUse, + /// The answer to a tool call, going back to the model. + tool_result: ToolResult, + /// Reasoning the model chose to show. Never invented by a connector: when + /// a provider returns no reasoning, there is no block. + thinking: []const u8, + + pub fn textOf(self: Block) []const u8 { + return switch (self) { + .text, .thinking => |t| t, + .tool_use => |t| t.name, + .tool_result => |t| t.content, + }; + } +}; + +pub const ToolUse = struct { + /// The provider's identifier for this call, echoed back with the result. + id: []const u8, + name: []const u8, + /// Arguments as JSON text. Kept as text because a connector must not + /// reinterpret what the model asked for on its way to the policy engine. + argumentsJson: []const u8, +}; + +pub const ToolResult = struct { + toolUseId: []const u8, + content: []const u8, + isError: bool = false, +}; + +pub const Message = struct { + role: Role, + blocks: []const Block, + + /// Every text block joined, for a provider with no block model. + pub fn plainText(self: Message, arena: std.mem.Allocator) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + for (self.blocks) |block| { + switch (block) { + .text => |t| try out.appendSlice(arena, t), + else => {}, + } + } + return out.items; + } +}; + +/// A tool offered to the model. The schema is JSON Schema text, which the +/// workbench already generates from its own types. +pub const Tool = struct { + name: []const u8, + description: []const u8, + schemaJson: []const u8, + /// Ask the provider to guarantee the arguments match the schema, where it + /// can. A provider that cannot leaves this unused rather than pretending. + strict: bool = false, +}; + +/// How hard the model should work. Providers express this differently; a +/// connector maps it or leaves it out. +pub const Effort = enum { low, medium, high, xhigh, max }; + +pub const Request = struct { + model: []const u8, + /// The system prompt. Sent wherever the provider puts it. + system: []const u8 = "", + messages: []const Message, + tools: []const Tool = &.{}, + maxOutputTokens: u32 = 16000, + /// Left unset for models that reject sampling parameters. A connector must + /// omit it rather than substitute a default. + temperature: ?f32 = null, + effort: ?Effort = null, + /// Ask for reasoning to be shown, where the provider supports it. + showThinking: bool = false, + stream: bool = false, +}; + +pub const StopReason = enum { + /// The model finished its turn. + end_turn, + /// The model wants a tool to run. + tool_use, + /// The output limit was reached. The answer is cut off. + max_tokens, + /// The model declined. Not an error: a result with a reason. + refusal, + /// A stop sequence matched. + stop_sequence, + /// The provider said something this build does not know. + unknown, + + /// What happened, for a person reading the block list. + pub fn text(self: StopReason) []const u8 { + return switch (self) { + .end_turn => "the model finished", + .tool_use => "the model asked to use a tool", + .max_tokens => "the answer reached the output limit and is incomplete", + .refusal => "the model declined to answer", + .stop_sequence => "a stop sequence matched", + .unknown => "the provider gave a reason this build does not know", + }; + } +}; + +pub const Usage = struct { + inputTokens: u64 = 0, + outputTokens: u64 = 0, + /// Tokens read from a provider's prompt cache, where it reports them. + cacheReadTokens: u64 = 0, + cacheWriteTokens: u64 = 0, + + pub fn total(self: Usage) u64 { + return self.inputTokens + self.outputTokens; + } +}; + +/// What came back. +pub const Completion = struct { + /// The model that actually answered. Providers may substitute one, and the + /// record has to say which ran rather than which was asked for. + model: []const u8, + blocks: []const Block, + stopReason: StopReason, + usage: Usage = .{}, + /// Set when the model declined and the provider said why. + refusalCategory: ?[]const u8 = null, + /// The provider's own identifier for this response, for support requests. + id: []const u8 = "", + + pub fn text(self: Completion, arena: std.mem.Allocator) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + for (self.blocks) |block| { + switch (block) { + .text => |t| try out.appendSlice(arena, t), + else => {}, + } + } + return out.items; + } + + /// The tool calls the model asked for, in order. + pub fn toolCalls(self: Completion, arena: std.mem.Allocator) ![]const ToolUse { + var out: std.ArrayList(ToolUse) = .empty; + for (self.blocks) |block| { + switch (block) { + .tool_use => |t| try out.append(arena, t), + else => {}, + } + } + return out.items; + } + + /// A hash of the answer, for the provenance record. What the model said is + /// recorded by address, so the log can prove it later without storing it. + pub fn contentHash(self: Completion, arena: std.mem.Allocator) !hashing.Hash { + var parts: std.ArrayList([]const u8) = .empty; + try parts.append(arena, self.model); + for (self.blocks) |block| { + try parts.append(arena, @tagName(block)); + try parts.append(arena, block.textOf()); + } + return hashing.Hash.ofParts(parts.items); + } +}; + +/// A prepared network call: everything a transport needs, and nothing about +/// how to make it. +pub const HttpRequest = struct { + method: []const u8 = "POST", + /// Full address, including the scheme. + url: []const u8, + headers: []const Header, + body: []const u8, + /// The host, split out so the policy engine can decide on it without + /// parsing a URL. + host: []const u8, + + pub const Header = struct { name: []const u8, value: []const u8 }; +}; + +pub const DecodeError = error{ + /// The provider's answer was not the shape this connector expects. + MalformedResponse, + /// The provider reported an error. `providerErrorText` has what it said. + ProviderError, +} || std.mem.Allocator.Error || std.json.ParseError(std.json.Scanner); + +/// The two halves every connector implements. +pub const Wire = struct { + /// A short, stable name: `anthropic`, `openai`, `ollama`. + id: []const u8, + encode: *const fn (arena: std.mem.Allocator, endpoint: Endpoint, request: Request) anyerror!HttpRequest, + decode: *const fn (arena: std.mem.Allocator, body: []const u8) anyerror!Completion, +}; + +/// Where a connector sends, and what it sends to authenticate. +pub const Endpoint = struct { + /// Base address with no trailing slash: `https://api.anthropic.com`. + baseUrl: []const u8, + /// The credential. Never logged, never written to an event. + apiKey: []const u8 = "", + /// Extra headers a deployment needs, such as a cloud resource name. + extraHeaders: []const HttpRequest.Header = &.{}, + + /// The host part of the base address, for the policy engine. + pub fn host(self: Endpoint) []const u8 { + var rest = self.baseUrl; + if (std.mem.indexOf(u8, rest, "://")) |at| rest = rest[at + 3 ..]; + if (std.mem.indexOfScalar(u8, rest, '/')) |at| rest = rest[0..at]; + if (std.mem.indexOfScalar(u8, rest, ':')) |at| rest = rest[0..at]; + return rest; + } + + /// True when the endpoint is on this machine. A local model still passes + /// the policy engine, but a person deciding about it wants to know that + /// nothing leaves the computer. + pub fn isLocal(self: Endpoint) bool { + const h = self.host(); + return std.mem.eql(u8, h, "localhost") or + std.mem.eql(u8, h, "127.0.0.1") or + std.mem.eql(u8, h, "::1") or + std.mem.eql(u8, h, "0.0.0.0"); + } +}; + +/// Write a JSON string, escaped. Connectors build their bodies with this +/// rather than by pasting text into a template, because a model's output is +/// arbitrary and a template is how it becomes an injection. +pub fn writeJsonString(w: *std.Io.Writer, text: []const u8) !void { + try std.json.Stringify.value(text, .{}, w); +} + +const testing = std.testing; + +test "an endpoint knows its host and whether it is local" { + const remote: Endpoint = .{ .baseUrl = "https://api.anthropic.com" }; + try testing.expectEqualStrings("api.anthropic.com", remote.host()); + try testing.expect(!remote.isLocal()); + + const local: Endpoint = .{ .baseUrl = "http://localhost:11434" }; + try testing.expectEqualStrings("localhost", local.host()); + try testing.expect(local.isLocal()); + + const with_path: Endpoint = .{ .baseUrl = "https://router.example.com/v1" }; + try testing.expectEqualStrings("router.example.com", with_path.host()); +} + +test "a completion reports its text, its tool calls and its address" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const completion: Completion = .{ + .model = "claude-opus-5", + .blocks = &.{ + .{ .text = "I will read the file." }, + .{ .tool_use = .{ .id = "toolu_1", .name = "read_file", .argumentsJson = "{\"path\":\"a.txt\"}" } }, + }, + .stopReason = .tool_use, + .usage = .{ .inputTokens = 12, .outputTokens = 7 }, + }; + try testing.expectEqualStrings("I will read the file.", try completion.text(arena)); + + const calls = try completion.toolCalls(arena); + try testing.expectEqual(@as(usize, 1), calls.len); + try testing.expectEqualStrings("read_file", calls[0].name); + try testing.expectEqual(@as(u64, 19), completion.usage.total()); + + // The same answer hashes the same way twice, and a different one does not. + const first = try completion.contentHash(arena); + const again = try completion.contentHash(arena); + try testing.expect(first.eql(again)); +} + +test "every stop reason says what happened in words" { + inline for (comptime std.meta.fields(StopReason)) |field| { + const reason: StopReason = @enumFromInt(field.value); + try testing.expect(reason.text().len > 0); + } + try testing.expect(std.mem.indexOf(u8, StopReason.max_tokens.text(), "incomplete") != null); +} + +test "a message flattens to text for a provider with no blocks" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const message: Message = .{ + .role = .user, + .blocks = &.{ + .{ .text = "first part. " }, + .{ .tool_result = .{ .toolUseId = "t1", .content = "not text" } }, + .{ .text = "second part." }, + }, + }; + try testing.expectEqualStrings("first part. second part.", try message.plainText(arena)); +} diff --git a/src/ai/sandbox.zig b/src/ai/sandbox.zig new file mode 100644 index 0000000..fab4cb6 --- /dev/null +++ b/src/ai/sandbox.zig @@ -0,0 +1,284 @@ +//! Beneath-only file access, enforced by the kernel. +//! +//! A policy that matches paths as text can be defeated by the filesystem. The +//! string `notes/todo.md` is inside the workspace; if `notes` is a symbolic +//! link to `/etc`, the file it opens is not. Normalising the text does not +//! help, because the escape happens during resolution, after the check. +//! +//! So the check is not done in text. The workspace root is held open as a +//! directory, and every file beneath it is opened with `openat2` and +//! `RESOLVE_BENEATH`, which makes the kernel refuse any resolution that leaves +//! that directory: an absolute path, a `..` that climbs past the root, a +//! symbolic link pointing outside, and the magic links under `/proc`. The +//! answer comes from the same component of the system that would perform the +//! escape. +//! +//! `RESOLVE_BENEATH` needs Linux 5.6. Where it is missing, this module reports +//! that it cannot enforce containment rather than falling back to a text +//! comparison that looks like a control and is not one. + +const std = @import("std"); +const builtin = @import("builtin"); +const linux = std.os.linux; + +pub const supported = builtin.os.tag == .linux; + +pub const Error = error{ + /// The path resolved outside the workspace root, or tried to. + EscapesWorkspace, + /// The kernel has no `openat2`. Containment cannot be enforced here, and + /// this is reported rather than approximated. + ContainmentUnavailable, + /// The workspace root could not be opened. + RootUnavailable, + NotFound, + AccessDenied, + IsDirectory, + OpenFailed, +}; + +/// `RESOLVE_BENEATH`: refuse any resolution that leaves the starting directory. +const RESOLVE_BENEATH: u64 = 0x08; +/// `RESOLVE_NO_MAGICLINKS`: refuse `/proc/self/fd/...` style links, which are +/// a documented way out of an otherwise contained resolution. +const RESOLVE_NO_MAGICLINKS: u64 = 0x02; + +/// The kernel's `open_how`, passed to `openat2`. +const OpenHow = extern struct { + flags: u64, + mode: u64, + resolve: u64, +}; + +pub const Mode = enum { + read, + write_truncate, + write_create, + + fn flags(self: Mode) u64 { + const O = linux.O; + var f: O = switch (self) { + .read => .{ .ACCMODE = .RDONLY }, + .write_truncate => .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, + .write_create => .{ .ACCMODE = .WRONLY, .CREAT = true }, + }; + f.CLOEXEC = true; + return @as(u32, @bitCast(f)); + } +}; + +/// A workspace root, held open so that every path is resolved from it. +pub const Root = struct { + fd: linux.fd_t, + path: []const u8, + + /// Open the workspace root. The directory handle is what every later + /// resolution starts from, so a rename of the root after this point cannot + /// redirect an open. + pub fn open(path: []const u8, buffer: []u8) Error!Root { + if (comptime !supported) return error.ContainmentUnavailable; + if (path.len >= buffer.len) return error.RootUnavailable; + @memcpy(buffer[0..path.len], path); + buffer[path.len] = 0; + const path_z: [*:0]const u8 = @ptrCast(buffer.ptr); + + var flags: linux.O = .{ .ACCMODE = .RDONLY, .DIRECTORY = true }; + flags.CLOEXEC = true; + const rc = linux.openat(linux.AT.FDCWD, path_z, flags, 0); + if (linux.errno(rc) != .SUCCESS) return error.RootUnavailable; + return .{ .fd = @intCast(rc), .path = path }; + } + + pub fn close(self: Root) void { + if (comptime !supported) return; + _ = linux.close(self.fd); + } + + /// Open a path beneath the root. The kernel refuses anything that would + /// leave it. + /// + /// `relative` is interpreted from the root. A leading `/` is not a way to + /// reach the filesystem root: `RESOLVE_BENEATH` rejects an absolute path, + /// so the caller's own text is never trusted to be relative. + pub fn openFile(self: Root, relative: []const u8, mode: Mode, buffer: []u8) Error!linux.fd_t { + if (comptime !supported) return error.ContainmentUnavailable; + if (relative.len == 0) return error.NotFound; + if (relative.len >= buffer.len) return error.EscapesWorkspace; + @memcpy(buffer[0..relative.len], relative); + buffer[relative.len] = 0; + const path_z: [*:0]const u8 = @ptrCast(buffer.ptr); + + var how: OpenHow = .{ + .flags = mode.flags(), + .mode = if (mode == .read) 0 else 0o600, + .resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS, + }; + const rc = linux.syscall4( + .openat2, + @as(usize, @bitCast(@as(isize, self.fd))), + @intFromPtr(path_z), + @intFromPtr(&how), + @sizeOf(OpenHow), + ); + return switch (linux.errno(rc)) { + .SUCCESS => @intCast(rc), + // The kernel refused to leave the root. This is the control doing + // its job, and it is reported as such rather than as "not found". + .XDEV, .LOOP => error.EscapesWorkspace, + .NOSYS => error.ContainmentUnavailable, + .NOENT => error.NotFound, + .ACCES, .PERM => error.AccessDenied, + .ISDIR => error.IsDirectory, + else => error.OpenFailed, + }; + } + + /// Read a whole file from beneath the root. + pub fn readFile( + self: Root, + arena: std.mem.Allocator, + relative: []const u8, + limit: usize, + ) (Error || std.mem.Allocator.Error)![]const u8 { + var path_buffer: [4096]u8 = undefined; + const fd = try self.openFile(relative, .read, &path_buffer); + defer _ = linux.close(fd); + + var out: std.ArrayList(u8) = .empty; + var chunk: [64 * 1024]u8 = undefined; + while (out.items.len < limit) { + const rc = linux.read(fd, &chunk, chunk.len); + if (linux.errno(rc) != .SUCCESS) return error.OpenFailed; + if (rc == 0) break; + try out.appendSlice(arena, chunk[0..rc]); + } + return out.items; + } + + /// Write a whole file beneath the root, creating it if needed. + pub fn writeFile(self: Root, relative: []const u8, bytes: []const u8) Error!void { + var path_buffer: [4096]u8 = undefined; + const fd = try self.openFile(relative, .write_truncate, &path_buffer); + defer _ = linux.close(fd); + + var written: usize = 0; + while (written < bytes.len) { + const rc = linux.write(fd, bytes[written..].ptr, bytes.len - written); + switch (linux.errno(rc)) { + .SUCCESS => {}, + .INTR, .AGAIN => continue, + else => return error.OpenFailed, + } + if (rc == 0) return error.OpenFailed; + written += rc; + } + } + + /// True when the path can be opened beneath the root. Used by a check that + /// wants an answer without holding the file open. + pub fn contains(self: Root, relative: []const u8) bool { + var path_buffer: [4096]u8 = undefined; + const fd = self.openFile(relative, .read, &path_buffer) catch return false; + _ = linux.close(fd); + return true; + } +}; + +/// Whether this kernel can enforce containment. Reported by `zag doctor`, so a +/// person can tell whether the control is in force on their machine. +pub fn available() bool { + if (comptime !supported) return false; + var buffer: [8]u8 = undefined; + const root = Root.open(".", &buffer) catch return false; + defer root.close(); + var path_buffer: [8]u8 = undefined; + _ = root.openFile(".", .read, &path_buffer) catch |err| return err != error.ContainmentUnavailable; + return true; +} + +const testing = std.testing; + +test "a path beneath the root opens, and one above it does not" { + if (comptime !supported) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root_path = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + try std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = try std.fmt.allocPrint(arena, "{s}/inside.txt", .{root_path}), + .data = "beneath the root", + }); + + var buffer: [4096]u8 = undefined; + const root = Root.open(root_path, &buffer) catch return error.SkipZigTest; + defer root.close(); + + const inside = root.readFile(arena, "inside.txt", 1 << 20) catch |err| switch (err) { + error.ContainmentUnavailable => return error.SkipZigTest, + else => return err, + }; + try testing.expectEqualStrings("beneath the root", inside); + + // Climbing out with `..`, and naming an absolute path, are both refused by + // the kernel rather than by a string comparison here. + try testing.expectError(error.EscapesWorkspace, root.readFile(arena, "../../../etc/passwd", 1 << 20)); + try testing.expectError(error.EscapesWorkspace, root.readFile(arena, "/etc/passwd", 1 << 20)); + try testing.expect(!root.contains("../..")); + try testing.expect(root.contains("inside.txt")); +} + +test "a symbolic link pointing out of the workspace is refused" { + if (comptime !supported) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root_path = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + + // A link that leaves the workspace. Its text says "escape", which is + // inside; what it resolves to is not. This is the case a path check + // written in text cannot see. + const link_path = try std.fmt.allocPrintSentinel(arena, "{s}/escape", .{root_path}, 0); + const target = "/etc"; + const rc = linux.symlink(target, link_path.ptr); + if (linux.errno(rc) != .SUCCESS) return error.SkipZigTest; + + var buffer: [4096]u8 = undefined; + const root = Root.open(root_path, &buffer) catch return error.SkipZigTest; + defer root.close(); + + const result = root.readFile(arena, "escape/passwd", 1 << 20); + try testing.expectError(error.EscapesWorkspace, result); +} + +test "writing lands beneath the root and nowhere else" { + if (comptime !supported) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root_path = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + + var buffer: [4096]u8 = undefined; + const root = Root.open(root_path, &buffer) catch return error.SkipZigTest; + defer root.close(); + + root.writeFile("written.txt", "by the executor") catch |err| switch (err) { + error.ContainmentUnavailable => return error.SkipZigTest, + else => return err, + }; + const read_back = try root.readFile(arena, "written.txt", 1 << 20); + try testing.expectEqualStrings("by the executor", read_back); + + try testing.expectError(error.EscapesWorkspace, root.writeFile("../outside.txt", "should not land")); +} diff --git a/src/ai/transport.zig b/src/ai/transport.zig new file mode 100644 index 0000000..cf69753 --- /dev/null +++ b/src/ai/transport.zig @@ -0,0 +1,820 @@ +//! The gate every model request passes through. +//! +//! A connector on its own is an ordinary API client, and there are thousands of +//! those. What makes this one worth having is that no request reaches a +//! provider until the policy engine has decided it, and every decision is a +//! record that outlives the session. +//! +//! One call to a model asks for three separate permissions, and they are asked +//! for separately because a person may reasonably answer them differently: +//! +//! * `model.infer` on the connector — may this agent use a model at all, and +//! this one in particular? +//! * `network.connect` on the host — may bytes leave for that address? A +//! workspace can allow `localhost` and refuse everything else, and that one +//! rule is the difference between a local-only setup and an open one. +//! * `credentials.use` on the variable name — may the stored key be spent on +//! this? The value is never asked for and never seen here. +//! +//! The order matters. All three are decided **before** anything is encoded, so +//! a refused request never has a body built for it and a refused credential is +//! never copied into a header. +//! +//! The credential itself is handled once, at the last possible moment: it is +//! passed to the connector's encoder, which writes it into a header, and that +//! header is sent. It is never written to the log, never put in a decision, +//! never in an error, and never in the `Attempt` this returns. What the record +//! holds instead is the variable's name and whether it was set. +//! +//! Sending is behind a `Sender` rather than called directly, for two reasons. +//! The tests run the whole gate against recorded answers with no network at +//! all, which is the only way to test a refusal honestly. And a deployment that +//! must route through its own egress proxy can supply one without touching the +//! decision path. + +const std = @import("std"); +const provider = @import("provider.zig"); +const catalog = @import("catalog.zig"); +const policy_mod = @import("policy.zig"); +const capability_mod = @import("capability.zig"); +const tools = @import("tools.zig"); +const hashing = @import("../core/hash.zig"); + +pub const Connector = catalog.Connector; +pub const Decision = policy_mod.Decision; +pub const Capability = capability_mod.Capability; + +pub const Error = error{ + /// The policy refused one of the three permissions. `Attempt.refusedAt` + /// says which. + NotPermitted, + /// A person has to decide before this can be sent. + NeedsApproval, + /// The connector needs a credential and the variable is empty. + CredentialMissing, + /// The provider answered with a status this build treats as a failure. + ProviderRejected, + /// The request could not be sent at all. + SendFailed, +} || std.mem.Allocator.Error; + +/// What a person is told when a request does not go out. None of these mention +/// a key, a header or a URL with a credential in it. +pub fn refusalText(err: Error) []const u8 { + return switch (err) { + error.NotPermitted => "The policy does not allow this model request.", + error.NeedsApproval => "This model request is waiting for a person to allow it.", + error.CredentialMissing => "That provider needs a credential and none is set.", + error.ProviderRejected => "The provider refused the request.", + error.SendFailed => "The request could not be sent.", + error.OutOfMemory => "There was not enough memory to send this.", + }; +} + +/// A raw answer from a provider, before it is decoded. +pub const Response = struct { + status: u16, + body: []const u8, + + pub fn ok(self: Response) bool { + return self.status >= 200 and self.status < 300; + } +}; + +/// Whatever actually puts the bytes on the wire. +/// +/// It receives a fully-formed request that has already been allowed. A sender +/// has no say in whether the request goes; by the time it is called, that has +/// been decided and recorded. +pub const Sender = struct { + context: *anyopaque, + sendFn: *const fn ( + context: *anyopaque, + arena: std.mem.Allocator, + request: provider.HttpRequest, + ) anyerror!Response, + + pub fn send( + self: Sender, + arena: std.mem.Allocator, + request: provider.HttpRequest, + ) anyerror!Response { + return self.sendFn(self.context, arena, request); + } +}; + +/// Which of the three permissions was the one that stopped a request. +pub const Stage = enum { + infer, + network, + credential, + + pub fn text(self: Stage) []const u8 { + return switch (self) { + .infer => "using a model", + .network => "reaching the provider over the network", + .credential => "using the stored credential", + }; + } +}; + +/// The record of one attempt, whether or not it was sent. +/// +/// This is what a caller writes into the block list and what the log gets. It +/// is complete on a refusal as well as on an answer: a request that was stopped +/// still produced decisions, and those are the interesting ones. +pub const Attempt = struct { + connector: []const u8, + model: []const u8, + host: []const u8, + /// True when the address is on this computer. + local: bool, + /// The decisions made, in the order they were asked for. + decisions: []const Decision, + /// Set when the request never went out. + refusedAt: ?Stage = null, + /// The prompt's hash. The prompt itself is the caller's to store or not; + /// the record says which prompt without holding it. + promptHash: hashing.Hash, + /// Set once an answer arrived. + status: ?u16 = null, + completion: ?provider.Completion = null, + /// Why the request could not be sent, when it could not be. Written in + /// words rather than as an error name, because a person reading this is + /// trying to find out whether the server is running, not to debug a + /// library. + sendProblem: []const u8 = "", + /// What the provider sent back when it refused. Kept so a person can be + /// told what went wrong rather than only that something did. It is the + /// provider's own words, never anything this build put in the request, so + /// showing it cannot leak a credential. + providerError: []const u8 = "", + + pub fn sent(self: Attempt) bool { + return self.status != null; + } + + /// One line for a person reading the session later. + pub fn summary(self: Attempt, arena: std.mem.Allocator) ![]const u8 { + if (self.refusedAt) |stage| { + return std.fmt.allocPrint( + arena, + "Not sent to {s}: the policy refused {s}.", + .{ self.connector, stage.text() }, + ); + } + const completion = self.completion orelse return std.fmt.allocPrint( + arena, + "Sent to {s}, which answered with status {d}.", + .{ self.connector, self.status orelse 0 }, + ); + return std.fmt.allocPrint( + arena, + "{s} answered with {s}, {s}: {s}.", + .{ + self.connector, + if (completion.model.len > 0) completion.model else self.model, + if (self.local) "on this computer" else "over the network", + completion.stopReason.text(), + }, + ); + } +}; + +/// Reads a credential by variable name. +/// +/// A transport is given one of these rather than reading the environment +/// itself, so that the library never touches the process environment and a +/// deployment can hold its keys anywhere: a keychain, an agent, a vault. +pub const Credentials = struct { + context: *anyopaque, + lookupFn: *const fn (context: *anyopaque, variable: []const u8) ?[]const u8, + + pub fn lookup(self: Credentials, variable: []const u8) ?[]const u8 { + return self.lookupFn(self.context, variable); + } + + /// Whether a variable is set, without reading its value. This is what the + /// connector list is allowed to ask. + pub fn isSet(self: Credentials, variable: []const u8) bool { + return self.lookup(variable) != null; + } +}; + +/// Nothing is available. The default, so that a transport built without +/// credentials refuses a hosted provider rather than sending an empty key. +pub const no_credentials: Credentials = .{ + .context = undefined, + .lookupFn = struct { + fn lookup(_: *anyopaque, _: []const u8) ?[]const u8 { + return null; + } + }.lookup, +}; + +pub const Transport = struct { + arena: std.mem.Allocator, + engine: *policy_mod.Engine, + sender: Sender, + credentials: Credentials = no_credentials, + + /// Decide, then send. + /// + /// Every path through this function that reaches the sender has three + /// allowed decisions behind it, and every path that does not returns them + /// anyway through `attempt`. + pub fn send( + self: Transport, + connector: Connector, + request: provider.Request, + context: policy_mod.Context, + attempt: *Attempt, + ) Error!provider.Completion { + var decisions: std.ArrayList(Decision) = .empty; + const key_variable = connector.keyVariable; + + attempt.* = .{ + .connector = connector.id, + .model = request.model, + .host = connector.endpoint("").host(), + .local = connector.locality == .local, + .decisions = &.{}, + .promptHash = hashPrompt(request), + }; + + // 1. May a model be used at all, and this connector in particular? + const infer = try self.decide(&decisions, .{ + .capability = .@"model.infer", + .resource = .{ .service = connector.id }, + }, context); + attempt.decisions = decisions.items; + if (!infer.isAllowed()) { + attempt.refusedAt = .infer; + return stopped(infer); + } + + // 2. May bytes leave for that address? A local model still asks, so + // that the log says a connection was made either way. + const network = try self.decide(&decisions, .{ + .capability = .@"network.connect", + .resource = .{ .host = attempt.host }, + }, context); + attempt.decisions = decisions.items; + if (!network.isAllowed()) { + attempt.refusedAt = .network; + return stopped(network); + } + + // 3. May the stored credential be spent here? Asked before it is read, + // so a refusal never touches the value. + var api_key: []const u8 = ""; + if (connector.needsCredential()) { + const use = try self.decide(&decisions, .{ + .capability = .@"credentials.use", + .resource = .{ .credential = key_variable }, + }, context); + attempt.decisions = decisions.items; + if (!use.isAllowed()) { + attempt.refusedAt = .credential; + return stopped(use); + } + api_key = self.credentials.lookup(key_variable) orelse { + attempt.refusedAt = .credential; + return error.CredentialMissing; + }; + } + + const wire = connector.wire(); + const http = wire.encode(self.arena, connector.endpoint(api_key), request) catch { + return error.SendFailed; + }; + const response = self.sender.send(self.arena, http) catch |err| { + attempt.sendProblem = sendProblemText(err, connector.locality == .local); + return error.SendFailed; + }; + attempt.status = response.status; + + if (!response.ok()) { + attempt.providerError = messageIn(response.body); + return error.ProviderRejected; + } + + const completion = wire.decode(self.arena, response.body) catch { + return error.ProviderRejected; + }; + attempt.completion = completion; + return completion; + } + + /// The typed request this attempt corresponds to, for the tool log. It is + /// built from the same values the decisions were made on, so the record and + /// the decision cannot drift apart. + pub fn toolRequest(attempt: Attempt) tools.ToolRequest { + return .{ .infer = .{ + .provider = attempt.connector, + .model = attempt.model, + .promptHash = attempt.promptHash, + } }; + } + + fn decide( + self: Transport, + into: *std.ArrayList(Decision), + request: capability_mod.Request, + context: policy_mod.Context, + ) Error!Decision { + const decision = self.engine.decide(request, context) catch return error.OutOfMemory; + try into.append(self.arena, decision); + return decision; + } + + fn stopped(decision: Decision) Error { + return switch (decision.effect) { + .require_human => error.NeedsApproval, + else => error.NotPermitted, + }; + } +}; + +/// Say why a request never reached the provider. +/// +/// The distinction that matters to a person is between "nothing is listening +/// there" — usually a local server they have not started — and "the network +/// would not carry it", so those are separated and everything else is left +/// honestly vague rather than guessed at. +pub fn sendProblemText(err: anyerror, local: bool) []const u8 { + return switch (err) { + error.ConnectionRefused => if (local) + "Nothing is listening at that address. The model server may not be running." + else + "The provider refused the connection.", + error.ConnectionTimedOut, error.Timeout => "The provider did not answer in time.", + error.UnknownHostName, error.NameServerFailure, error.TemporaryNameServerFailure => "That address could not be looked up.", + error.TlsInitializationFailed, error.CertificateBundleLoadFailure => "The secure connection could not be set up.", + error.NetworkUnreachable => "There is no route to that address from here.", + else => "", + }; +} + +/// Pull the human-readable part out of an error body. +/// +/// Every one of the five shapes puts its explanation under `message` +/// somewhere, so that one field is looked for rather than five shapes being +/// parsed. When it is not there the body is shown as it came, trimmed, because +/// an unexplained status is worse than an ugly line. +pub fn messageIn(body: []const u8) []const u8 { + const key = "\"message\":"; + var search = body; + while (std.mem.indexOf(u8, search, key)) |at| { + var rest = search[at + key.len ..]; + rest = std.mem.trimStart(u8, rest, " \t\r\n"); + if (rest.len > 0 and rest[0] == '"') { + const text = rest[1..]; + var index: usize = 0; + while (index < text.len) : (index += 1) { + if (text[index] == '\\') { + index += 1; + continue; + } + if (text[index] == '"') return text[0..index]; + } + } + search = rest; + } + const trimmed = std.mem.trim(u8, body, " \t\r\n"); + return if (trimmed.len > 400) trimmed[0..400] else trimmed; +} + +/// Hash the prompt so the record names it without holding it. +/// +/// The system prompt and every message's text go in, in order. Two different +/// conversations cannot collide into one hash, and the same conversation twice +/// gives the same hash, which is what makes a repeated request visible in the +/// log. +pub fn hashPrompt(request: provider.Request) hashing.Hash { + var accumulated = hashing.Hash.of(request.model); + accumulated = hashing.Hash.chain(accumulated, hashing.Hash.of(request.system)); + for (request.messages) |message| { + accumulated = hashing.Hash.chain(accumulated, hashing.Hash.of(@tagName(message.role))); + for (message.blocks) |block| { + const piece = switch (block) { + .text, .thinking => |text| hashing.Hash.of(text), + .tool_use => |call| hashing.Hash.ofParts(&.{ call.name, call.argumentsJson }), + .tool_result => |result| hashing.Hash.ofParts(&.{ result.toolUseId, result.content }), + }; + accumulated = hashing.Hash.chain(accumulated, piece); + } + } + return accumulated; +} + +/// Sends over HTTP, using the standard library's client. +/// +/// This is the only part of the file that touches a socket, and it is reached +/// only after the gate above has allowed the request three times. +pub const Http = struct { + client: *std.http.Client, + /// Answers larger than this are refused rather than held. A provider that + /// returns something enormous is a problem to notice, not to absorb. + limit: usize = 16 << 20, + + pub fn sender(self: *Http) Sender { + return .{ .context = self, .sendFn = sendHttp }; + } + + fn sendHttp( + context: *anyopaque, + arena: std.mem.Allocator, + request: provider.HttpRequest, + ) anyerror!Response { + const self: *Http = @ptrCast(@alignCast(context)); + + var extra: std.ArrayList(std.http.Header) = .empty; + var content_type: []const u8 = "application/json"; + for (request.headers) |header| { + // The client writes this one itself from `headers.content_type`. + if (std.ascii.eqlIgnoreCase(header.name, "content-type")) { + content_type = header.value; + continue; + } + try extra.append(arena, .{ .name = header.name, .value = header.value }); + } + + var body: std.Io.Writer.Allocating = .init(arena); + const result = try self.client.fetch(.{ + .location = .{ .url = request.url }, + .method = .POST, + .payload = request.body, + .headers = .{ .content_type = .{ .override = content_type } }, + .extra_headers = extra.items, + // A credential is in those headers, so a redirect is never + // followed. The policy allowed one host; following a redirect + // would spend the credential on a host nobody decided about. + .redirect_behavior = .unhandled, + .response_writer = &body.writer, + }); + + return .{ .status = @intFromEnum(result.status), .body = body.written() }; + } +}; + +/// A sender that answers from a script instead of a network, for tests and for +/// showing a workspace what a provider would return. +pub const Recorded = struct { + responses: []const Response, + /// Every request that was handed over, in order. A test asserts on this to + /// prove that a refused request never reached here at all. + seen: std.ArrayList(provider.HttpRequest) = .empty, + arena: std.mem.Allocator, + index: usize = 0, + + pub fn sender(self: *Recorded) Sender { + return .{ .context = self, .sendFn = sendRecorded }; + } + + fn sendRecorded( + context: *anyopaque, + _: std.mem.Allocator, + request: provider.HttpRequest, + ) anyerror!Response { + const self: *Recorded = @ptrCast(@alignCast(context)); + try self.seen.append(self.arena, request); + if (self.index >= self.responses.len) return error.NoRecordedResponse; + defer self.index += 1; + return self.responses[self.index]; + } +}; + +const testing = std.testing; +const idmod = @import("../core/id.zig"); +const timeutil = @import("../core/time.zig"); + +const answer = + \\{"id":"msg_1","model":"claude-opus-5","role":"assistant", + \\ "content":[{"type":"text","text":"Four."}], + \\ "stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":2}} +; + +const Fixture = struct { + arena_state: std.heap.ArenaAllocator, + engine: policy_mod.Engine, + recorded: Recorded, + + fn init(rules: []const policy_mod.Rule, responses: []const Response) !*Fixture { + const self = try testing.allocator.create(Fixture); + self.* = .{ + .arena_state = std.heap.ArenaAllocator.init(testing.allocator), + .engine = undefined, + .recorded = undefined, + }; + const scratch = self.arena_state.allocator(); + self.engine = policy_mod.Engine.init(scratch, .{ + .id = "test", + .name = "Test policy", + .description = "Whatever the test needs.", + .rules = rules, + }, 7); + self.recorded = .{ .responses = responses, .arena = scratch }; + return self; + } + + fn deinit(self: *Fixture) void { + self.arena_state.deinit(); + testing.allocator.destroy(self); + } + + fn arena(self: *Fixture) std.mem.Allocator { + return self.arena_state.allocator(); + } + + fn transport(self: *Fixture, credentials: Credentials) Transport { + return .{ + .arena = self.arena(), + .engine = &self.engine, + .sender = self.recorded.sender(), + .credentials = credentials, + }; + } + + fn context(self: *Fixture) policy_mod.Context { + var ids = idmod.Generator.init(11, 0); + _ = self; + return .{ + .actor = ids.next(idmod.ActorId), + .session = ids.next(idmod.SessionId), + .agent = ids.next(idmod.AgentId), + .now = .{ .ns = 1_700_000_000 * timeutil.ns_per_s }, + }; + } +}; + +fn oneKey(_: *anyopaque, variable: []const u8) ?[]const u8 { + if (std.mem.eql(u8, variable, "ANTHROPIC_API_KEY")) return "sk-secret-value"; + return null; +} + +const with_key: Credentials = .{ .context = undefined, .lookupFn = oneKey }; + +fn ask() provider.Request { + return .{ + .model = "claude-opus-5", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Two plus two?" }} }}, + }; +} + +const allow_everything = [_]policy_mod.Rule{.{ + .id = "allow-all", + .capabilities = &.{ .@"model.infer", .@"network.connect", .@"credentials.use" }, + .effect = .allow, + .reason = "This workspace allows model requests.", +}}; + +test "an allowed request is sent, and the answer comes back decoded" { + const fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + var attempt: Attempt = undefined; + const transport = fixture.transport(with_key); + const completion = try transport.send( + catalog.find("anthropic").?, + ask(), + fixture.context(), + &attempt, + ); + + try testing.expectEqualStrings("Four.", try completion.text(fixture.arena())); + try testing.expect(attempt.sent()); + try testing.expectEqual(@as(u16, 200), attempt.status.?); + // Three permissions, three decisions, all recorded. + try testing.expectEqual(@as(usize, 3), attempt.decisions.len); + try testing.expectEqual(Capability.@"model.infer", attempt.decisions[0].request.capability); + try testing.expectEqual(Capability.@"network.connect", attempt.decisions[1].request.capability); + try testing.expectEqual(Capability.@"credentials.use", attempt.decisions[2].request.capability); + try testing.expectEqual(@as(usize, 1), fixture.recorded.seen.items.len); +} + +test "a refused model request never reaches the network" { + const rules = [_]policy_mod.Rule{.{ + .id = "no-models", + .capabilities = &.{.@"model.infer"}, + .effect = .deny, + .reason = "This workspace does not use models.", + }}; + const fixture = try Fixture.init(&rules, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + var attempt: Attempt = undefined; + const transport = fixture.transport(with_key); + try testing.expectError(error.NotPermitted, transport.send( + catalog.find("anthropic").?, + ask(), + fixture.context(), + &attempt, + )); + + // Nothing was encoded and nothing was sent. This is the assertion the + // whole file exists for. + try testing.expectEqual(@as(usize, 0), fixture.recorded.seen.items.len); + try testing.expectEqual(Stage.infer, attempt.refusedAt.?); + try testing.expect(!attempt.sent()); + // The one decision that was reached is still on the record. + try testing.expectEqual(@as(usize, 1), attempt.decisions.len); +} + +test "a workspace can allow models on this computer and refuse the rest" { + const rules = [_]policy_mod.Rule{ + .{ + .id = "models-allowed", + .capabilities = &.{.@"model.infer"}, + .effect = .allow, + .reason = "Models are allowed here.", + }, + .{ + .id = "local-only", + .capabilities = &.{.@"network.connect"}, + .scope = .{ .hosts = &.{"localhost"} }, + .effect = .allow, + .reason = "Only models on this computer may be reached.", + }, + }; + const fixture = try Fixture.init(&rules, &.{.{ + .status = 200, + .body = + \\{"model":"llama3.2","message":{"role":"assistant","content":"Four."}, + \\ "done":true,"done_reason":"stop"} + , + }}); + defer fixture.deinit(); + + const transport = fixture.transport(no_credentials); + + // The hosted one is stopped at the network, after being allowed a model. + var hosted: Attempt = undefined; + try testing.expectError(error.NotPermitted, transport.send( + catalog.find("anthropic").?, + ask(), + fixture.context(), + &hosted, + )); + try testing.expectEqual(Stage.network, hosted.refusedAt.?); + try testing.expectEqual(@as(usize, 0), fixture.recorded.seen.items.len); + + // The local one goes, and needs no credential to do it. + var local: Attempt = undefined; + const completion = try transport.send( + catalog.find("ollama").?, + .{ + .model = "llama3.2", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Two plus two?" }} }}, + }, + fixture.context(), + &local, + ); + try testing.expectEqualStrings("Four.", try completion.text(fixture.arena())); + try testing.expect(local.local); + try testing.expectEqual(@as(usize, 1), fixture.recorded.seen.items.len); + try testing.expectEqualStrings("localhost", fixture.recorded.seen.items[0].host); +} + +test "a request waiting for a person is not sent while it waits" { + const rules = [_]policy_mod.Rule{.{ + .id = "ask-first", + .capabilities = &.{ .@"model.infer", .@"network.connect", .@"credentials.use" }, + .effect = .require_human, + .reason = "A person allows each model request here.", + }}; + const fixture = try Fixture.init(&rules, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + var attempt: Attempt = undefined; + const transport = fixture.transport(with_key); + try testing.expectError(error.NeedsApproval, transport.send( + catalog.find("anthropic").?, + ask(), + fixture.context(), + &attempt, + )); + try testing.expectEqual(@as(usize, 0), fixture.recorded.seen.items.len); +} + +test "the credential is spent on the wire and appears nowhere else" { + const fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + var attempt: Attempt = undefined; + const transport = fixture.transport(with_key); + _ = try transport.send(catalog.find("anthropic").?, ask(), fixture.context(), &attempt); + + const sent = fixture.recorded.seen.items[0]; + var carried = false; + for (sent.headers) |header| { + if (std.mem.eql(u8, header.value, "sk-secret-value")) carried = true; + } + try testing.expect(carried); + + // It is not in the address, not in the body, and not in any decision. + try testing.expect(std.mem.indexOf(u8, sent.url, "sk-secret") == null); + try testing.expect(std.mem.indexOf(u8, sent.body, "sk-secret") == null); + for (attempt.decisions) |decision| { + try testing.expect(std.mem.indexOf(u8, decision.request.resource.text(), "sk-secret") == null); + try testing.expect(std.mem.indexOf(u8, decision.reason, "sk-secret") == null); + } + // The record names the variable, which is the part a person needs. + try testing.expectEqualStrings("ANTHROPIC_API_KEY", attempt.decisions[2].request.resource.text()); + + const line = try attempt.summary(fixture.arena()); + try testing.expect(std.mem.indexOf(u8, line, "sk-secret") == null); +} + +test "a missing credential is reported rather than sent as an empty one" { + const fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + var attempt: Attempt = undefined; + const transport = fixture.transport(no_credentials); + try testing.expectError(error.CredentialMissing, transport.send( + catalog.find("openai").?, + ask(), + fixture.context(), + &attempt, + )); + try testing.expectEqual(@as(usize, 0), fixture.recorded.seen.items.len); + try testing.expectEqual(Stage.credential, attempt.refusedAt.?); +} + +test "a provider's error status is not read as an answer" { + const fixture = try Fixture.init(&allow_everything, &.{.{ + .status = 401, + .body = + \\{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}} + , + }}); + defer fixture.deinit(); + + var attempt: Attempt = undefined; + const transport = fixture.transport(with_key); + try testing.expectError(error.ProviderRejected, transport.send( + catalog.find("anthropic").?, + ask(), + fixture.context(), + &attempt, + )); + try testing.expectEqual(@as(u16, 401), attempt.status.?); + try testing.expect(attempt.completion == null); + // A person is told what the provider said, not only that it said no. + try testing.expectEqualStrings("invalid x-api-key", attempt.providerError); +} + +test "an error message is found in every shape, and an unexplained one is shown as it came" { + try testing.expectEqualStrings("invalid x-api-key", messageIn( + \\{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}} + )); + try testing.expectEqualStrings("Incorrect API key provided", messageIn( + \\{"error":{"message":"Incorrect API key provided","type":"invalid_request_error"}} + )); + try testing.expectEqualStrings("API key not valid", messageIn( + \\{"error":{"code":400,"message":"API key not valid","status":"INVALID_ARGUMENT"}} + )); + // Nothing recognisable: the body itself, rather than silence. + try testing.expectEqualStrings("Service Unavailable", messageIn(" Service Unavailable\n")); +} + +test "the same prompt hashes the same way and a different one does not" { + const first = hashPrompt(ask()); + const again = hashPrompt(ask()); + try testing.expect(first.eql(again)); + + const other = hashPrompt(.{ + .model = "claude-opus-5", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Three plus three?" }} }}, + }); + try testing.expect(!first.eql(other)); + + // The model is part of it: the same question to a different model is a + // different request. + const elsewhere = hashPrompt(.{ + .model = "llama3.2", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Two plus two?" }} }}, + }); + try testing.expect(!first.eql(elsewhere)); +} + +test "an attempt describes itself in words a person can read" { + const fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + var attempt: Attempt = undefined; + const transport = fixture.transport(with_key); + _ = try transport.send(catalog.find("anthropic").?, ask(), fixture.context(), &attempt); + + const line = try attempt.summary(fixture.arena()); + try testing.expect(std.mem.indexOf(u8, line, "anthropic") != null); + try testing.expect(std.mem.indexOf(u8, line, "over the network") != null); + + // The typed request built from the attempt asks for the capability the + // decisions were made under, so the log and the gate cannot disagree. + const request = Transport.toolRequest(attempt); + try testing.expectEqual(Capability.@"model.infer", request.capability()); + try testing.expectEqual(Capability.@"network.connect", request.additionalCapability().?); + try testing.expectEqualStrings("anthropic", request.infer.provider); +} diff --git a/src/ai/wire/anthropic.zig b/src/ai/wire/anthropic.zig new file mode 100644 index 0000000..79458b1 --- /dev/null +++ b/src/ai/wire/anthropic.zig @@ -0,0 +1,391 @@ +//! The Anthropic Messages API. +//! +//! This is the first connector, and the one this workbench was written +//! alongside. The shape it speaks is `POST /v1/messages`: a system prompt beside +//! the messages rather than inside them, content as a list of typed blocks, and +//! tool calls as blocks in the assistant's own content. +//! +//! Two details of the current API are easy to get wrong from memory, so they +//! are written down here rather than assumed: +//! +//! * Sampling parameters are **rejected** on the current models. Sending +//! `temperature` to Claude Opus 5 is a 400, not a hint that is ignored. The +//! neutral request leaves temperature unset by default and this connector +//! only sends it when a caller explicitly set one. +//! * Thinking is configured as `{"type": "adaptive"}`. A fixed token budget +//! is not part of this API any more and is refused. +//! +//! A refusal arrives as a normal answer with `stop_reason` of `refusal`, not as +//! an HTTP error. It is decoded as a completion with that stop reason, because +//! a model declining is a result the record should keep, not a fault. + +const std = @import("std"); +const provider = @import("../provider.zig"); + +pub const Request = provider.Request; +pub const Completion = provider.Completion; +pub const Endpoint = provider.Endpoint; +pub const HttpRequest = provider.HttpRequest; + +/// The version header the API requires on every request. +pub const api_version = "2023-06-01"; + +pub const wire: provider.Wire = .{ .id = "anthropic", .encode = encode, .decode = decode }; + +pub fn encode(arena: std.mem.Allocator, endpoint: Endpoint, request: Request) anyerror!HttpRequest { + var body: std.Io.Writer.Allocating = .init(arena); + const w = &body.writer; + + try w.writeAll("{\"model\":"); + try provider.writeJsonString(w, request.model); + try w.print(",\"max_tokens\":{d}", .{request.maxOutputTokens}); + + if (request.system.len > 0) { + try w.writeAll(",\"system\":"); + try provider.writeJsonString(w, request.system); + } + + // Sampling is sent only when a caller asked for it. The current models + // refuse it outright, so a helpful default here would break every request. + if (request.temperature) |t| try w.print(",\"temperature\":{d}", .{t}); + + if (request.showThinking) { + try w.writeAll(",\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"}"); + } + if (request.effort) |effort| { + try w.print(",\"output_config\":{{\"effort\":\"{s}\"}}", .{@tagName(effort)}); + } + if (request.stream) try w.writeAll(",\"stream\":true"); + + if (request.tools.len > 0) { + try w.writeAll(",\"tools\":["); + for (request.tools, 0..) |tool, i| { + if (i > 0) try w.writeAll(","); + try w.writeAll("{\"name\":"); + try provider.writeJsonString(w, tool.name); + try w.writeAll(",\"description\":"); + try provider.writeJsonString(w, tool.description); + if (tool.strict) try w.writeAll(",\"strict\":true"); + try w.writeAll(",\"input_schema\":"); + // The schema is already JSON, so it is placed as-is rather than + // quoted into a string. + try w.writeAll(tool.schemaJson); + try w.writeAll("}"); + } + try w.writeAll("]"); + } + + try w.writeAll(",\"messages\":["); + for (request.messages, 0..) |message, i| { + if (i > 0) try w.writeAll(","); + try w.writeAll("{\"role\":\""); + try w.writeAll(@tagName(message.role)); + try w.writeAll("\",\"content\":["); + for (message.blocks, 0..) |block, b| { + if (b > 0) try w.writeAll(","); + switch (block) { + .text => |text| { + try w.writeAll("{\"type\":\"text\",\"text\":"); + try provider.writeJsonString(w, text); + try w.writeAll("}"); + }, + .thinking => |text| { + // Reasoning is echoed back unchanged when continuing with + // the same model; changing it invalidates the block. + try w.writeAll("{\"type\":\"thinking\",\"thinking\":"); + try provider.writeJsonString(w, text); + try w.writeAll("}"); + }, + .tool_use => |call| { + try w.writeAll("{\"type\":\"tool_use\",\"id\":"); + try provider.writeJsonString(w, call.id); + try w.writeAll(",\"name\":"); + try provider.writeJsonString(w, call.name); + try w.writeAll(",\"input\":"); + try w.writeAll(if (call.argumentsJson.len > 0) call.argumentsJson else "{}"); + try w.writeAll("}"); + }, + .tool_result => |result| { + try w.writeAll("{\"type\":\"tool_result\",\"tool_use_id\":"); + try provider.writeJsonString(w, result.toolUseId); + try w.writeAll(",\"content\":"); + try provider.writeJsonString(w, result.content); + if (result.isError) try w.writeAll(",\"is_error\":true"); + try w.writeAll("}"); + }, + } + } + try w.writeAll("]}"); + } + try w.writeAll("]}"); + + var headers: std.ArrayList(HttpRequest.Header) = .empty; + try headers.append(arena, .{ .name = "content-type", .value = "application/json" }); + try headers.append(arena, .{ .name = "anthropic-version", .value = api_version }); + if (endpoint.apiKey.len > 0) { + try headers.append(arena, .{ .name = "x-api-key", .value = endpoint.apiKey }); + } + try headers.appendSlice(arena, endpoint.extraHeaders); + + return .{ + .url = try std.fmt.allocPrint(arena, "{s}/v1/messages", .{endpoint.baseUrl}), + .headers = headers.items, + .body = body.written(), + .host = endpoint.host(), + }; +} + +pub fn decode(arena: std.mem.Allocator, body: []const u8) anyerror!Completion { + const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch { + return error.MalformedResponse; + }; + const root = switch (parsed.value) { + .object => |o| o, + else => return error.MalformedResponse, + }; + + // An error arrives as `{"type":"error","error":{"type":...,"message":...}}`. + if (root.get("error")) |err| { + if (err == .object) return error.ProviderError; + } + + var blocks: std.ArrayList(provider.Block) = .empty; + if (root.get("content")) |content| { + if (content != .array) return error.MalformedResponse; + for (content.array.items) |item| { + if (item != .object) continue; + const object = item.object; + const kind = stringField(object, "type") orelse continue; + if (std.mem.eql(u8, kind, "text")) { + try blocks.append(arena, .{ .text = stringField(object, "text") orelse "" }); + } else if (std.mem.eql(u8, kind, "thinking")) { + const text = stringField(object, "thinking") orelse ""; + // The default is to return an empty thinking block. An empty + // one carries nothing, so it is left out rather than kept as a + // block with no content. + if (text.len > 0) try blocks.append(arena, .{ .thinking = text }); + } else if (std.mem.eql(u8, kind, "tool_use")) { + var arguments: std.Io.Writer.Allocating = .init(arena); + if (object.get("input")) |input| { + try std.json.Stringify.value(input, .{}, &arguments.writer); + } + try blocks.append(arena, .{ .tool_use = .{ + .id = stringField(object, "id") orelse "", + .name = stringField(object, "name") orelse "", + .argumentsJson = arguments.written(), + } }); + } + } + } + + var usage: provider.Usage = .{}; + if (root.get("usage")) |u| { + if (u == .object) { + usage.inputTokens = intField(u.object, "input_tokens"); + usage.outputTokens = intField(u.object, "output_tokens"); + usage.cacheReadTokens = intField(u.object, "cache_read_input_tokens"); + usage.cacheWriteTokens = intField(u.object, "cache_creation_input_tokens"); + } + } + + var refusal_category: ?[]const u8 = null; + if (root.get("stop_details")) |details| { + if (details == .object) refusal_category = stringField(details.object, "category"); + } + + return .{ + .model = stringField(root, "model") orelse "", + .blocks = blocks.items, + .stopReason = stopReasonFrom(stringField(root, "stop_reason")), + .usage = usage, + .refusalCategory = refusal_category, + .id = stringField(root, "id") orelse "", + }; +} + +fn stopReasonFrom(name: ?[]const u8) provider.StopReason { + const text = name orelse return .unknown; + if (std.mem.eql(u8, text, "end_turn")) return .end_turn; + if (std.mem.eql(u8, text, "tool_use")) return .tool_use; + if (std.mem.eql(u8, text, "max_tokens")) return .max_tokens; + if (std.mem.eql(u8, text, "refusal")) return .refusal; + if (std.mem.eql(u8, text, "stop_sequence")) return .stop_sequence; + return .unknown; +} + +pub fn stringField(object: std.json.ObjectMap, name: []const u8) ?[]const u8 { + const value = object.get(name) orelse return null; + return switch (value) { + .string => |s| s, + else => null, + }; +} + +pub fn intField(object: std.json.ObjectMap, name: []const u8) u64 { + const value = object.get(name) orelse return 0; + return switch (value) { + .integer => |i| if (i < 0) 0 else @intCast(i), + else => 0, + }; +} + +const testing = std.testing; + +test "a request carries the version header and the key, and no sampling" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ + .baseUrl = "https://api.anthropic.com", + .apiKey = "sk-ant-test", + }, .{ + .model = "claude-opus-5", + .system = "You are careful.", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Hello" }} }}, + }); + + try testing.expectEqualStrings("https://api.anthropic.com/v1/messages", http.url); + try testing.expectEqualStrings("api.anthropic.com", http.host); + + var saw_version = false; + var saw_key = false; + for (http.headers) |header| { + if (std.mem.eql(u8, header.name, "anthropic-version")) { + saw_version = true; + try testing.expectEqualStrings(api_version, header.value); + } + if (std.mem.eql(u8, header.name, "x-api-key")) saw_key = true; + } + try testing.expect(saw_version); + try testing.expect(saw_key); + + // Sampling is absent unless it was asked for. The current models reject it. + try testing.expect(std.mem.indexOf(u8, http.body, "temperature") == null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"model\":\"claude-opus-5\"") != null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"system\":\"You are careful.\"") != null); + + // The body is valid JSON, not a string that happens to look like it. + const parsed = try std.json.parseFromSlice(std.json.Value, arena, http.body, .{}); + try testing.expect(parsed.value == .object); +} + +test "thinking and effort are sent in the shape the API takes now" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ .baseUrl = "https://api.anthropic.com" }, .{ + .model = "claude-opus-5", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Think about this." }} }}, + .showThinking = true, + .effort = .high, + }); + try testing.expect(std.mem.indexOf(u8, http.body, "\"thinking\":{\"type\":\"adaptive\"") != null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"output_config\":{\"effort\":\"high\"}") != null); + // A fixed thinking budget is not part of this API and must never be sent. + try testing.expect(std.mem.indexOf(u8, http.body, "budget_tokens") == null); +} + +test "a tool call round-trips through the wire" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ .baseUrl = "https://api.anthropic.com" }, .{ + .model = "claude-opus-5", + .tools = &.{.{ + .name = "read_file", + .description = "Read a file in the workspace.", + .schemaJson = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}", + }}, + .messages = &.{ + .{ .role = .user, .blocks = &.{.{ .text = "Read a.txt" }} }, + .{ .role = .assistant, .blocks = &.{.{ .tool_use = .{ + .id = "toolu_1", + .name = "read_file", + .argumentsJson = "{\"path\":\"a.txt\"}", + } }} }, + .{ .role = .user, .blocks = &.{.{ .tool_result = .{ + .toolUseId = "toolu_1", + .content = "the file contents", + } }} }, + }, + }); + + // The schema goes in as JSON, not as a quoted string. + try testing.expect(std.mem.indexOf(u8, http.body, "\"input_schema\":{\"type\":\"object\"") != null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"tool_use_id\":\"toolu_1\"") != null); + const parsed = try std.json.parseFromSlice(std.json.Value, arena, http.body, .{}); + try testing.expect(parsed.value == .object); +} + +test "an answer decodes into blocks, a stop reason and usage" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"id":"msg_01","type":"message","role":"assistant","model":"claude-opus-5", + \\ "content":[{"type":"text","text":"I will read it."}, + \\ {"type":"tool_use","id":"toolu_9","name":"read_file","input":{"path":"a.txt"}}], + \\ "stop_reason":"tool_use", + \\ "usage":{"input_tokens":41,"output_tokens":18,"cache_read_input_tokens":7}} + ; + const completion = try decode(arena, body); + try testing.expectEqualStrings("claude-opus-5", completion.model); + try testing.expectEqualStrings("msg_01", completion.id); + try testing.expectEqual(provider.StopReason.tool_use, completion.stopReason); + try testing.expectEqualStrings("I will read it.", try completion.text(arena)); + + const calls = try completion.toolCalls(arena); + try testing.expectEqual(@as(usize, 1), calls.len); + try testing.expectEqualStrings("read_file", calls[0].name); + // The arguments stay as JSON text, for the policy engine to read. + try testing.expect(std.mem.indexOf(u8, calls[0].argumentsJson, "\"path\"") != null); + + try testing.expectEqual(@as(u64, 41), completion.usage.inputTokens); + try testing.expectEqual(@as(u64, 7), completion.usage.cacheReadTokens); +} + +test "a refusal is a result, not a failure" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"id":"msg_02","model":"claude-opus-5","content":[], + \\ "stop_reason":"refusal", + \\ "stop_details":{"type":"refusal","category":"cyber"}, + \\ "usage":{"input_tokens":10,"output_tokens":0}} + ; + const completion = try decode(arena, body); + try testing.expectEqual(provider.StopReason.refusal, completion.stopReason); + try testing.expectEqualStrings("cyber", completion.refusalCategory.?); +} + +test "a provider error is reported as one" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens is required"}} + ; + try testing.expectError(error.ProviderError, decode(arena, body)); + try testing.expectError(error.MalformedResponse, decode(arena, "not json at all")); +} + +test "an empty thinking block is left out rather than kept as nothing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"id":"m","model":"claude-opus-5","stop_reason":"end_turn", + \\ "content":[{"type":"thinking","thinking":""},{"type":"text","text":"Done."}]} + ; + const completion = try decode(arena, body); + try testing.expectEqual(@as(usize, 1), completion.blocks.len); + try testing.expectEqualStrings("Done.", try completion.text(arena)); +} diff --git a/src/ai/wire/gemini.zig b/src/ai/wire/gemini.zig new file mode 100644 index 0000000..8eb2ae1 --- /dev/null +++ b/src/ai/wire/gemini.zig @@ -0,0 +1,283 @@ +//! Google's Gemini `generateContent` shape. +//! +//! It differs from the other two in ways that matter for translation. The +//! assistant's role is called `model`. Content is a list of `parts`, and a tool +//! call is a part with a `functionCall` object rather than a block with a type. +//! The system prompt is `systemInstruction`, a content object of its own. The +//! model name goes in the path rather than the body. +//! +//! Gemini gives a tool call no identifier. The neutral shape needs one to match +//! a result back to its call, so this connector uses the function's name. That +//! is a real limitation of the wire, not a choice: two calls to the same +//! function in one turn cannot be told apart, and the decoder says so rather +//! than inventing an identifier that would silently pair the wrong result with +//! the wrong call. + +const std = @import("std"); +const provider = @import("../provider.zig"); +const shared = @import("anthropic.zig"); + +pub const wire: provider.Wire = .{ .id = "gemini", .encode = encode, .decode = decode }; + +pub fn encode( + arena: std.mem.Allocator, + endpoint: provider.Endpoint, + request: provider.Request, +) anyerror!provider.HttpRequest { + var body: std.Io.Writer.Allocating = .init(arena); + const w = &body.writer; + + try w.writeAll("{"); + if (request.system.len > 0) { + try w.writeAll("\"systemInstruction\":{\"parts\":[{\"text\":"); + try provider.writeJsonString(w, request.system); + try w.writeAll("}]},"); + } + + try w.writeAll("\"contents\":["); + var wrote_any = false; + for (request.messages) |message| { + if (wrote_any) try w.writeAll(","); + try w.writeAll("{\"role\":\""); + try w.writeAll(switch (message.role) { + .user, .system => "user", + .assistant => "model", + }); + try w.writeAll("\",\"parts\":["); + var parts: usize = 0; + for (message.blocks) |block| { + if (parts > 0) try w.writeAll(","); + switch (block) { + .text, .thinking => |text| { + try w.writeAll("{\"text\":"); + try provider.writeJsonString(w, text); + try w.writeAll("}"); + }, + .tool_use => |call| { + try w.writeAll("{\"functionCall\":{\"name\":"); + try provider.writeJsonString(w, call.name); + try w.writeAll(",\"args\":"); + try w.writeAll(if (call.argumentsJson.len > 0) call.argumentsJson else "{}"); + try w.writeAll("}}"); + }, + .tool_result => |result| { + try w.writeAll("{\"functionResponse\":{\"name\":"); + try provider.writeJsonString(w, result.toolUseId); + try w.writeAll(",\"response\":{\"content\":"); + try provider.writeJsonString(w, result.content); + try w.writeAll("}}}"); + }, + } + parts += 1; + } + if (parts == 0) try w.writeAll("{\"text\":\"\"}"); + try w.writeAll("]}"); + wrote_any = true; + } + try w.writeAll("]"); + + if (request.tools.len > 0) { + try w.writeAll(",\"tools\":[{\"functionDeclarations\":["); + for (request.tools, 0..) |tool, i| { + if (i > 0) try w.writeAll(","); + try w.writeAll("{\"name\":"); + try provider.writeJsonString(w, tool.name); + try w.writeAll(",\"description\":"); + try provider.writeJsonString(w, tool.description); + try w.writeAll(",\"parameters\":"); + try w.writeAll(tool.schemaJson); + try w.writeAll("}"); + } + try w.writeAll("]}]"); + } + + try w.print(",\"generationConfig\":{{\"maxOutputTokens\":{d}", .{request.maxOutputTokens}); + if (request.temperature) |t| try w.print(",\"temperature\":{d}", .{t}); + try w.writeAll("}}"); + + var headers: std.ArrayList(provider.HttpRequest.Header) = .empty; + try headers.append(arena, .{ .name = "content-type", .value = "application/json" }); + // The key goes in a header rather than the query string, so it does not + // land in a proxy log or a shell history. + if (endpoint.apiKey.len > 0) { + try headers.append(arena, .{ .name = "x-goog-api-key", .value = endpoint.apiKey }); + } + try headers.appendSlice(arena, endpoint.extraHeaders); + + return .{ + .url = try std.fmt.allocPrint(arena, "{s}/v1beta/models/{s}:generateContent", .{ + endpoint.baseUrl, + request.model, + }), + .headers = headers.items, + .body = body.written(), + .host = endpoint.host(), + }; +} + +pub fn decode(arena: std.mem.Allocator, body: []const u8) anyerror!provider.Completion { + const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch { + return error.MalformedResponse; + }; + const root = switch (parsed.value) { + .object => |o| o, + else => return error.MalformedResponse, + }; + if (root.get("error")) |err| { + if (err == .object) return error.ProviderError; + } + + const candidates = root.get("candidates") orelse return error.MalformedResponse; + if (candidates != .array or candidates.array.items.len == 0) return error.MalformedResponse; + const candidate = switch (candidates.array.items[0]) { + .object => |o| o, + else => return error.MalformedResponse, + }; + + var blocks: std.ArrayList(provider.Block) = .empty; + if (candidate.get("content")) |content| { + if (content == .object) { + if (content.object.get("parts")) |parts| { + if (parts == .array) { + for (parts.array.items) |item| { + if (item != .object) continue; + const part = item.object; + if (shared.stringField(part, "text")) |text| { + if (text.len > 0) try blocks.append(arena, .{ .text = text }); + continue; + } + if (part.get("functionCall")) |call| { + if (call != .object) continue; + const name = shared.stringField(call.object, "name") orelse ""; + var arguments: std.Io.Writer.Allocating = .init(arena); + if (call.object.get("args")) |args| { + try std.json.Stringify.value(args, .{}, &arguments.writer); + } + try blocks.append(arena, .{ + .tool_use = .{ + // This wire carries no call identifier, so the + // function's name stands in for one. + .id = name, + .name = name, + .argumentsJson = arguments.written(), + }, + }); + } + } + } + } + } + } + + var usage: provider.Usage = .{}; + if (root.get("usageMetadata")) |u| { + if (u == .object) { + usage.inputTokens = shared.intField(u.object, "promptTokenCount"); + usage.outputTokens = shared.intField(u.object, "candidatesTokenCount"); + usage.cacheReadTokens = shared.intField(u.object, "cachedContentTokenCount"); + } + } + + var stop = stopReasonFrom(shared.stringField(candidate, "finishReason")); + // A call with no text is a tool turn, whatever the finish reason says. + if (stop == .end_turn) { + for (blocks.items) |block| { + if (block == .tool_use) stop = .tool_use; + } + } + + return .{ + .model = shared.stringField(root, "modelVersion") orelse "", + .blocks = blocks.items, + .stopReason = stop, + .usage = usage, + .id = shared.stringField(root, "responseId") orelse "", + }; +} + +fn stopReasonFrom(name: ?[]const u8) provider.StopReason { + const text = name orelse return .unknown; + if (std.mem.eql(u8, text, "STOP")) return .end_turn; + if (std.mem.eql(u8, text, "MAX_TOKENS")) return .max_tokens; + if (std.mem.eql(u8, text, "SAFETY")) return .refusal; + if (std.mem.eql(u8, text, "PROHIBITED_CONTENT")) return .refusal; + if (std.mem.eql(u8, text, "BLOCKLIST")) return .refusal; + return .unknown; +} + +const testing = std.testing; + +test "the model goes in the path and the assistant is called model" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ + .baseUrl = "https://generativelanguage.googleapis.com", + .apiKey = "key-test", + }, .{ + .model = "gemini-2.5-pro", + .system = "Be exact.", + .messages = &.{ + .{ .role = .user, .blocks = &.{.{ .text = "Hello" }} }, + .{ .role = .assistant, .blocks = &.{.{ .text = "Hi" }} }, + }, + }); + + try testing.expect(std.mem.endsWith(u8, http.url, "/v1beta/models/gemini-2.5-pro:generateContent")); + try testing.expect(std.mem.indexOf(u8, http.body, "\"systemInstruction\"") != null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"role\":\"model\"") != null); + + // The key is a header, so it stays out of the address. + try testing.expect(std.mem.indexOf(u8, http.url, "key-test") == null); + var saw_key = false; + for (http.headers) |header| { + if (std.mem.eql(u8, header.name, "x-goog-api-key")) saw_key = true; + } + try testing.expect(saw_key); + + const parsed = try std.json.parseFromSlice(std.json.Value, arena, http.body, .{}); + try testing.expect(parsed.value == .object); +} + +test "a function call decodes, and stands in its own name for an identifier" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"modelVersion":"gemini-2.5-pro","responseId":"r1","candidates":[ + \\ {"finishReason":"STOP","content":{"role":"model","parts":[ + \\ {"functionCall":{"name":"read_file","args":{"path":"a.txt"}}}]}}], + \\ "usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":5}} + ; + const completion = try decode(arena, body); + const calls = try completion.toolCalls(arena); + try testing.expectEqual(@as(usize, 1), calls.len); + try testing.expectEqualStrings("read_file", calls[0].name); + try testing.expectEqualStrings("read_file", calls[0].id); + // A tool turn is reported as one even though the finish reason said stop. + try testing.expectEqual(provider.StopReason.tool_use, completion.stopReason); + try testing.expectEqual(@as(u64, 20), completion.usage.inputTokens); +} + +test "a blocked answer is a refusal" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"modelVersion":"gemini-2.5-pro","candidates":[{"finishReason":"SAFETY","content":{"parts":[]}}]} + ; + const completion = try decode(arena, body); + try testing.expectEqual(provider.StopReason.refusal, completion.stopReason); +} + +test "an error body is reported" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + try testing.expectError(error.ProviderError, decode(arena, + \\{"error":{"code":400,"message":"API key not valid","status":"INVALID_ARGUMENT"}} + )); +} diff --git a/src/ai/wire/huggingface.zig b/src/ai/wire/huggingface.zig new file mode 100644 index 0000000..bf27ac6 --- /dev/null +++ b/src/ai/wire/huggingface.zig @@ -0,0 +1,220 @@ +//! Hugging Face text generation, in the shape a served model speaks. +//! +//! Hugging Face reaches models two ways, and the difference matters when +//! choosing a connector. Its router at `router.huggingface.co/v1` speaks the +//! OpenAI shape, so the `openai` connector serves it and every inference +//! provider behind it. This connector speaks the *other* one: the plain +//! text-generation endpoint that Text Generation Inference exposes, which is +//! what a self-hosted model, an inference endpoint, or a container on a +//! workstation actually serves. +//! +//! That shape has no notion of a conversation. There is one string in and one +//! string out. So the messages are rendered into a prompt here, and the +//! rendering is written out plainly rather than hidden, because a template is +//! the part that decides how well a model behaves and a person tuning one +//! needs to see it. There are no tools: a model behind this endpoint cannot be +//! given any, and this connector says so rather than dropping them quietly. + +const std = @import("std"); +const provider = @import("../provider.zig"); +const shared = @import("anthropic.zig"); + +pub const wire: provider.Wire = .{ .id = "huggingface", .encode = encode, .decode = decode }; + +/// Render a conversation as one prompt. +/// +/// The form is the plain chat transcript that instruction-tuned models are used +/// to. A served model with its own chat template will apply that instead; this +/// is what is sent when there is none. +pub fn renderPrompt(arena: std.mem.Allocator, request: provider.Request) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + if (request.system.len > 0) { + try out.appendSlice(arena, "System: "); + try out.appendSlice(arena, request.system); + try out.appendSlice(arena, "\n\n"); + } + for (request.messages) |message| { + const label = switch (message.role) { + .user => "User: ", + .assistant => "Assistant: ", + .system => "System: ", + }; + try out.appendSlice(arena, label); + try out.appendSlice(arena, try message.plainText(arena)); + try out.appendSlice(arena, "\n\n"); + } + try out.appendSlice(arena, "Assistant:"); + return out.items; +} + +pub fn encode( + arena: std.mem.Allocator, + endpoint: provider.Endpoint, + request: provider.Request, +) anyerror!provider.HttpRequest { + var body: std.Io.Writer.Allocating = .init(arena); + const w = &body.writer; + + try w.writeAll("{\"inputs\":"); + try provider.writeJsonString(w, try renderPrompt(arena, request)); + try w.print(",\"parameters\":{{\"max_new_tokens\":{d},\"return_full_text\":false", .{ + request.maxOutputTokens, + }); + if (request.temperature) |t| { + // This endpoint rejects a temperature of exactly zero; the nearest + // meaning is sampling turned off. + if (t <= 0) { + try w.writeAll(",\"do_sample\":false"); + } else { + try w.print(",\"temperature\":{d},\"do_sample\":true", .{t}); + } + } + try w.writeAll("}"); + try w.print(",\"stream\":{s}}}", .{if (request.stream) "true" else "false"}); + + var headers: std.ArrayList(provider.HttpRequest.Header) = .empty; + try headers.append(arena, .{ .name = "content-type", .value = "application/json" }); + if (endpoint.apiKey.len > 0) { + try headers.append(arena, .{ + .name = "authorization", + .value = try std.fmt.allocPrint(arena, "Bearer {s}", .{endpoint.apiKey}), + }); + } + try headers.appendSlice(arena, endpoint.extraHeaders); + + return .{ + .url = try std.fmt.allocPrint(arena, "{s}/generate", .{endpoint.baseUrl}), + .headers = headers.items, + .body = body.written(), + .host = endpoint.host(), + }; +} + +pub fn decode(arena: std.mem.Allocator, body: []const u8) anyerror!provider.Completion { + const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch { + return error.MalformedResponse; + }; + + // Two shapes are served in the wild: a bare object, and a list of them. + const object = switch (parsed.value) { + .object => |o| o, + .array => |items| blk: { + if (items.items.len == 0) return error.MalformedResponse; + break :blk switch (items.items[0]) { + .object => |o| o, + else => return error.MalformedResponse, + }; + }, + else => return error.MalformedResponse, + }; + + if (object.get("error")) |_| return error.ProviderError; + + const text = shared.stringField(object, "generated_text") orelse return error.MalformedResponse; + var blocks: std.ArrayList(provider.Block) = .empty; + if (text.len > 0) try blocks.append(arena, .{ .text = text }); + + var usage: provider.Usage = .{}; + var stop: provider.StopReason = .end_turn; + if (object.get("details")) |details| { + if (details == .object) { + usage.outputTokens = shared.intField(details.object, "generated_tokens"); + if (shared.stringField(details.object, "finish_reason")) |reason| { + stop = if (std.mem.eql(u8, reason, "length")) .max_tokens else .end_turn; + } + } + } + + return .{ + .model = "", + .blocks = blocks.items, + .stopReason = stop, + .usage = usage, + }; +} + +const testing = std.testing; + +test "a conversation renders into one prompt" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const prompt = try renderPrompt(arena, .{ + .model = "meta-llama/Llama-3.1-8B-Instruct", + .system = "Answer in one word.", + .messages = &.{ + .{ .role = .user, .blocks = &.{.{ .text = "Two plus two?" }} }, + .{ .role = .assistant, .blocks = &.{.{ .text = "Four" }} }, + .{ .role = .user, .blocks = &.{.{ .text = "And three more?" }} }, + }, + }); + try testing.expect(std.mem.startsWith(u8, prompt, "System: Answer in one word.")); + try testing.expect(std.mem.indexOf(u8, prompt, "User: Two plus two?") != null); + try testing.expect(std.mem.indexOf(u8, prompt, "Assistant: Four") != null); + // It ends ready for the model to continue. + try testing.expect(std.mem.endsWith(u8, prompt, "Assistant:")); +} + +test "the request asks only for the new text" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ + .baseUrl = "https://my-endpoint.endpoints.huggingface.cloud", + .apiKey = "hf_test", + }, .{ + .model = "tgi", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Hello" }} }}, + .maxOutputTokens = 256, + }); + try testing.expect(std.mem.endsWith(u8, http.url, "/generate")); + try testing.expect(std.mem.indexOf(u8, http.body, "\"return_full_text\":false") != null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"max_new_tokens\":256") != null); + + const parsed = try std.json.parseFromSlice(std.json.Value, arena, http.body, .{}); + try testing.expect(parsed.value == .object); +} + +test "a temperature of zero turns sampling off rather than being rejected" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ .baseUrl = "http://localhost:8080" }, .{ + .model = "tgi", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Hi" }} }}, + .temperature = 0, + }); + try testing.expect(std.mem.indexOf(u8, http.body, "\"do_sample\":false") != null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"temperature\":0") == null); +} + +test "both served shapes decode" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const as_list = + \\[{"generated_text":"Seven.","details":{"finish_reason":"eos_token","generated_tokens":2}}] + ; + const from_list = try decode(arena, as_list); + try testing.expectEqualStrings("Seven.", try from_list.text(arena)); + try testing.expectEqual(@as(u64, 2), from_list.usage.outputTokens); + + const as_object = + \\{"generated_text":"Seven.","details":{"finish_reason":"length","generated_tokens":256}} + ; + const from_object = try decode(arena, as_object); + try testing.expectEqual(provider.StopReason.max_tokens, from_object.stopReason); +} + +test "an error is reported" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + try testing.expectError(error.ProviderError, decode(arena, + \\{"error":"Model is currently loading","estimated_time":20.0} + )); +} diff --git a/src/ai/wire/ollama.zig b/src/ai/wire/ollama.zig new file mode 100644 index 0000000..c53ba68 --- /dev/null +++ b/src/ai/wire/ollama.zig @@ -0,0 +1,293 @@ +//! Ollama's own `/api/chat` shape, for models running on this machine. +//! +//! Ollama also serves an OpenAI-compatible endpoint, and the `openai` connector +//! reaches it. This one speaks the native shape instead, because it reports +//! things the compatible endpoint drops: which model actually answered after a +//! tag was resolved, why generation stopped, and the token counts under +//! `prompt_eval_count` and `eval_count`. +//! +//! Nothing here leaves the machine. That is a property worth keeping visible +//! rather than assuming: the policy engine still decides `network.connect` to +//! `localhost`, so a workspace can be configured to allow local models and +//! refuse every hosted one, and the decision is in the log either way. + +const std = @import("std"); +const provider = @import("../provider.zig"); +const shared = @import("anthropic.zig"); + +pub const wire: provider.Wire = .{ .id = "ollama", .encode = encode, .decode = decode }; + +pub fn encode( + arena: std.mem.Allocator, + endpoint: provider.Endpoint, + request: provider.Request, +) anyerror!provider.HttpRequest { + var body: std.Io.Writer.Allocating = .init(arena); + const w = &body.writer; + + try w.writeAll("{\"model\":"); + try provider.writeJsonString(w, request.model); + // Streaming is off unless asked for. Ollama streams by default, and a + // caller that did not ask for a stream would get a body it cannot parse. + try w.print(",\"stream\":{s}", .{if (request.stream) "true" else "false"}); + + try w.writeAll(",\"messages\":["); + var wrote_any = false; + if (request.system.len > 0) { + try w.writeAll("{\"role\":\"system\",\"content\":"); + try provider.writeJsonString(w, request.system); + try w.writeAll("}"); + wrote_any = true; + } + for (request.messages) |message| { + var tool_results: usize = 0; + for (message.blocks) |block| { + if (block == .tool_result) tool_results += 1; + } + if (tool_results > 0) { + for (message.blocks) |block| { + switch (block) { + .tool_result => |result| { + if (wrote_any) try w.writeAll(","); + try w.writeAll("{\"role\":\"tool\",\"content\":"); + try provider.writeJsonString(w, result.content); + try w.writeAll("}"); + wrote_any = true; + }, + else => {}, + } + } + continue; + } + + if (wrote_any) try w.writeAll(","); + try w.writeAll("{\"role\":\""); + try w.writeAll(@tagName(message.role)); + try w.writeAll("\",\"content\":"); + try provider.writeJsonString(w, try message.plainText(arena)); + + var calls: usize = 0; + for (message.blocks) |block| { + if (block == .tool_use) calls += 1; + } + if (calls > 0) { + try w.writeAll(",\"tool_calls\":["); + var written: usize = 0; + for (message.blocks) |block| { + switch (block) { + .tool_use => |call| { + if (written > 0) try w.writeAll(","); + try w.writeAll("{\"function\":{\"name\":"); + try provider.writeJsonString(w, call.name); + // Arguments are an object here, unlike the OpenAI shape + // where the same field is a string. + try w.writeAll(",\"arguments\":"); + try w.writeAll(if (call.argumentsJson.len > 0) call.argumentsJson else "{}"); + try w.writeAll("}}"); + written += 1; + }, + else => {}, + } + } + try w.writeAll("]"); + } + try w.writeAll("}"); + wrote_any = true; + } + try w.writeAll("]"); + + if (request.tools.len > 0) { + try w.writeAll(",\"tools\":["); + for (request.tools, 0..) |tool, i| { + if (i > 0) try w.writeAll(","); + try w.writeAll("{\"type\":\"function\",\"function\":{\"name\":"); + try provider.writeJsonString(w, tool.name); + try w.writeAll(",\"description\":"); + try provider.writeJsonString(w, tool.description); + try w.writeAll(",\"parameters\":"); + try w.writeAll(tool.schemaJson); + try w.writeAll("}}"); + } + try w.writeAll("]"); + } + + try w.print(",\"options\":{{\"num_predict\":{d}", .{request.maxOutputTokens}); + if (request.temperature) |t| try w.print(",\"temperature\":{d}", .{t}); + try w.writeAll("}}"); + + var headers: std.ArrayList(provider.HttpRequest.Header) = .empty; + try headers.append(arena, .{ .name = "content-type", .value = "application/json" }); + if (endpoint.apiKey.len > 0) { + try headers.append(arena, .{ + .name = "authorization", + .value = try std.fmt.allocPrint(arena, "Bearer {s}", .{endpoint.apiKey}), + }); + } + try headers.appendSlice(arena, endpoint.extraHeaders); + + return .{ + .url = try std.fmt.allocPrint(arena, "{s}/api/chat", .{endpoint.baseUrl}), + .headers = headers.items, + .body = body.written(), + .host = endpoint.host(), + }; +} + +pub fn decode(arena: std.mem.Allocator, body: []const u8) anyerror!provider.Completion { + const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch { + return error.MalformedResponse; + }; + const root = switch (parsed.value) { + .object => |o| o, + else => return error.MalformedResponse, + }; + if (root.get("error")) |_| return error.ProviderError; + + const message = switch (root.get("message") orelse return error.MalformedResponse) { + .object => |o| o, + else => return error.MalformedResponse, + }; + + var blocks: std.ArrayList(provider.Block) = .empty; + if (shared.stringField(message, "thinking")) |text| { + if (text.len > 0) try blocks.append(arena, .{ .thinking = text }); + } + if (shared.stringField(message, "content")) |text| { + if (text.len > 0) try blocks.append(arena, .{ .text = text }); + } + if (message.get("tool_calls")) |calls| { + if (calls == .array) { + for (calls.array.items, 0..) |item, index| { + if (item != .object) continue; + const function = switch (item.object.get("function") orelse continue) { + .object => |o| o, + else => continue, + }; + var arguments: std.Io.Writer.Allocating = .init(arena); + if (function.get("arguments")) |args| { + try std.json.Stringify.value(args, .{}, &arguments.writer); + } + const name = shared.stringField(function, "name") orelse ""; + try blocks.append(arena, .{ + .tool_use = .{ + // This wire carries no identifier either, so calls are + // numbered in the order they arrived. Two calls to one + // function stay distinct. + .id = try std.fmt.allocPrint(arena, "{s}-{d}", .{ name, index }), + .name = name, + .argumentsJson = arguments.written(), + }, + }); + } + } + } + + var stop = stopReasonFrom(shared.stringField(root, "done_reason")); + if (stop == .end_turn) { + for (blocks.items) |block| { + if (block == .tool_use) stop = .tool_use; + } + } + + return .{ + .model = shared.stringField(root, "model") orelse "", + .blocks = blocks.items, + .stopReason = stop, + .usage = .{ + .inputTokens = shared.intField(root, "prompt_eval_count"), + .outputTokens = shared.intField(root, "eval_count"), + }, + }; +} + +fn stopReasonFrom(name: ?[]const u8) provider.StopReason { + const text = name orelse return .end_turn; + if (std.mem.eql(u8, text, "stop")) return .end_turn; + if (std.mem.eql(u8, text, "length")) return .max_tokens; + return .unknown; +} + +const testing = std.testing; + +test "a local request never asks for a stream it cannot read" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const endpoint: provider.Endpoint = .{ .baseUrl = "http://localhost:11434" }; + try testing.expect(endpoint.isLocal()); + + const http = try encode(arena, endpoint, .{ + .model = "llama3.2", + .system = "Answer briefly.", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Hello" }} }}, + }); + try testing.expectEqualStrings("http://localhost:11434/api/chat", http.url); + try testing.expectEqualStrings("localhost", http.host); + // Ollama streams unless told otherwise, so this is always explicit. + try testing.expect(std.mem.indexOf(u8, http.body, "\"stream\":false") != null); + + const parsed = try std.json.parseFromSlice(std.json.Value, arena, http.body, .{}); + try testing.expect(parsed.value == .object); +} + +test "arguments are an object here, not a string" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ .baseUrl = "http://localhost:11434" }, .{ + .model = "llama3.2", + .messages = &.{.{ .role = .assistant, .blocks = &.{.{ .tool_use = .{ + .id = "c1", + .name = "read_file", + .argumentsJson = "{\"path\":\"a.txt\"}", + } }} }}, + }); + // Unquoted: an object, unlike the OpenAI shape. + try testing.expect(std.mem.indexOf(u8, http.body, "\"arguments\":{\"path\":\"a.txt\"}") != null); +} + +test "an answer decodes with its counts and its own model name" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"model":"llama3.2:3b","created_at":"2026-09-06T00:00:00Z", + \\ "message":{"role":"assistant","content":"Four."}, + \\ "done":true,"done_reason":"stop", + \\ "prompt_eval_count":18,"eval_count":3} + ; + const completion = try decode(arena, body); + try testing.expectEqualStrings("llama3.2:3b", completion.model); + try testing.expectEqualStrings("Four.", try completion.text(arena)); + try testing.expectEqual(provider.StopReason.end_turn, completion.stopReason); + try testing.expectEqual(@as(u64, 18), completion.usage.inputTokens); + try testing.expectEqual(@as(u64, 3), completion.usage.outputTokens); +} + +test "two calls to one function stay apart" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"model":"llama3.2","message":{"role":"assistant","content":"", + \\ "tool_calls":[{"function":{"name":"read_file","arguments":{"path":"a.txt"}}}, + \\ {"function":{"name":"read_file","arguments":{"path":"b.txt"}}}]}, + \\ "done":true,"done_reason":"stop"} + ; + const completion = try decode(arena, body); + const calls = try completion.toolCalls(arena); + try testing.expectEqual(@as(usize, 2), calls.len); + try testing.expect(!std.mem.eql(u8, calls[0].id, calls[1].id)); + try testing.expectEqual(provider.StopReason.tool_use, completion.stopReason); +} + +test "an error is reported rather than read as an empty answer" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + try testing.expectError(error.ProviderError, decode(arena, "{\"error\":\"model not found\"}")); +} diff --git a/src/ai/wire/openai.zig b/src/ai/wire/openai.zig new file mode 100644 index 0000000..8294c99 --- /dev/null +++ b/src/ai/wire/openai.zig @@ -0,0 +1,353 @@ +//! The OpenAI chat-completions shape. +//! +//! This one connector reaches more providers than all the others together. +//! Groq, Together, Fireworks, OpenRouter, DeepSeek, Mistral, xAI, Cerebras, +//! Nebius, DeepInfra, vLLM, LM Studio, LocalAI, llama.cpp's server and Hugging +//! Face's router all accept this request shape at a different address. That is +//! why it is worth writing carefully once. +//! +//! Where it differs from the Anthropic shape: +//! +//! * The system prompt is the first message, not a field beside them. +//! * A tool call is a field on the assistant message, not a content block, +//! and its arguments are a JSON *string* rather than an object. +//! * A tool result is a message with the role `tool`. +//! +//! `max_tokens` is deprecated in favour of `max_completion_tokens` on newer +//! OpenAI models, but most compatible servers still only understand the older +//! name. Both are sent: servers ignore what they do not know, and the two never +//! disagree because they are written from one value. + +const std = @import("std"); +const provider = @import("../provider.zig"); +const shared = @import("anthropic.zig"); + +pub const wire: provider.Wire = .{ .id = "openai", .encode = encode, .decode = decode }; + +pub fn encode( + arena: std.mem.Allocator, + endpoint: provider.Endpoint, + request: provider.Request, +) anyerror!provider.HttpRequest { + var body: std.Io.Writer.Allocating = .init(arena); + const w = &body.writer; + + try w.writeAll("{\"model\":"); + try provider.writeJsonString(w, request.model); + try w.print(",\"max_tokens\":{d},\"max_completion_tokens\":{d}", .{ + request.maxOutputTokens, + request.maxOutputTokens, + }); + if (request.temperature) |t| try w.print(",\"temperature\":{d}", .{t}); + if (request.stream) try w.writeAll(",\"stream\":true"); + + if (request.tools.len > 0) { + try w.writeAll(",\"tools\":["); + for (request.tools, 0..) |tool, i| { + if (i > 0) try w.writeAll(","); + try w.writeAll("{\"type\":\"function\",\"function\":{\"name\":"); + try provider.writeJsonString(w, tool.name); + try w.writeAll(",\"description\":"); + try provider.writeJsonString(w, tool.description); + if (tool.strict) try w.writeAll(",\"strict\":true"); + try w.writeAll(",\"parameters\":"); + try w.writeAll(tool.schemaJson); + try w.writeAll("}}"); + } + try w.writeAll("]"); + } + + try w.writeAll(",\"messages\":["); + var wrote_any = false; + if (request.system.len > 0) { + try w.writeAll("{\"role\":\"system\",\"content\":"); + try provider.writeJsonString(w, request.system); + try w.writeAll("}"); + wrote_any = true; + } + + for (request.messages) |message| { + // A tool result is its own message here, so one neutral message can + // become several. + var tool_results: usize = 0; + for (message.blocks) |block| { + if (block == .tool_result) tool_results += 1; + } + if (tool_results > 0) { + for (message.blocks) |block| { + switch (block) { + .tool_result => |result| { + if (wrote_any) try w.writeAll(","); + try w.writeAll("{\"role\":\"tool\",\"tool_call_id\":"); + try provider.writeJsonString(w, result.toolUseId); + try w.writeAll(",\"content\":"); + try provider.writeJsonString(w, result.content); + try w.writeAll("}"); + wrote_any = true; + }, + else => {}, + } + } + continue; + } + + if (wrote_any) try w.writeAll(","); + try w.writeAll("{\"role\":\""); + try w.writeAll(@tagName(message.role)); + try w.writeAll("\",\"content\":"); + try provider.writeJsonString(w, try message.plainText(arena)); + + var calls: usize = 0; + for (message.blocks) |block| { + if (block == .tool_use) calls += 1; + } + if (calls > 0) { + try w.writeAll(",\"tool_calls\":["); + var written: usize = 0; + for (message.blocks) |block| { + switch (block) { + .tool_use => |call| { + if (written > 0) try w.writeAll(","); + try w.writeAll("{\"id\":"); + try provider.writeJsonString(w, call.id); + try w.writeAll(",\"type\":\"function\",\"function\":{\"name\":"); + try provider.writeJsonString(w, call.name); + // Arguments are a JSON string here, not an object, so + // the JSON text is quoted into one. + try w.writeAll(",\"arguments\":"); + try provider.writeJsonString(w, call.argumentsJson); + try w.writeAll("}}"); + written += 1; + }, + else => {}, + } + } + try w.writeAll("]"); + } + try w.writeAll("}"); + wrote_any = true; + } + try w.writeAll("]}"); + + var headers: std.ArrayList(provider.HttpRequest.Header) = .empty; + try headers.append(arena, .{ .name = "content-type", .value = "application/json" }); + if (endpoint.apiKey.len > 0) { + try headers.append(arena, .{ + .name = "authorization", + .value = try std.fmt.allocPrint(arena, "Bearer {s}", .{endpoint.apiKey}), + }); + } + try headers.appendSlice(arena, endpoint.extraHeaders); + + return .{ + .url = try std.fmt.allocPrint(arena, "{s}/chat/completions", .{endpoint.baseUrl}), + .headers = headers.items, + .body = body.written(), + .host = endpoint.host(), + }; +} + +pub fn decode(arena: std.mem.Allocator, body: []const u8) anyerror!provider.Completion { + const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch { + return error.MalformedResponse; + }; + const root = switch (parsed.value) { + .object => |o| o, + else => return error.MalformedResponse, + }; + if (root.get("error")) |err| { + if (err == .object or err == .string) return error.ProviderError; + } + + const choices = root.get("choices") orelse return error.MalformedResponse; + if (choices != .array or choices.array.items.len == 0) return error.MalformedResponse; + const choice = switch (choices.array.items[0]) { + .object => |o| o, + else => return error.MalformedResponse, + }; + const message = switch (choice.get("message") orelse return error.MalformedResponse) { + .object => |o| o, + else => return error.MalformedResponse, + }; + + var blocks: std.ArrayList(provider.Block) = .empty; + if (shared.stringField(message, "content")) |text| { + if (text.len > 0) try blocks.append(arena, .{ .text = text }); + } + // Some servers put the model's reasoning in a field of its own. + if (shared.stringField(message, "reasoning_content")) |text| { + if (text.len > 0) try blocks.append(arena, .{ .thinking = text }); + } + if (message.get("tool_calls")) |calls| { + if (calls == .array) { + for (calls.array.items) |item| { + if (item != .object) continue; + const call = item.object; + const function = switch (call.get("function") orelse continue) { + .object => |o| o, + else => continue, + }; + try blocks.append(arena, .{ .tool_use = .{ + .id = shared.stringField(call, "id") orelse "", + .name = shared.stringField(function, "name") orelse "", + .argumentsJson = shared.stringField(function, "arguments") orelse "{}", + } }); + } + } + } + + var usage: provider.Usage = .{}; + if (root.get("usage")) |u| { + if (u == .object) { + usage.inputTokens = shared.intField(u.object, "prompt_tokens"); + usage.outputTokens = shared.intField(u.object, "completion_tokens"); + } + } + + return .{ + .model = shared.stringField(root, "model") orelse "", + .blocks = blocks.items, + .stopReason = stopReasonFrom(shared.stringField(choice, "finish_reason")), + .usage = usage, + .id = shared.stringField(root, "id") orelse "", + }; +} + +fn stopReasonFrom(name: ?[]const u8) provider.StopReason { + const text = name orelse return .unknown; + if (std.mem.eql(u8, text, "stop")) return .end_turn; + if (std.mem.eql(u8, text, "tool_calls")) return .tool_use; + if (std.mem.eql(u8, text, "function_call")) return .tool_use; + if (std.mem.eql(u8, text, "length")) return .max_tokens; + if (std.mem.eql(u8, text, "content_filter")) return .refusal; + return .unknown; +} + +const testing = std.testing; + +test "the system prompt becomes the first message" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ .baseUrl = "https://api.openai.com/v1", .apiKey = "sk-test" }, .{ + .model = "gpt-4.1", + .system = "Be brief.", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "Hello" }} }}, + }); + try testing.expectEqualStrings("https://api.openai.com/v1/chat/completions", http.url); + try testing.expect(std.mem.indexOf(u8, http.body, "{\"role\":\"system\",\"content\":\"Be brief.\"}") != null); + + var saw_bearer = false; + for (http.headers) |header| { + if (std.mem.eql(u8, header.name, "authorization")) { + saw_bearer = true; + try testing.expectEqualStrings("Bearer sk-test", header.value); + } + } + try testing.expect(saw_bearer); + + const parsed = try std.json.parseFromSlice(std.json.Value, arena, http.body, .{}); + try testing.expect(parsed.value == .object); +} + +test "a tool result becomes a message with the tool role" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const http = try encode(arena, .{ .baseUrl = "http://localhost:1234/v1" }, .{ + .model = "local-model", + .messages = &.{ + .{ .role = .user, .blocks = &.{.{ .text = "Read a.txt" }} }, + .{ .role = .assistant, .blocks = &.{.{ .tool_use = .{ + .id = "call_1", + .name = "read_file", + .argumentsJson = "{\"path\":\"a.txt\"}", + } }} }, + .{ .role = .user, .blocks = &.{.{ .tool_result = .{ + .toolUseId = "call_1", + .content = "file contents", + } }} }, + }, + }); + + try testing.expect(std.mem.indexOf(u8, http.body, "\"role\":\"tool\"") != null); + try testing.expect(std.mem.indexOf(u8, http.body, "\"tool_call_id\":\"call_1\"") != null); + // Arguments are a quoted JSON string in this shape, not an object. + try testing.expect(std.mem.indexOf(u8, http.body, "\"arguments\":\"{\\\"path\\\"") != null); + + const parsed = try std.json.parseFromSlice(std.json.Value, arena, http.body, .{}); + try testing.expect(parsed.value == .object); +} + +test "an answer with a tool call decodes" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"id":"chatcmpl-1","model":"gpt-4.1","choices":[ + \\ {"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","content":null, + \\ "tool_calls":[{"id":"call_9","type":"function", + \\ "function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}}]}}], + \\ "usage":{"prompt_tokens":30,"completion_tokens":12}} + ; + const completion = try decode(arena, body); + try testing.expectEqual(provider.StopReason.tool_use, completion.stopReason); + const calls = try completion.toolCalls(arena); + try testing.expectEqual(@as(usize, 1), calls.len); + try testing.expectEqualStrings("read_file", calls[0].name); + try testing.expectEqualStrings("{\"path\":\"a.txt\"}", calls[0].argumentsJson); + try testing.expectEqual(@as(u64, 30), completion.usage.inputTokens); +} + +test "finish reasons map to the neutral ones" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const cases = [_]struct { given: []const u8, want: provider.StopReason }{ + .{ .given = "stop", .want = .end_turn }, + .{ .given = "length", .want = .max_tokens }, + .{ .given = "content_filter", .want = .refusal }, + .{ .given = "something_new", .want = .unknown }, + }; + for (cases) |case| { + const body = try std.fmt.allocPrint(arena, + \\{{"id":"x","model":"m","choices":[{{"finish_reason":"{s}","message":{{"content":"hi"}}}}]}} + , .{case.given}); + const completion = try decode(arena, body); + try testing.expectEqual(case.want, completion.stopReason); + } +} + +test "reasoning returned in its own field becomes a thinking block" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const body = + \\{"id":"x","model":"deepseek-reasoner","choices":[{"finish_reason":"stop", + \\ "message":{"role":"assistant","content":"42","reasoning_content":"working it out"}}]} + ; + const completion = try decode(arena, body); + try testing.expectEqualStrings("42", try completion.text(arena)); + var saw_thinking = false; + for (completion.blocks) |block| { + if (block == .thinking) saw_thinking = true; + } + try testing.expect(saw_thinking); +} + +test "an error body is reported rather than decoded as an answer" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try testing.expectError(error.ProviderError, decode(arena, + \\{"error":{"message":"model not found","type":"invalid_request_error"}} + )); + // No choices at all is malformed, not an empty answer. + try testing.expectError(error.MalformedResponse, decode(arena, "{\"choices\":[]}")); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 22948bc..eed3738 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -38,6 +38,9 @@ const Command = enum { history, knowledge, term, + providers, + policy, + ask, fn parse(text: []const u8) ?Command { const table = [_]struct { name: []const u8, command: Command }{ @@ -70,6 +73,9 @@ const Command = enum { .{ .name = "history", .command = .history }, .{ .name = "knowledge", .command = .knowledge }, .{ .name = "term", .command = .term }, + .{ .name = "providers", .command = .providers }, + .{ .name = "policy", .command = .policy }, + .{ .name = "ask", .command = .ask }, }; for (table) |entry| { if (std.mem.eql(u8, entry.name, text)) return entry.command; @@ -109,6 +115,9 @@ pub const help_text = \\ history Search recorded work. For example: status:failed zig \\ knowledge Show the knowledge under .workspace/, and what is overdue. \\ term Open a shell in a terminal that records what you do. + \\ providers List the model connectors and say which credentials are set. + \\ policy Show the policy in force, and where it was read from. + \\ ask Ask a model, through the policy. Use --provider to choose one. \\ \\Options \\ --profile Use this conformance profile. The default is "default". @@ -118,6 +127,8 @@ pub const help_text = \\ --out Write generated files here. \\ --repo Audit this directory. \\ --root Read the workspace in this directory. + \\ --provider Which connector to use. Run "zag providers" to see them. + \\ --model Which model to ask for. \\ --raw Start the shell with no added prompt marks. \\ --approve-truncate Apply the recovery plan after preserving the original log. \\ @@ -136,6 +147,8 @@ const Options = struct { /// Run the shell exactly as it is, with no generated init file. raw: bool = false, approve_truncate: bool = false, + provider: []const u8 = "", + model: []const u8 = "", positional: []const []const u8 = &.{}, /// Everything after `--`. passthrough: []const []const u8 = &.{}, @@ -181,6 +194,8 @@ fn parseOptions(arena: std.mem.Allocator, args: []const []const u8) !Options { .{ .flag = "--out", .field = &options.out }, .{ .flag = "--repo", .field = &options.repo }, .{ .flag = "--root", .field = &options.root }, + .{ .flag = "--provider", .field = &options.provider }, + .{ .flag = "--model", .field = &options.model }, }; var matched = false; for (named) |entry| { @@ -280,7 +295,7 @@ fn run( try w.print("zag {s}\n", .{zag.version}); break :blk 0; }, - .doctor => try doctor(arena, w), + .doctor => try doctor(arena, io, w, options), .lint => try lint(arena, io, w, options), .terms => try terms(arena, w, options), .standards => try standards(arena, w, options), @@ -304,6 +319,9 @@ fn run( .history => try historySearch(arena, io, w, options), .knowledge => try knowledgeIndex(arena, io, w, options), .term => try interactiveTerminal(arena, io, w, options, environment), + .providers => try listProviders(arena, io, w, options, environment), + .policy => try showPolicy(arena, io, w, options), + .ask => try askAModel(arena, io, w, options, environment), }; } @@ -311,8 +329,10 @@ fn run( // Commands // --------------------------------------------------------------------------- -fn doctor(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { +fn doctor(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { const builtin = @import("builtin"); + const loaded = try LoadedPolicy.fromWorkspace(arena, io, options.root); + const written_policy = loaded.path != null and loaded.problems.len == 0; try w.print("zag {s}\n\n", .{zag.version}); try w.print("Built for {s} on {s}, with Zig {s}.\n\n", .{ @tagName(builtin.os.tag), @@ -328,6 +348,12 @@ fn doctor(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { try w.writeAll(" Read-only object audit: yes\n"); try w.writeAll(" Evidence-preserving log recovery: yes\n"); try w.writeAll(" Capability policy and approvals: yes\n"); + try w.print(" Policy written by you, in a file: {s}\n", .{if (written_policy) "yes, and it is in force" else "yes, but none is written here"}); + try w.print(" File access held inside the folder: {s}\n", .{if (zag.ai.sandbox.supported) "yes, enforced by the kernel" else "not on this platform"}); + try w.print(" Ask a model, through the policy: yes, {d} connectors, {d} on this computer\n", .{ + zag.ai.catalog.count(), + zag.ai.catalog.localCount(), + }); try w.writeAll(" Plain-language checks: yes\n"); try w.writeAll(" Terminology and metadata registry: yes\n"); try w.writeAll(" Standards registry and evidence: yes\n"); @@ -336,12 +362,11 @@ fn doctor(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { try w.writeAll("What this build cannot do yet\n"); try w.writeAll(" Draw its own window. The renderer is not written; the accessibility tree it must publish is.\n"); try w.writeAll(" Talk to a language server. The editor surfaces are not written.\n"); - try w.writeAll(" Reach a model provider. The runtime and its policy are written; no provider is wired in.\n"); + try w.writeAll(" Read an answer as it arrives. A model request waits for the whole reply.\n"); + try w.writeAll(" Run a model's tool calls on its own. The executors are written; nothing loops them yet.\n"); try w.writeAll(" Run on Windows or macOS terminals. The pseudoterminal layer is Linux only so far.\n"); try w.writeAll(" Split the screen. There are no tabs, panes or splits yet.\n"); - try w.writeAll(" Be configured. There is no settings file and no key bindings yet.\n\n"); - try w.writeAll(" Enforce file and network policy at the operating-system boundary. The concrete executors are not written.\n"); - try w.writeAll(" Run on Windows or macOS terminals. The pseudoterminal layer is Linux only so far.\n\n"); + try w.writeAll(" Be configured beyond the policy file. There are no key bindings and no theme file yet.\n\n"); const system = try zag.knowledge.vocabulary.build(arena); try w.print("The vocabulary holds {d} concepts.\n", .{system.count()}); @@ -980,6 +1005,244 @@ fn workbenchActor() zag.events.event.Actor { /// The wall clock, read through the platform's input and output layer rather /// than from a global, so a test can drive the tool with a clock of its own. +// --------------------------------------------------------------------------- +// Model providers +// --------------------------------------------------------------------------- + +/// Reads credentials from the environment the person started zag with. +/// +/// The library never touches the environment itself. This is the one place +/// that does, and it hands the value straight to the connector's encoder, so +/// no key is ever held anywhere a log or an error message could reach it. +const Environment = struct { + entries: []const []const u8, + + fn credentials(self: *const Environment) zag.ai.transport.Credentials { + return .{ .context = @constCast(self), .lookupFn = lookup }; + } + + fn lookup(context: *anyopaque, variable: []const u8) ?[]const u8 { + const self: *const Environment = @ptrCast(@alignCast(context)); + for (self.entries) |entry| { + const equals = std.mem.indexOfScalar(u8, entry, '=') orelse continue; + if (std.mem.eql(u8, entry[0..equals], variable)) { + const value = entry[equals + 1 ..]; + return if (value.len > 0) value else null; + } + } + return null; + } +}; + +threadlocal var credential_view: ?*const Environment = null; + +fn credentialIsSet(variable: []const u8) bool { + const view = credential_view orelse return false; + return Environment.lookup(@constCast(view), variable) != null; +} + +fn listProviders( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, + environment: []const []const u8, +) !u8 { + _ = arena; + _ = io; + _ = options; + const view: Environment = .{ .entries = environment }; + credential_view = &view; + defer credential_view = null; + try zag.ai.catalog.writeList(w, credentialIsSet); + return 0; +} + +/// Read the workspace's policy, or say plainly that the built-in one is in use. +/// +/// A policy that was written by a person and a policy that was chosen for them +/// are different things, and the difference is worth a line of output every +/// time rather than being something they have to remember. +const LoadedPolicy = struct { + policy: zag.ai.policy.Policy, + path: ?[]const u8, + problems: []const zag.ai.policy_file.Problem, + + fn fromWorkspace(arena: std.mem.Allocator, io: std.Io, root: []const u8) !LoadedPolicy { + const path = try std.fmt.allocPrint(arena, "{s}/{s}", .{ + root, + zag.ai.policy_file.default_path, + }); + const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)) catch { + const built_in = try zag.ai.policy_file.parse(arena, zag.ai.policy_file.local_models_only); + return .{ .policy = built_in.policy.?, .path = null, .problems = &.{} }; + }; + const result = try zag.ai.policy_file.parse(arena, source); + if (result.policy) |written| { + return .{ .policy = written, .path = path, .problems = &.{} }; + } + // A file that could not be read grants nothing. The built-in policy is + // not substituted for it, because that would turn a mistake in the + // file into permissions the person did not write. + return .{ + .policy = .{ + .id = "unusable", + .name = "This workspace's policy could not be read", + .description = "Nothing is allowed until the policy file is fixed.", + .rules = &.{}, + }, + .path = path, + .problems = result.problems, + }; + } +}; + +fn showPolicy( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, +) !u8 { + const loaded = try LoadedPolicy.fromWorkspace(arena, io, options.root); + + if (loaded.path) |path| { + try w.print("Read from {s}.\n\n", .{path}); + } else { + try w.print( + "No policy is written in this workspace, so the built-in one is in force.\nWrite {s}/{s} to change it.\n\n", + .{ options.root, zag.ai.policy_file.default_path }, + ); + } + + if (loaded.problems.len > 0) { + try w.writeAll("This file could not be used. Nothing in it is in force.\n"); + for (loaded.problems) |problem| { + if (problem.rule) |number| { + try w.print(" Rule {d}: {s}\n", .{ number, problem.message }); + } else { + try w.print(" {s}\n", .{problem.message}); + } + } + return 1; + } + + try zag.ai.policy_file.describe(loaded.policy, w); + return 0; +} + +/// Ask a model one question, through the policy. +/// +/// Nothing here decides anything. The connector is chosen, the policy is read +/// from the workspace, and the transport asks it three times before a byte +/// leaves. When the answer is no, this prints what was refused and why, which +/// is the more interesting outcome and the one worth reading carefully. +fn askAModel( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, + environment: []const []const u8, +) !u8 { + if (options.positional.len == 0) { + try w.writeAll("Write the question after the command. For example: zag ask \"what does this build do?\"\n"); + return 2; + } + + const chosen = if (options.provider.len > 0) options.provider else "ollama"; + const connector = zag.ai.catalog.find(chosen) orelse { + try w.print("\"{s}\" is not a connector zag knows. Run \"zag providers\" to see them.\n", .{chosen}); + return 2; + }; + + var question: std.ArrayList(u8) = .empty; + for (options.positional, 0..) |word, index| { + if (index > 0) try question.append(arena, ' '); + try question.appendSlice(arena, word); + } + + const loaded = try LoadedPolicy.fromWorkspace(arena, io, options.root); + if (loaded.problems.len > 0) { + try w.writeAll("This workspace's policy could not be read, so nothing was sent.\n"); + try w.writeAll("Run \"zag policy\" to see what is wrong with it.\n"); + return 1; + } + + var engine = zag.ai.policy.Engine.init(arena, loaded.policy, @bitCast(wallClock(io).ns)); + var identifiers = zag.core.id.Generator.init(@bitCast(wallClock(io).ns), 0); + const context: zag.ai.policy.Context = .{ + .actor = identifiers.next(zag.core.id.ActorId), + .now = wallClock(io), + }; + + const view: Environment = .{ .entries = environment }; + var client: std.http.Client = .{ .allocator = arena, .io = io }; + defer client.deinit(); + var http: zag.ai.transport.Http = .{ .client = &client }; + + const transport: zag.ai.transport.Transport = .{ + .arena = arena, + .engine = &engine, + .sender = http.sender(), + .credentials = view.credentials(), + }; + + var attempt: zag.ai.transport.Attempt = undefined; + const completion = transport.send(connector, .{ + .model = if (options.model.len > 0) options.model else defaultModel(connector), + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = question.items }} }}, + }, context, &attempt) catch |err| { + try w.print("{s}\n", .{zag.ai.transport.refusalText(err)}); + if (attempt.sendProblem.len > 0) try w.print("{s}\n", .{attempt.sendProblem}); + if (attempt.status) |status| { + try w.print("{s} answered {d}", .{ connector.name, status }); + if (attempt.providerError.len > 0) { + try w.print(": {s}", .{attempt.providerError}); + } + try w.writeAll("\n"); + } + try w.writeAll("\n"); + try writeDecisions(attempt, w); + return 1; + }; + + const answer = try completion.text(arena); + if (answer.len > 0) try w.print("{s}\n\n", .{answer}); + try w.print("{s}\n\n", .{try attempt.summary(arena)}); + try writeDecisions(attempt, w); + return 0; +} + +/// The model a connector answers with when the person did not name one. +/// +/// There is no right answer here, so the rule is to name something that +/// provider actually serves rather than to guess a capable one. +fn defaultModel(connector: zag.ai.catalog.Connector) []const u8 { + if (std.mem.eql(u8, connector.id, "anthropic")) return "claude-opus-5"; + if (std.mem.eql(u8, connector.id, "openai")) return "gpt-5"; + if (std.mem.eql(u8, connector.id, "gemini")) return "gemini-2.5-pro"; + if (connector.locality == .local) return "llama3.2"; + return ""; +} + +fn writeDecisions(attempt: zag.ai.transport.Attempt, w: *std.Io.Writer) !void { + if (attempt.decisions.len == 0) { + try w.writeAll("No decision was reached.\n"); + return; + } + try w.writeAll("What the policy decided\n"); + for (attempt.decisions) |decision| { + try w.print(" {s} to {s}: {s}\n", .{ + switch (decision.effect) { + .allow => "Allowed", + .deny => "Refused", + .require_human => "Waiting for you", + }, + decision.request.capability.explain(), + decision.reason, + }); + } +} + fn wallClock(io: std.Io) zag.core.time.Timestamp { const raw = std.Io.Timestamp.now(io, .real); return .{ .ns = @intCast(raw.nanoseconds) }; @@ -1857,6 +2120,31 @@ fn check(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Optio for (lifecycle_findings.items) |finding| try w.print(" LIFECYCLE {s}\n", .{finding}); problems += workflow_findings.items.len + lifecycle_findings.items.len; + // 7b. The workspace policy. A policy that does not parse allows nothing, + // which is safe but is never what the person who wrote it meant, so the + // build says so rather than letting it sit there doing nothing. + { + const policy_path = try std.fs.path.join(arena, &.{ options.repo, zag.ai.policy_file.default_path }); + if (dir.readFileAlloc(io, policy_path, arena, .limited(1 << 20))) |source| { + const result = try zag.ai.policy_file.parse(arena, source); + if (result.policy) |written| { + try w.print("Workspace policy: {d} rule(s), no problems.\n", .{written.rules.len}); + } else { + try w.print("Workspace policy: {d} problem(s). Nothing in the file is in force.\n", .{result.problems.len}); + for (result.problems) |problem| { + if (problem.rule) |number| { + try w.print(" POLICY rule {d}: {s}\n", .{ number, problem.message }); + } else { + try w.print(" POLICY {s}\n", .{problem.message}); + } + problems += 1; + } + } + } else |_| { + try w.writeAll("Workspace policy: none written, so the built-in one is in force.\n"); + } + } + // 8. Instruction files. const agents_path = try std.fs.path.join(arena, &.{ options.repo, "AGENTS.md" }); if (dir.readFileAlloc(io, agents_path, arena, .limited(4 << 20))) |source| { @@ -2155,3 +2443,145 @@ test "the daemon interface describes every operation with its capability" { try testing.expectEqualStrings("process.execute", submit.get("x-zag-capability").?.string); try testing.expect(submit.get("requestBody") != null); } + +test "the connector list says which credentials are set, without printing one" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var buffer: [16384]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + const environment = [_][]const u8{ + "PATH=/usr/bin", + "ANTHROPIC_API_KEY=sk-secret-value", + "OPENAI_API_KEY=", + }; + try testing.expectEqual(@as(u8, 0), try listProviders(arena, io, &w, .{}, &environment)); + const text = w.buffered(); + + try testing.expect(std.mem.indexOf(u8, text, "ANTHROPIC_API_KEY (set)") != null); + // An empty variable is not a credential, whatever the shell thinks. + try testing.expect(std.mem.indexOf(u8, text, "OPENAI_API_KEY (not set)") != null); + // The value never reaches the page. + try testing.expect(std.mem.indexOf(u8, text, "sk-secret-value") == null); +} + +test "the policy shown is the one written in the workspace, and a broken one grants nothing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + const directory = try std.fmt.allocPrint(arena, "{s}/.workspace", .{root}); + const policy_path = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root, zag.ai.policy_file.default_path }); + try std.Io.Dir.cwd().createDirPath(io, directory); + + // With no file, the built-in policy is in force and says so. + var absent_buffer: [4096]u8 = undefined; + var absent = std.Io.Writer.fixed(&absent_buffer); + try testing.expectEqual(@as(u8, 0), try showPolicy(arena, io, &absent, .{ .root = root })); + try testing.expect(std.mem.indexOf(u8, absent.buffered(), "No policy is written") != null); + + // A written one replaces it. + try std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = policy_path, + .data = + \\name = "Read only" + \\ + \\[[rule]] + \\allow = ["fs.read"] + \\because = "Looking is allowed." + \\ + , + }); + var written_buffer: [4096]u8 = undefined; + var written = std.Io.Writer.fixed(&written_buffer); + try testing.expectEqual(@as(u8, 0), try showPolicy(arena, io, &written, .{ .root = root })); + try testing.expect(std.mem.indexOf(u8, written.buffered(), "Read only") != null); + try testing.expect(std.mem.indexOf(u8, written.buffered(), "Looking is allowed.") != null); + + // A broken one is reported, and the built-in policy is not quietly put in + // its place. Substituting one would hand out permissions nobody wrote. + try std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = policy_path, + .data = + \\[[rule]] + \\allow = ["fs.read", "network.teleport"] + \\ + , + }); + var broken_buffer: [4096]u8 = undefined; + var broken = std.Io.Writer.fixed(&broken_buffer); + try testing.expectEqual(@as(u8, 1), try showPolicy(arena, io, &broken, .{ .root = root })); + try testing.expect(std.mem.indexOf(u8, broken.buffered(), "network.teleport") != null); + try testing.expect(std.mem.indexOf(u8, broken.buffered(), "Nothing in it is in force") != null); +} + +test "asking a model refuses before it sends, and says what the policy decided" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + + // No policy is written, so the built-in local-only one applies. A hosted + // provider is refused, and nothing is sent: this runs with no network. + var buffer: [8192]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + const status = try askAModel(arena, io, &w, .{ + .root = root, + .provider = "anthropic", + .positional = &.{ "two", "plus", "two" }, + }, &.{"ANTHROPIC_API_KEY=sk-secret-value"}); + try testing.expectEqual(@as(u8, 1), status); + + const text = w.buffered(); + try testing.expect(std.mem.indexOf(u8, text, "policy does not allow") != null); + try testing.expect(std.mem.indexOf(u8, text, "What the policy decided") != null); + // Using a model was allowed; reaching that host was not, and the two are + // shown separately because they are separate answers. + try testing.expect(std.mem.indexOf(u8, text, "Allowed to send this work to a model provider") != null); + try testing.expect(std.mem.indexOf(u8, text, "Refused to connect to services over the network") != null); + try testing.expect(std.mem.indexOf(u8, text, "sk-secret-value") == null); +} + +test "a connector nobody has heard of is refused before any policy is read" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var buffer: [2048]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try testing.expectEqual(@as(u8, 2), try askAModel(arena, io, &w, .{ + .provider = "skynet", + .positional = &.{"hello"}, + }, &.{})); + try testing.expect(std.mem.indexOf(u8, w.buffered(), "zag providers") != null); +} + +test "every connector this build ships resolves and can be named on the command line" { + // The list the tool prints and the list the transport can reach are the + // same list. A connector that printed but could not be found would be a + // promise the tool could not keep. + for (zag.ai.catalog.connectors) |connector| { + const found = zag.ai.catalog.find(connector.id) orelse return error.TestUnexpectedResult; + try testing.expectEqualStrings(connector.baseUrl, found.baseUrl); + try testing.expect(defaultModel(found).len > 0 or connector.locality == .hosted); + } +} diff --git a/src/root.zig b/src/root.zig index bd56c8c..6701ed5 100644 --- a/src/root.zig +++ b/src/root.zig @@ -97,6 +97,7 @@ pub const terminal = struct { pub const ai = struct { pub const capability = @import("ai/capability.zig"); pub const policy = @import("ai/policy.zig"); + pub const policy_file = @import("ai/policy_file.zig"); pub const approval = @import("ai/approval.zig"); pub const tools = @import("ai/tools.zig"); pub const runtime = @import("ai/runtime.zig"); @@ -106,6 +107,18 @@ pub const ai = struct { pub const transparency = @import("ai/transparency.zig"); pub const documentation = @import("ai/documentation.zig"); pub const lifecycle = @import("ai/lifecycle.zig"); + pub const sandbox = @import("ai/sandbox.zig"); + pub const executor = @import("ai/executor.zig"); + pub const provider = @import("ai/provider.zig"); + pub const catalog = @import("ai/catalog.zig"); + pub const transport = @import("ai/transport.zig"); + pub const wire = struct { + pub const anthropic = @import("ai/wire/anthropic.zig"); + pub const openai = @import("ai/wire/openai.zig"); + pub const gemini = @import("ai/wire/gemini.zig"); + pub const ollama = @import("ai/wire/ollama.zig"); + pub const huggingface = @import("ai/wire/huggingface.zig"); + }; }; pub const records = struct { From 49b11ff362513963728d32244c145a2b91839117 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 01:22:00 +0000 Subject: [PATCH 02/12] The attention loop, a dependency graph, and two vectorised scans Ask, act under a decision, ask again. A model is asked; its tool calls become typed requests; each one is decided on its own and spent by an executor; the results go back for the next turn. The narrow point is where a model's JSON becomes a typed request. There is no third outcome and no passthrough: an unknown name, arguments that do not fit the type, or an unknown enum value are refused before any policy is consulted. The capability comes from the request kind, never from the arguments, so a model writing "capability" into its own call is writing a field that does not exist. Asking another model and starting an agent are not offered at all, and a test asserts their absence rather than leaving it to whoever edits the list next. The schemas are generated from the same Zig types the executors take, so a field added to a request appears on the wire on the next build. A loop that only stops when a model says it is finished does not stop. Four bounds, each a different failure worth telling apart: turns, tokens, a monotonic wall clock, and a model asking for the same refused thing over and over. The repetition fingerprint ignores fields nobody decides on, so varying a byte limit cannot defeat it. Two graphs, both derived, neither declared. The causal forest answers "what led to this". A dependency graph adds the edges the log does not write down - a file written here and read there - and answers "which of the things I did could this be about", across sessions that caused nothing in each other. Every edge points forwards, so log order is a topological order: no sort anywhere, reachability is bitsets filled in one backwards pass, and the critical path is one pass too. That path is the chain that decided how long work took, not the sum of everything. What a model pays attention to is chosen by walking that graph from the event in question, nearest first, with recency only as a tie-break. Every other agent tool fills the window newest-first; the events that explain a failure are usually older than the ones the person can already see. Two measured speedups, same machine, back to back, in the benchmark: bytes into the screen 70.6 -> 129.4 MiB/s (1.8x) line breaks in a document 1327.2 -> 3625.8 MiB/s (2.7x) Both from src/core/scan.zig: a vector compare finds where a run of printable text ends, and counting line breaks before allocating sizes the array exactly. Each has a scalar twin that is never deleted, and a differential test over every length near a vector boundary. The screen and the parser each got a test feeding the same bytes both ways and asserting the results are identical, so the shortcut cannot drift from the thing it shortcuts. Fixed a real bug found while testing this. "zag run -- sh -c 'exit 3'" joined the argument vector back into a shell line with spaces, so the quoting was lost, "exit 3" became two words, and a command that failed was recorded as having succeeded and exited zero. This is the exact failure the threat model's T3 warns about, in the tool's own front door. Arguments are now quoted, and a test runs that command end to end and asserts the record says three. Verified: 502 tests pass; zag check clean; zig fmt clean; four cross-compile targets build; the loop was driven end to end against a local server through the policy, running one tool and being refused another. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- AGENTS.md | 6 + README.md | 21 +- bench/main.zig | 56 ++ docs/competitive-position.md | 29 +- docs/roadmap.md | 29 +- docs/threat-model.md | 10 + src/ai/attention.zig | 645 +++++++++++++++++++++ src/ai/loop.zig | 1050 ++++++++++++++++++++++++++++++++++ src/ai/toolschema.zig | 495 ++++++++++++++++ src/cli/main.zig | 402 ++++++++++++- src/core/scan.zig | 246 ++++++++ src/events/graph.zig | 81 ++- src/events/lineage.zig | 811 ++++++++++++++++++++++++++ src/interop/jsonschema.zig | 29 +- src/language/text.zig | 17 +- src/reports/notation.zig | 75 +++ src/root.zig | 5 + src/terminal/grid.zig | 106 +++- src/terminal/vt.zig | 142 ++++- 19 files changed, 4180 insertions(+), 75 deletions(-) create mode 100644 src/ai/attention.zig create mode 100644 src/ai/loop.zig create mode 100644 src/ai/toolschema.zig create mode 100644 src/core/scan.zig create mode 100644 src/events/lineage.zig diff --git a/AGENTS.md b/AGENTS.md index e23d414..414257a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,12 @@ so. approvals, the typed executors, the model connectors and governance. - `src/ai/wire` holds one file per model provider shape. A new provider that speaks an existing shape is an entry in `src/ai/catalog.zig`, not a new file. +- `src/ai/loop.zig` runs the agent loop, and `src/ai/toolschema.zig` decides + what a model may ask for. A tool added there is a tool a model can reach, so + read the file's opening comment before adding one. +- `src/events/lineage.zig` derives the dependency graph. Anything that answers + "what did this affect" belongs there rather than in `graph.zig`, which is the + causal forest and a different question. - `src/language` holds the plain-language rules. - `src/knowledge` and `src/metadata` hold the vocabulary, the workspace knowledge base and the registry. diff --git a/README.md b/README.md index 9c3a00c..49e0482 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ zag is a terminal, a block model, an agent runtime and a standards control plane in one Zig program. It fetches no package from the network: the whole thing builds from the Zig standard library. -> Status: the substrate is built and tested, and a model can be asked through -> the policy. The graphical renderer, the editor surfaces and the agent loop are -> not written yet. `zag doctor` prints what this build can and cannot do, so the -> claim never runs ahead of the code. +> Status: the substrate is built and tested, and an agent loop runs against it +> with every tool call decided separately. The graphical renderer and the editor +> surfaces are not written yet, and answers do not stream. `zag doctor` prints +> what this build can and cannot do, so the claim never runs ahead of the code. ## Why it is built this way @@ -33,11 +33,11 @@ graph, an audit trail and an agent's context are all views of the same events. | Part | What it does | | --- | --- | -| `src/events` | The typed event union, append-only hash chain and immutable content-addressed output store | +| `src/events` | The typed event union, append-only hash chain, immutable content-addressed output store, the causal graph and the derived dependency graph | | `src/workspace` | Blocks, sessions, workflows, structured history, and the workspace service | | `src/editor` | The command line as an editing document, with undo that groups by intent | | `src/terminal` | Escape-sequence parser, screen with scrollback, shell integration, real pseudoterminal, and the recording terminal | -| `src/ai` | Capabilities, the policy engine, the workspace policy file, approvals, typed tools and their executors, kernel-enforced path containment, the model connectors and the gate they pass through, risk, impact, transparency, generated cards | +| `src/ai` | Capabilities, the policy engine, the workspace policy file, approvals, typed tools and their executors, kernel-enforced path containment, the model connectors and the gate they pass through, the agent loop, attention selection, risk, impact, transparency, generated cards | | `src/language` | The plain-language pass, with controlled-English and easy-to-read profiles | | `src/knowledge` | The concept system, the thesaurus view, the published vocabulary and the workspace knowledge base | | `src/metadata` | The registry that stops one field having four names | @@ -65,7 +65,8 @@ zag term # a shell in a terminal that records what you do zag doctor # what this build can and cannot do zag policy # the policy in force, and where it was read from zag providers # the model connectors, and which credentials are set -zag ask "why did that fail?" # ask a model, through the policy +zag ask "why did that fail?" # ask a model; it can use tools, one decision each +zag why .workspace/events.jsonl # what a failure depended on, and what it affected zag run -- zig build test # run a command and record it as a block zag lint README.md # check text against the plain-language rules zag terms workspace # what a word means here @@ -149,6 +150,12 @@ request -> plan -> typed tool request -> policy decision host, and spending that credential. All three are decided before the request is encoded, so a refused request is never built and a refused credential is never read. +- A model cannot name a capability. Its tool call becomes a typed request or + nothing at all, and the capability comes from the request kind. Text can make + a model ask for anything; asking is all it can do. +- Two tools are never offered to a model: asking another model, which would + spend a credential on a request nobody read, and starting an agent, which + would let it widen its own reach. - The policy is a file the person writes, in `.workspace/policy.toml`. It is never shown to a model and never consulted by one. Run `zag policy` to read it in words. A file that does not parse allows nothing; nothing more permissive diff --git a/bench/main.zig b/bench/main.zig index bdb3cfc..460259e 100644 --- a/bench/main.zig +++ b/bench/main.zig @@ -354,6 +354,60 @@ fn benchmarkPlainLanguage(arena: std.mem.Allocator, io: std.Io, runs: usize) any return measure.into("prose through the language rules", "bytes", text.items.len, text.items.len); } +fn benchmarkLineIndex(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result { + // A large document, the size a checker or an editor really opens. + var text: std.ArrayList(u8) = .empty; + while (text.items.len < (4 << 20)) { + try text.appendSlice(arena, "a line of ordinary prose, about sixty characters long here\n"); + } + + var measure: Measure = .{}; + for (0..runs) |_| { + var scratch = std.heap.ArenaAllocator.init(arena); + defer scratch.deinit(); + const clock = Clock.start(io); + const index = try zag.language.text.LineIndex.build(scratch.allocator(), text.items); + measure.record(clock.elapsed()); + std.mem.doNotOptimizeAway(index.starts.len); + } + return measure.into("line breaks found in a document", "bytes", text.items.len, text.items.len); +} + +fn benchmarkLineage(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result { + // A day of recorded work: commands, their output, and the files they + // touched, with the files shared between them so the graph has real edges. + const commands: usize = 20_000; + var log = try buildCommandLog(arena, commands); + var gen: zag.core.id.Generator = .init(9, 1_788_000_000_000); + const actor: zag.events.event.Actor = .{ + .id = gen.next(zag.core.id.ActorId), + .kind = .agent, + .label = "agent", + }; + const session = gen.next(zag.core.id.SessionId); + var index: usize = 0; + while (index < commands) : (index += 1) { + _ = try log.append(.{ .file_changed = .{ + .session = session, + .path = try std.fmt.allocPrint(arena, "src/file-{d}.zig", .{index % 500}), + .changeKind = .modified, + } }, .{ .at = .{ .ns = 1_788_000_000_000_000_000 + @as(i64, @intCast(index)) }, .actor = actor }); + } + + var measure: Measure = .{}; + for (0..runs) |_| { + var scratch = std.heap.ArenaAllocator.init(arena); + defer scratch.deinit(); + const clock = Clock.start(io); + const dag = try zag.events.lineage.Dag.build(scratch.allocator(), log); + const path = try dag.criticalPath(); + measure.record(clock.elapsed()); + std.mem.doNotOptimizeAway(path.items.len); + } + const events = log.entries.items.len; + return measure.into("dependency graph built and its critical path", "events", events, 0); +} + pub fn main(init: std.process.Init) !u8 { const gpa = init.gpa; const io = init.io; @@ -392,6 +446,8 @@ pub fn main(init: std.process.Init) !u8 { .{ .run = benchmarkHistoryQuery, .times = 20 }, .{ .run = benchmarkEditor, .times = runs }, .{ .run = benchmarkPlainLanguage, .times = runs }, + .{ .run = benchmarkLineIndex, .times = runs }, + .{ .run = benchmarkLineage, .times = runs }, }; for (benchmarks) |benchmark| { var scratch = std.heap.ArenaAllocator.init(gpa); diff --git a/docs/competitive-position.md b/docs/competitive-position.md index 5d9ea8d..154a253 100644 --- a/docs/competitive-position.md +++ b/docs/competitive-position.md @@ -70,14 +70,13 @@ Hermes Agent, cmux, Pi, term2 and Lacy. These run a model against a repository. They differ in model support, editor integration, sandboxing and how much of the loop they automate. -Most of them are still ahead of zag on the thing they exist for. They loop: -they ask a model, run what it asks for, and ask again, and they have spent years -on how that loop behaves. zag asks a model once. The connectors, the typed -executors and the policy engine are each built and tested; nothing yet runs the -cycle between them. - -What zag has instead is the boundary. Twenty-eight connectors across five wire -formats reach hosted providers and models on this computer alike. Every one of +zag loops now. It asks a model, runs what it asks for under a decision each +time, and asks again. Those tools have spent years on how that loop behaves. They +are still ahead of it on breadth: no streaming here, one tool call at a time, no +editor integration. + +What zag has that they do not is the boundary. Twenty-eight connectors across +five wire formats reach hosted providers and models on this computer alike. Every one of them passes the policy engine three times before a byte is encoded. It asks to use a model at all, to reach that host, and to spend that credential. @@ -111,6 +110,8 @@ changes named: | Blocks folded from the log | 11 times faster | Folding scanned every block for each parent link and each git change. Both are indexed now. | | Prose through the plain-language rules | 180 times faster at 128 KiB | Placing a finding walked the text from the start, so checking a document cost the square of its length. A line index makes it linear. | | Keystrokes into the editor buffer | No longer quadratic | Typing added a piece and copied the undo buffer for each keystroke. An append extends the last piece and grows the buffer in place. | +| Bytes into the screen | 1.8 times faster | Every byte went through the escape-sequence state machine, including the printable ASCII that is almost all of them. A vectorised scan finds where a run of ordinary text ends, and the run is printed without the state machine. | +| Line breaks found in a document | 2.7 times faster | The index grew a list a byte at a time. Counting the breaks first, with a vector compare, sizes the array exactly, so nothing is ever copied to make room. | Run the harness to see what your own machine does. It reports the build mode, the best of several runs, and the spread between them. A busy machine is then @@ -131,6 +132,13 @@ Built, tested and audited by the build: spend the decision they were given rather than one they chose. - Twenty-eight model connectors across five wire formats, ten of them running on this computer, each gated by the policy engine before a byte is encoded. +- An agent loop where every tool call is a separate decision, and four separate + bounds on a run that will not stop: turns, tokens, time, and repetition. +- A dependency graph derived from the record, joining work through the files + that passed between it, and a critical path that says what decided how long + something took rather than adding everything up. +- Model attention chosen by causal distance from the thing being asked about, + rather than by taking the newest events until the window is full. - A standards control plane over sixty-five standards, with evidence. - Plain-language, terminology, metadata, accessibility and knowledge rules enforced by `zig build check`. @@ -145,9 +153,8 @@ the terminal you have for now. - **No tabs, splits or panes.** One shell to a session. - **No key bindings, no theme file.** The policy is written in a file; nothing else about the terminal is. -- **No agent loop, and no streaming.** A model can be asked one question, and - the request waits for the whole answer. Nothing yet runs a model's tool calls - through the executors and asks it again. +- **No streaming, and one tool call at a time.** A request waits for the whole + answer, and a turn that asks for four independent reads does them in order. - **Linux only.** The pseudoterminal uses Linux system calls directly. macOS and Windows are not built. - **No remote attachment.** The daemon opens, verifies, reports and plans. It diff --git a/docs/roadmap.md b/docs/roadmap.md index d011338..c0203b1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,12 +55,30 @@ The policy is read from `.workspace/policy.toml`, written by the person whose computer it is. A file that does not parse allows nothing; nothing more permissive is put in its place. +The loop runs. A model is asked. Its tool calls become typed requests. Each one +is decided on its own and spent by an executor, and the results go back for the +next turn. A model is never offered a tool that would widen its own reach. It cannot +name a capability either, because the capability comes from the request kind. +Four separate bounds stop a run: turns, tokens, wall clock, and a model asking +for the same refused thing over and over. Each ending says which. + +Two graphs are derived from the log rather than declared. The causal forest +answers "what led to this". A dependency graph adds the edges the log does not +write down, such as a file written here and read there. It answers "which of the +things I did could this be about", across sessions that caused nothing in each +other. Reachability over it is bitsets filled in one backwards pass. The +critical path is the chain that decided how long work took, rather than the sum +of everything in it. + +What a model pays attention to is chosen by walking that graph from the event +being asked about, nearest first. Recency is only a tie-break. + These statements are covered by automated tests. They do not establish power-loss survival on every filesystem, hostile tamper resistance, a safe garbage collector, a complete terminal, a graphical product or organisational -certification. The wire formats are tested against recorded answers and the -Anthropic connector has been called; the other addresses are each provider's -documented one and `zag providers` says which is which. +certification. The wire formats are tested against recorded answers, and the +Anthropic connector has been called. The other addresses are each provider's +documented one, and `zag providers` says which is which. ## Priority 0: protect the record @@ -122,11 +140,10 @@ editing Zig. document and diagnostic models. - Implement the daemon interface described by the generated OpenAPI document, with authentication, authorisation, version negotiation and backpressure. -- Loop a model's tool calls through the executors. The provider connectors and - the executors both exist; nothing yet runs the cycle of asking, executing - under a decision, and asking again. - Read a model's answer as it arrives. Every connector waits for the whole reply, which is fine for a question and wrong for a long one. +- Run more than one tool call at a time. A turn that asks for four independent + reads runs them one after another. - Add macOS pseudoterminal and Windows ConPTY implementations, and the terminal settings each one needs, with the same behavioural test suite used on Linux. - Define remote replay, reconnect and conflict semantics before allowing more diff --git a/docs/threat-model.md b/docs/threat-model.md index f2fe7eb..416b6fd 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -45,6 +45,8 @@ against that actor. | T7 | A child floods output or never exits | Captured output is bounded; deadlines use monotonic time and terminate the process group, escalating to `KILL` | Add operating-system CPU, memory, process-count and disk quotas | | T8 | Terminal output discloses a secret | New event and object files use owner-only permissions where POSIX permissions exist | Add detection and redaction before output enters model context or diagnostic bundles | | T12 | An agent spends a credential on a provider nobody chose | Asking a model needs three separate decisions — `model.infer` on the connector, `network.connect` on the host, `credentials.use` on the variable — all taken before the request is encoded. The credential is read only after its own decision allows it, is written into a header by the connector, and never enters a decision, a log, an error or a summary. A redirect is never followed, so a credential cannot be carried to a host the policy did not decide | Enforce the host at the socket, including name resolution and address changes; a host that resolves to an unexpected address is not yet caught | +| T12a | A model asks for a tool that would widen its own reach | A model's tool call becomes a typed request or nothing: an unknown name, arguments that do not fit the type, or an unknown enum value are all refused before any policy is consulted. The capability is taken from the request kind, never from the arguments, so a model that writes a capability into its own call is writing a field that does not exist. `infer` and `spawn_agent` are not offered at all, and a test asserts their absence rather than leaving it to whoever edits the list | Offer a way for a person to widen the offered set deliberately, with the widening recorded | +| T12b | An agent loop runs away, or repeats a refused call for ever | Four bounds, each reported separately: turns, tokens, a monotonic wall clock, and a repetition count over a fingerprint of the request's kind and resource. The fingerprint deliberately ignores fields nobody decides on, so varying a byte limit cannot defeat it | Bound the total work an agent may cause across runs, not only within one | | T13 | A workspace policy file is written to widen permission | The file is never shown to a model and never consulted by one. Its fallback is not read from the file: an unparsable file allows nothing, and nothing more permissive is substituted. An unknown capability name refuses the whole file rather than dropping the rule, so a denial cannot be narrowed by a typo | Add a recorded event when the policy file changes, so a widening is visible in the log rather than only in the file | | T9 | A terminal sequence corrupts memory or parser state | Fixed parser bounds, UTF-8 replacement, screen bounds and regression tests | Add coverage-guided fuzzing and differential tests against a mature terminal parser | | T10 | A build input is replaced upstream | No package dependencies; CI pins the checkout action and verifies the Zig archive digest | Add signed release provenance, artifact signing and an independently verified toolchain policy | @@ -72,6 +74,11 @@ The following properties must remain release-gate tests: - A command runs from its argument vector, with no shell interpreting it. - A model request that the policy refuses is never encoded and never sent, and its credential is never read. +- A model's tool call becomes a typed request or nothing, and its capability + comes from the request kind rather than from anything the model wrote. +- An agent run stops on every one of its four bounds, and says which. +- A command line rebuilt from an argument vector runs the same command the + vector named. - A policy file that does not parse allows nothing, and no other policy is put in its place. @@ -84,6 +91,9 @@ Review this model before merging a change that: - implements file write, delete, git push or remote execution; - sends workspace content to a model provider; - adds a connector, a wire format or a credential variable; +- adds a tool to the set a model is offered, or changes how a tool call is + parsed into a typed request; +- joins an argument vector back into a command line; - changes how the workspace policy file is read, or what its fallback is; - changes event encoding, recovery, retention or migration; - describes an event record as proof, authentic or tamper-proof. diff --git a/src/ai/attention.zig b/src/ai/attention.zig new file mode 100644 index 0000000..186f25c --- /dev/null +++ b/src/ai/attention.zig @@ -0,0 +1,645 @@ +//! What the model is allowed to pay attention to. +//! +//! A context window is a budget, and every agent tool spends it the same way: +//! newest first, until it runs out. That is the wrong rule. The newest events +//! in a session are usually the ones the person can already see; the ones that +//! explain a failure are often much older, and are reached from the failure +//! through the dependency graph rather than through the clock. +//! +//! So selection here is a graph problem, not a recency problem. Given one event +//! a person cares about — a failing test, a diagnostic, a command that did not +//! work — this walks the derived dependency graph in `events/lineage.zig` and +//! takes what that event actually depended on, nearest first. Recency is a +//! tie-breaker between things at equal causal distance, which is the job it is +//! good at. +//! +//! ## Why distance, then recency +//! +//! Causal distance is how many dependency steps separate an event from the +//! thing being asked about. Distance one is what the failure directly rests on. +//! Distance two is what those rest on. An event at distance one from a failing +//! build is relevant however old it is; an event at no distance at all is not +//! relevant however recent. Sorting by distance and breaking ties by recency +//! keeps both facts, in the right order of importance. +//! +//! ## Why the budget is in characters +//! +//! Tokens are what a provider bills, and this build cannot count them: the +//! tokenizer belongs to the model and differs between them. Counting characters +//! and dividing is an estimate, and calling it an estimate is the honest option. +//! `Selection` reports both the characters it actually packed and the estimate +//! it made, so nobody reads a guess as a measurement. +//! +//! ## What is never selected +//! +//! Nothing here reads a file, opens the content store, or resolves a hash. It +//! selects *events*, and an event is a record of something that happened, not +//! the bytes it happened to. Whoever renders the selection decides what to +//! fetch, under the policy, and that separation is deliberate: choosing what to +//! look at must not itself be a way of reading things. + +const std = @import("std"); +const event_mod = @import("../events/event.zig"); +const lineage = @import("../events/lineage.zig"); +const log_mod = @import("../events/log.zig"); +const timeutil = @import("../core/time.zig"); +const notation = @import("../reports/notation.zig"); + +pub const Envelope = event_mod.Envelope; +pub const Dag = lineage.Dag; + +/// Why one event was chosen. +/// +/// Kept so a person can ask "why is this in the context?" and get an answer +/// that is not "it fitted". +pub const Reason = enum { + /// The event the question was about. + the_question, + /// Something the question depended on, directly or through others. + depended_on, + /// Something that happened after and was affected by it. + affected_by, + /// Recent work in the same session, included because it is where the + /// person is. + recent, + + pub fn text(self: Reason) []const u8 { + return switch (self) { + .the_question => "what you asked about", + .depended_on => "what it depended on", + .affected_by => "what it went on to affect", + .recent => "recent work here", + }; + } +}; + +pub const Chosen = struct { + index: usize, + envelope: Envelope, + reason: Reason, + /// Dependency steps from the event in question. Zero for the question + /// itself. + distance: usize, + /// Characters this event's rendering took. + characters: usize, +}; + +pub const Selection = struct { + chosen: []const Chosen, + /// Events that were relevant and did not fit. + dropped: usize, + /// Characters actually packed. + characters: usize, + /// The budget it was packed against. + budget: usize, + + /// A rough token count. + /// + /// Four characters to a token is the usual English approximation. It is an + /// approximation: the real count belongs to the model's own tokenizer, + /// which this build does not have and will not guess at more precisely than + /// this. + pub fn estimatedTokens(self: Selection) usize { + return self.characters / 4; + } + + pub fn contains(self: Selection, index: usize) bool { + for (self.chosen) |item| { + if (item.index == index) return true; + } + return false; + } + + /// Say what went in and what did not, for a person checking the answer. + pub fn writeExplanation(self: Selection, w: *std.Io.Writer) std.Io.Writer.Error!void { + try w.print("{d} events, about {d} tokens of a {d} character budget.\n", .{ + self.chosen.len, + self.estimatedTokens(), + self.budget, + }); + if (self.dropped > 0) { + try w.print("{d} relevant event", .{self.dropped}); + if (self.dropped != 1) try w.writeAll("s"); + try w.writeAll(" did not fit and were left out.\n"); + } + inline for (comptime std.enums.values(Reason)) |reason| { + var count: usize = 0; + for (self.chosen) |item| { + if (item.reason == reason) count += 1; + } + if (count > 0) try w.print(" {d} for {s}\n", .{ count, reason.text() }); + } + } +}; + +pub const Options = struct { + /// Characters of rendered events. The default is roughly sixteen thousand + /// tokens, which leaves room in most windows for the instructions, the + /// tools and the answer. + budget: usize = 64_000, + /// How far back through the dependency graph to look. Beyond a few steps + /// the connection is usually real and no longer useful. + maxDistance: usize = 4, + /// Include what the event went on to affect, as well as what it depended + /// on. Useful for "what did this change break", wasteful for "why did this + /// fail". + includeAffected: bool = false, + /// How many recent events to add once the graph has been walked, to place + /// the person's own last few actions in view. + recent: usize = 8, +}; + +/// Choose what the model looks at, given one event to reason about. +pub fn select( + arena: std.mem.Allocator, + dag: Dag, + about: usize, + options: Options, +) !Selection { + if (about >= dag.nodes.len) return .{ + .chosen = &.{}, + .dropped = 0, + .characters = 0, + .budget = options.budget, + }; + + var distance = try arena.alloc(usize, dag.nodes.len); + @memset(distance, std.math.maxInt(usize)); + var reason = try arena.alloc(Reason, dag.nodes.len); + @memset(reason, .recent); + + distance[about] = 0; + reason[about] = .the_question; + + // Backwards through the dependency edges, breadth first, so the first time + // an event is reached is by its shortest path. A depth-first walk would + // give a distance that depended on the order edges happened to be stored + // in, which is not a property anybody should be sorting on. + try spread(arena, dag, &distance, &reason, about, .backwards, .depended_on, options.maxDistance); + if (options.includeAffected) { + try spread(arena, dag, &distance, &reason, about, .forwards, .affected_by, options.maxDistance); + } + + // The most recent events, whatever their connection. They cost little and + // they are where the person is looking. + if (options.recent > 0) { + var index = dag.nodes.len; + var added: usize = 0; + while (index > 0 and added < options.recent) { + index -= 1; + if (distance[index] != std.math.maxInt(usize)) continue; + distance[index] = options.maxDistance + 1; + reason[index] = .recent; + added += 1; + } + } + + // Everything reached, sorted by distance and then by recency. + var candidates: std.ArrayList(Chosen) = .empty; + for (distance, 0..) |d, index| { + if (d == std.math.maxInt(usize)) continue; + try candidates.append(arena, .{ + .index = index, + .envelope = dag.nodes[index].envelope, + .reason = reason[index], + .distance = d, + .characters = renderedLength(dag.nodes[index].envelope), + }); + } + std.mem.sort(Chosen, candidates.items, {}, closerFirst); + + var chosen: std.ArrayList(Chosen) = .empty; + var used: usize = 0; + var dropped: usize = 0; + for (candidates.items) |candidate| { + if (used + candidate.characters > options.budget) { + // Not `break`. A later candidate may be small enough to fit, and + // leaving out something that fits because something bigger did not + // would waste the budget it exists to spend. + dropped += 1; + continue; + } + used += candidate.characters; + try chosen.append(arena, candidate); + } + + // Back into the order things happened, because that is how a conversation + // reads. The selection was made by relevance; the presentation is by time. + std.mem.sort(Chosen, chosen.items, {}, earlierFirst); + + return .{ + .chosen = chosen.items, + .dropped = dropped, + .characters = used, + .budget = options.budget, + }; +} + +fn spread( + arena: std.mem.Allocator, + dag: Dag, + distance: *[]usize, + reason: *[]Reason, + from: usize, + comptime direction: enum { forwards, backwards }, + comptime why: Reason, + limit: usize, +) !void { + var frontier: std.ArrayList(usize) = .empty; + try frontier.append(arena, from); + + var step: usize = 1; + while (step <= limit and frontier.items.len > 0) : (step += 1) { + var next: std.ArrayList(usize) = .empty; + for (frontier.items) |current| { + const list = switch (direction) { + .forwards => dag.nodes[current].out, + .backwards => dag.nodes[current].in, + }; + for (list) |e| { + const neighbour = switch (direction) { + .forwards => dag.edges[e].to, + .backwards => dag.edges[e].from, + }; + if (distance.*[neighbour] <= step) continue; + distance.*[neighbour] = step; + reason.*[neighbour] = why; + try next.append(arena, neighbour); + } + } + frontier = next; + } +} + +fn closerFirst(_: void, a: Chosen, b: Chosen) bool { + if (a.distance != b.distance) return a.distance < b.distance; + // Same distance: the more recent one first. + return a.index > b.index; +} + +fn earlierFirst(_: void, a: Chosen, b: Chosen) bool { + return a.index < b.index; +} + +/// How many characters an event takes when written for a model. +/// +/// Measured by writing it, so the estimate and the rendering cannot disagree. +/// A discarded writer counts without allocating. +pub fn renderedLength(envelope: Envelope) usize { + var counter: std.Io.Writer.Discarding = .init(&.{}); + writeEvent(envelope, &counter.writer) catch return 0; + return counter.count + counter.writer.end; +} + +/// Write one event the way a model reads it. +/// +/// Plain sentences, not JSON. A model reads prose better than it reads a record +/// format, and the record format is available to anything that wants it. +/// +/// Two things are deliberately not written. Content hashes, because a model +/// cannot do anything with one and it costs tokens to carry. And any output +/// bytes: an event says how much output there was and where it is, and fetching +/// it is a decision for whoever renders this, under the policy. +pub fn writeEvent(envelope: Envelope, w: *std.Io.Writer) std.Io.Writer.Error!void { + switch (envelope.payload) { + .command_submitted => |e| try w.print("Ran: {s} (in {s})", .{ e.commandText, e.workingDirectory }), + .command_finished => |e| { + if (e.exitStatus == 0) { + try w.writeAll("It worked, after "); + } else { + try w.print("It failed with status {d}, after ", .{e.exitStatus}); + } + try notation.writeDuration(w, e.duration); + try w.writeAll("."); + }, + .process_output => |e| try w.print("It printed {d} bytes.", .{e.byteCount}), + .file_changed => |e| try w.print("{s} was {s}.", .{ e.path, @tagName(e.changeKind) }), + .file_opened => |e| try w.print("{s} was read.", .{e.path}), + .diagnostic => |e| try w.print("{s}:{d}:{d}: {s}: {s}", .{ + e.path, + e.line, + e.column, + @tagName(e.severity), + e.message, + }), + .user_message => |e| try w.print("You said: {s}", .{e.text}), + .agent_message => |e| try w.print("The agent said ({s}): {s}", .{ @tagName(e.role), e.text }), + .agent_started => |e| try w.print("An agent started, for: {s}", .{e.request}), + .tool_requested => |e| try w.print("A tool was asked for: {s} ({s})", .{ e.tool, e.capability }), + .tool_finished => |e| try w.print("That tool {s}. {s}", .{ @tagName(e.outcome), e.summary }), + .approval_requested => |e| try w.print("You were asked: {s}", .{e.promptText}), + .approval_resolved => |e| try w.print("You answered: {s}.", .{@tagName(e.outcome)}), + .git_changed => |e| { + try w.print("The repository moved", .{}); + if (e.branch) |branch| try w.print(" on {s}", .{branch}); + try w.print(": {d} file(s) changed.", .{e.dirtyFileCount}); + }, + .session_opened => try w.writeAll("A session started."), + .session_closed => try w.writeAll("A session ended."), + .evidence_recorded => |e| try w.print("Evidence recorded for {s}: {s}.", .{ e.requirement, e.result }), + } +} + +/// Write the whole selection as the text a model is given. +pub fn writeSelection( + selection: Selection, + w: *std.Io.Writer, +) std.Io.Writer.Error!void { + for (selection.chosen) |item| { + try writeEvent(item.envelope, w); + try w.writeAll("\n"); + } +} + +const testing = std.testing; +const idmod = @import("../core/id.zig"); + +const Builder = struct { + arena: std.mem.Allocator, + log: log_mod.Log, + ids: idmod.Generator, + at: i64 = 1_700_000_000 * timeutil.ns_per_s, + + fn init(arena: std.mem.Allocator) Builder { + return .{ + .arena = arena, + .log = log_mod.Log.init(arena, 1), + .ids = idmod.Generator.init(2, 0), + }; + } + + fn append(self: *Builder, payload: event_mod.WorkspaceEvent) !void { + self.at += timeutil.ns_per_s; + _ = try self.log.append(payload, .{ + .at = .{ .ns = self.at }, + .actor = .{ .id = self.ids.next(idmod.ActorId), .kind = .person, .label = "you" }, + }); + } +}; + +test "what a failure depended on beats what merely happened recently" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.ids.next(idmod.SessionId); + + // 0: the change that will turn out to matter, long ago. + try builder.append(.{ .file_changed = .{ + .session = session, + .path = "src/parser.zig", + .changeKind = .modified, + } }); + // 1..20: a lot of unrelated work in between. + var noise: usize = 0; + while (noise < 20) : (noise += 1) { + try builder.append(.{ .file_changed = .{ + .session = session, + .path = try std.fmt.allocPrint(arena, "docs/note-{d}.md", .{noise}), + .changeKind = .modified, + } }); + } + // 21: the diagnostic, which depends on event 0 through the file. + try builder.append(.{ .diagnostic = .{ + .session = session, + .path = "src/parser.zig", + .line = 9, + .column = 1, + .severity = .@"error", + .source = "zig", + .message = "expected type", + } }); + + const dag = try lineage.Dag.build(arena, builder.log); + const selection = try select(arena, dag, 21, .{ .budget = 400, .recent = 2 }); + + // The old change is in, and it is in because the failure depended on it. + try testing.expect(selection.contains(0)); + for (selection.chosen) |item| { + if (item.index != 0) continue; + try testing.expectEqual(Reason.depended_on, item.reason); + try testing.expectEqual(@as(usize, 1), item.distance); + } + + // Twenty newer events sit between the two, and the only ones that got in + // are the two the recency allowance asked for. The rest were never + // candidates: they were not dropped for want of room, they were not + // relevant. That is the difference between this and filling a window + // newest-first, and it is the whole point of the file. + var noise_kept: usize = 0; + for (selection.chosen) |item| { + if (item.index >= 1 and item.index <= 20) { + noise_kept += 1; + try testing.expectEqual(Reason.recent, item.reason); + } + } + try testing.expectEqual(@as(usize, 2), noise_kept); + try testing.expectEqual(@as(usize, 4), selection.chosen.len); + + // With no recency allowance at all, only the failure and its cause remain. + const strict = try select(arena, dag, 21, .{ .budget = 400, .recent = 0 }); + try testing.expectEqual(@as(usize, 2), strict.chosen.len); + try testing.expect(strict.contains(0)); + try testing.expect(strict.contains(21)); +} + +test "the selection reads in the order things happened" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.ids.next(idmod.SessionId); + const block = builder.ids.next(idmod.BlockId); + + try builder.append(.{ .file_changed = .{ + .session = session, + .path = "src/a.zig", + .changeKind = .modified, + } }); + try builder.append(.{ .command_submitted = .{ + .block = block, + .session = session, + .commandText = "zig build test", + .workingDirectory = "/repo", + } }); + try builder.append(.{ .command_finished = .{ + .block = block, + .session = session, + .exitStatus = 1, + .duration = .{ .ns = 3 * timeutil.ns_per_s }, + } }); + + const dag = try lineage.Dag.build(arena, builder.log); + const selection = try select(arena, dag, 2, .{}); + + // Chosen by relevance; presented by time, because that is how it reads. + var previous: usize = 0; + for (selection.chosen, 0..) |item, position| { + if (position > 0) try testing.expect(item.index > previous); + previous = item.index; + } + + var buffer: [2048]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try writeSelection(selection, &w); + const text = w.buffered(); + try testing.expect(std.mem.indexOf(u8, text, "zig build test") != null); + try testing.expect(std.mem.indexOf(u8, text, "It failed with status 1") != null); + // Prose, not a record format. + try testing.expect(std.mem.indexOf(u8, text, "{\"") == null); +} + +test "a budget too small for everything keeps the closest and says what it dropped" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.ids.next(idmod.SessionId); + + var index: usize = 0; + while (index < 40) : (index += 1) { + try builder.append(.{ .file_changed = .{ + .session = session, + .path = "src/one.zig", + .changeKind = .modified, + } }); + } + + const dag = try lineage.Dag.build(arena, builder.log); + const selection = try select(arena, dag, 39, .{ .budget = 100, .maxDistance = 40, .recent = 0 }); + + try testing.expect(selection.characters <= 100); + try testing.expect(selection.chosen.len > 0); + try testing.expect(selection.dropped > 0); + // The event asked about is always in it. Dropping the question would make + // the answer meaningless. + try testing.expect(selection.contains(39)); + + var buffer: [1024]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try selection.writeExplanation(&w); + const text = w.buffered(); + try testing.expect(std.mem.indexOf(u8, text, "did not fit") != null); + try testing.expect(std.mem.indexOf(u8, text, "what you asked about") != null); +} + +test "an estimate is reported as an estimate" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.ids.next(idmod.SessionId); + try builder.append(.{ .user_message = .{ .session = session, .text = "why did that fail?" } }); + + const dag = try lineage.Dag.build(arena, builder.log); + const selection = try select(arena, dag, 0, .{}); + + var buffer: [512]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try selection.writeExplanation(&w); + // "about N tokens", never "N tokens": this build has no tokenizer and does + // not pretend to. + try testing.expect(std.mem.indexOf(u8, w.buffered(), "about ") != null); + + // The measured length and the written length are the same number, because + // one is produced by writing the other. + var measure: [512]u8 = undefined; + var m = std.Io.Writer.fixed(&measure); + try writeEvent(selection.chosen[0].envelope, &m); + try testing.expectEqual(m.buffered().len, selection.chosen[0].characters); +} + +test "looking forwards answers a different question from looking back" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.ids.next(idmod.SessionId); + + try builder.append(.{ .file_changed = .{ + .session = session, + .path = "src/core.zig", + .changeKind = .modified, + } }); + try builder.append(.{ .file_opened = .{ + .session = session, + .path = "src/core.zig", + .contentHash = .zero, + } }); + try builder.append(.{ .diagnostic = .{ + .session = session, + .path = "src/core.zig", + .line = 1, + .column = 1, + .severity = .warning, + .source = "zig", + .message = "unused", + } }); + + const dag = try lineage.Dag.build(arena, builder.log); + + // Asking about the change, looking back: nothing came before it. + const backwards = try select(arena, dag, 0, .{ .recent = 0 }); + try testing.expectEqual(@as(usize, 1), backwards.chosen.len); + + // Asking about the change, looking forward: what it went on to affect. + const forwards = try select(arena, dag, 0, .{ .recent = 0, .includeAffected = true }); + try testing.expect(forwards.chosen.len > 1); + var saw_affected = false; + for (forwards.chosen) |item| { + if (item.reason == .affected_by) saw_affected = true; + } + try testing.expect(saw_affected); +} + +test "every kind of event can be written for a model" { + // A new event kind that nobody taught this to write would be silently + // absent from every context. The switch is exhaustive, so this is really a + // check that each arm produces something a person would recognise. + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.ids.next(idmod.SessionId); + const block = builder.ids.next(idmod.BlockId); + const agent = builder.ids.next(idmod.AgentId); + + try builder.append(.{ .session_opened = .{ .session = session, .workingDirectory = "/repo", .shell = "bash" } }); + try builder.append(.{ .command_submitted = .{ + .block = block, + .session = session, + .commandText = "ls", + .workingDirectory = "/repo", + } }); + try builder.append(.{ .process_output = .{ + .block = block, + .session = session, + .contentHash = .zero, + .byteCount = 3, + } }); + try builder.append(.{ .agent_started = .{ + .agent = agent, + .session = session, + .request = "fix the parser", + .provider = "ollama", + .model = "llama3.2", + .policy = "workspace", + } }); + + for (builder.log.entries.items) |entry| { + var buffer: [512]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try writeEvent(entry, &w); + try testing.expect(w.buffered().len > 0); + // No identifier text leaks into what the model reads: it cannot use one + // and it costs tokens to carry. + try testing.expect(std.mem.indexOf(u8, w.buffered(), "evt_") == null); + try testing.expect(std.mem.indexOf(u8, w.buffered(), "blk_") == null); + } +} diff --git a/src/ai/loop.zig b/src/ai/loop.zig new file mode 100644 index 0000000..e676aa1 --- /dev/null +++ b/src/ai/loop.zig @@ -0,0 +1,1050 @@ +//! The attention loop: ask, act under a decision, ask again. +//! +//! A model on its own produces text. What makes it an agent is this cycle: it +//! is asked, it asks for tools, the tools run, and it is asked again with what +//! they produced. Everything else in this repository exists to make each turn +//! of that cycle safe and legible, and this file is where they meet. +//! +//! One turn: +//! +//! ``` +//! ask the model -> tool calls -> typed request -> policy decision +//! -> executor spends the decision -> result +//! -> back into the conversation -> ask again +//! ``` +//! +//! Nothing here decides anything. It has no allowlist of its own and no opinion +//! about what a model should be allowed to do. It carries requests to the +//! policy engine and results back, and it stops. +//! +//! ## Why it stops +//! +//! A loop that only stops when a model says it is finished is a loop that does +//! not stop. Four bounds are checked every turn, and each one is a different +//! failure worth telling apart: +//! +//! * **Turns.** A plain count. It catches the ordinary case of a model that +//! will keep going. +//! * **Tokens.** What the run costs. A model that reads a large file each +//! turn hits this long before the turn count. +//! * **Wall clock.** What the person waited. Measured on the monotonic clock, +//! so a change to the system time cannot extend or cut short a run. +//! * **Repetition.** The one that matters most in practice. A model that has +//! been refused often retries the same call forever, and each retry is +//! cheap enough that the other three bounds take a long time to notice. The +//! loop hashes each request and stops when the same one comes back too many +//! times, because the answer will not be different. +//! +//! ## What is written down +//! +//! Each turn produces a `Turn`, and the run produces a `Transcript`. Both are +//! complete on a refusal as well as on success: a run that was stopped by the +//! policy on its first tool call still has the decision that stopped it, and +//! that is the interesting record, not the empty answer. +//! +//! ## What is not here +//! +//! Approvals. When a policy says a person decides, the loop stops and says so, +//! carrying the request and the decision out to whoever asked. It does not +//! prompt, because the thing that prompts is a user interface and this is not +//! one. A caller that has a person available resumes by granting and calling +//! again. + +const std = @import("std"); +const provider = @import("provider.zig"); +const catalog = @import("catalog.zig"); +const transport_mod = @import("transport.zig"); +const toolschema = @import("toolschema.zig"); +const executor_mod = @import("executor.zig"); +const policy_mod = @import("policy.zig"); +const tools = @import("tools.zig"); +const capability_mod = @import("capability.zig"); +const hashing = @import("../core/hash.zig"); +const timeutil = @import("../core/time.zig"); +const notation = @import("../reports/notation.zig"); + +pub const Decision = policy_mod.Decision; +pub const ToolRequest = tools.ToolRequest; +pub const ToolResult = tools.ToolResult; + +/// Why a run ended. +/// +/// Every one of these is an ordinary outcome that a person may need to act on, +/// which is why they are an enumeration and not an error set. Only a run that +/// could not be attempted at all is an error. +pub const Ending = enum { + /// The model finished its turn with an answer. + answered, + /// The model declined. + refused_by_model, + /// A person has to allow something before the run can go on. + waiting_for_a_person, + /// The policy refused, and no person was going to be asked. + stopped_by_policy, + /// The turn budget ran out. + out_of_turns, + /// The token budget ran out. + out_of_tokens, + /// The time budget ran out. + out_of_time, + /// The model asked for the same thing over and over. + going_in_circles, + /// The provider could not be reached, or would not answer. + provider_unavailable, + + /// Whether the run produced the answer it was asked for. + pub fn succeeded(self: Ending) bool { + return self == .answered; + } + + /// What a person is told, in words that say what to do next where there is + /// something to do. + pub fn text(self: Ending) []const u8 { + return switch (self) { + .answered => "The model finished.", + .refused_by_model => "The model declined to answer.", + .waiting_for_a_person => "This is waiting for you to allow something.", + .stopped_by_policy => "The policy refused something the model needed, so the run stopped.", + .out_of_turns => "The run reached its limit on turns before it finished.", + .out_of_tokens => "The run reached its limit on tokens before it finished.", + .out_of_time => "The run reached its time limit before it finished.", + .going_in_circles => "The model kept asking for the same thing, so the run stopped.", + .provider_unavailable => "The provider could not be reached.", + }; + } +}; + +/// One tool call, from what the model asked for to what happened. +pub const Step = struct { + /// The identifier the model gave the call, needed to pair the result back. + callId: []const u8, + /// The name the model used, whether or not it was a real tool. + toolName: []const u8, + /// The typed request, when the call became one. + request: ?ToolRequest = null, + /// The decision, when one was reached. + decision: ?Decision = null, + /// What happened, when anything did. + result: ?ToolResult = null, + /// What is put back into the conversation for the model to read. + reply: []const u8, + /// True when the reply says no rather than reporting an outcome. + refused: bool = false, + /// True only when the executor was actually called. + /// + /// A refusal carries a result too — the model needs to be told what + /// happened — but that result was written here, not by anything running. + /// The two must not be confused: "was refused" and "ran and failed" look + /// alike in a summary and are opposites in an audit. + executed: bool = false, + + pub fn ran(self: Step) bool { + return self.executed; + } +}; + +/// One turn: one question to the model, and everything done with its answer. +pub const Turn = struct { + number: usize, + /// The record of the model request itself, decisions included. + attempt: transport_mod.Attempt, + /// What the model said, when it said anything. + text: []const u8 = "", + steps: []const Step = &.{}, + usage: provider.Usage = .{}, +}; + +/// The whole run. +pub const Transcript = struct { + ending: Ending, + turns: []const Turn, + /// The model's last text, which is the answer when there is one. + answer: []const u8 = "", + /// Everything the run spent, added up across turns. + usage: provider.Usage = .{}, + /// How long the run took, on the monotonic clock. + elapsed: timeutil.Duration = .{ .ns = 0 }, + /// Set when the run stopped for a person to decide. This is the request + /// they are being asked about. + pending: ?ToolRequest = null, + /// The decision that asked for them. + pendingDecision: ?Decision = null, + + pub fn toolCallCount(self: Transcript) usize { + var total: usize = 0; + for (self.turns) |turn| total += turn.steps.len; + return total; + } + + pub fn ranAnything(self: Transcript) bool { + for (self.turns) |turn| { + for (turn.steps) |step| { + if (step.ran()) return true; + } + } + return false; + } + + /// One paragraph a person can read instead of the transcript. + pub fn writeSummary(self: Transcript, w: *std.Io.Writer) std.Io.Writer.Error!void { + try w.print("{s}\n", .{self.ending.text()}); + try w.print("{d} turn", .{self.turns.len}); + if (self.turns.len != 1) try w.writeAll("s"); + try w.print(", {d} tool call", .{self.toolCallCount()}); + if (self.toolCallCount() != 1) try w.writeAll("s"); + var refused: usize = 0; + for (self.turns) |turn| { + for (turn.steps) |step| { + if (step.refused) refused += 1; + } + } + if (refused > 0) { + try w.print(", {d} of them refused", .{refused}); + } + try w.print(", {d} tokens in and {d} out", .{ + self.usage.inputTokens, + self.usage.outputTokens, + }); + try w.writeAll(", and took "); + try notation.writeDuration(w, self.elapsed); + try w.writeAll(".\n"); + } +}; + +/// The bounds a run is held to. +/// +/// The defaults are deliberately modest. A run that needs more than this is a +/// run whose author should say so, because the cost lands on somebody. +pub const Budget = struct { + /// How many times the model may be asked. + turns: usize = 12, + /// Tokens across the whole run, input and output together. + tokens: u64 = 400_000, + /// Wall clock for the whole run. + time: timeutil.Duration = .{ .ns = 300 * timeutil.ns_per_s }, + /// How many times the same request may be asked for before the run is + /// treated as stuck. Two is a retry; more than that is a loop. + repeats: usize = 3, + /// Tool calls the model may make in one turn. A model that asks for + /// hundreds at once is not being helped by having them all run. + callsPerTurn: usize = 16, +}; + +pub const Error = error{ + /// The run could not be started: no connector, or no way to send. + CannotStart, +} || std.mem.Allocator.Error; + +pub const Options = struct { + connector: catalog.Connector, + model: []const u8, + /// The instructions the model runs under. This is prompt text: it shapes + /// behaviour and grants nothing. + system: []const u8 = "", + budget: Budget = .{}, + /// Ask for the model's reasoning where the provider offers it. + showThinking: bool = false, + effort: ?provider.Effort = null, + maxOutputTokens: u32 = 16000, +}; + +/// A monotonic clock, passed in so a run can be timed in a test without one. +pub const Clock = struct { + context: *anyopaque, + nowFn: *const fn (context: *anyopaque) i64, + + pub fn now(self: Clock) i64 { + return self.nowFn(self.context); + } +}; + +/// A clock that never advances. A run under this one is bounded by turns, +/// tokens and repetition, and its elapsed time is zero. +pub const stopped_clock: Clock = .{ + .context = undefined, + .nowFn = struct { + fn now(_: *anyopaque) i64 { + return 0; + } + }.now, +}; + +pub const Runner = struct { + arena: std.mem.Allocator, + transport: transport_mod.Transport, + executor: executor_mod.Runner, + engine: *policy_mod.Engine, + clock: Clock = stopped_clock, + + /// Run the loop until it ends, for one reason or another. + pub fn run( + self: Runner, + options: Options, + question: []const u8, + context: policy_mod.Context, + ) Error!Transcript { + const started = self.clock.now(); + + var messages: std.ArrayList(provider.Message) = .empty; + try messages.append(self.arena, .{ + .role = .user, + .blocks = try self.arena.dupe(provider.Block, &.{.{ .text = question }}), + }); + + var turns: std.ArrayList(Turn) = .empty; + var usage: provider.Usage = .{}; + var seen: std.ArrayList(Repeat) = .empty; + const declared = toolschema.declarations(self.arena) catch return error.OutOfMemory; + + var ending: Ending = .out_of_turns; + var answer: []const u8 = ""; + var pending: ?ToolRequest = null; + var pending_decision: ?Decision = null; + + var number: usize = 1; + while (number <= options.budget.turns) : (number += 1) { + var attempt: transport_mod.Attempt = undefined; + const completion = self.transport.send(options.connector, .{ + .model = options.model, + .system = options.system, + .messages = messages.items, + .tools = declared, + .maxOutputTokens = options.maxOutputTokens, + .effort = options.effort, + .showThinking = options.showThinking, + }, context, &attempt) catch |err| { + try turns.append(self.arena, .{ .number = number, .attempt = attempt }); + ending = switch (err) { + error.NeedsApproval => .waiting_for_a_person, + error.NotPermitted, error.CredentialMissing => .stopped_by_policy, + error.ProviderRejected, error.SendFailed => .provider_unavailable, + error.OutOfMemory => return error.OutOfMemory, + }; + break; + }; + + usage.inputTokens += completion.usage.inputTokens; + usage.outputTokens += completion.usage.outputTokens; + usage.cacheReadTokens += completion.usage.cacheReadTokens; + usage.cacheWriteTokens += completion.usage.cacheWriteTokens; + + const said = try completion.text(self.arena); + if (said.len > 0) answer = said; + + // The model's own turn goes into the conversation exactly as it + // came, so that a tool result has a call to belong to. + try messages.append(self.arena, .{ + .role = .assistant, + .blocks = completion.blocks, + }); + + const calls = try completion.toolCalls(self.arena); + if (calls.len == 0) { + try turns.append(self.arena, .{ + .number = number, + .attempt = attempt, + .text = said, + .usage = completion.usage, + }); + ending = switch (completion.stopReason) { + .refusal => .refused_by_model, + else => .answered, + }; + break; + } + + var steps: std.ArrayList(Step) = .empty; + var results: std.ArrayList(provider.Block) = .empty; + var stop: ?Ending = null; + + for (calls, 0..) |call, index| { + if (index >= options.budget.callsPerTurn) break; + + const step = try self.performOne(call, context, &seen, options.budget.repeats); + try steps.append(self.arena, step); + try results.append(self.arena, .{ .tool_result = .{ + .toolUseId = call.id, + .content = step.reply, + .isError = step.refused or (step.result != null and !step.result.?.succeeded()), + } }); + + if (step.decision) |decision| { + if (decision.effect == .require_human) { + stop = .waiting_for_a_person; + pending = step.request; + pending_decision = decision; + } + } + if (self.repeatedTooOften(seen.items, options.budget.repeats)) { + stop = .going_in_circles; + } + } + + try turns.append(self.arena, .{ + .number = number, + .attempt = attempt, + .text = said, + .steps = steps.items, + .usage = completion.usage, + }); + + if (stop) |reason| { + ending = reason; + break; + } + + // The results go back as one user turn, which is the shape every + // provider expects. + try messages.append(self.arena, .{ .role = .user, .blocks = results.items }); + + if (usage.total() >= options.budget.tokens) { + ending = .out_of_tokens; + break; + } + const spent = self.clock.now() - started; + if (spent >= options.budget.time.ns and options.budget.time.ns > 0 and spent > 0) { + ending = .out_of_time; + break; + } + } + + return .{ + .ending = ending, + .turns = turns.items, + .answer = answer, + .usage = usage, + .elapsed = .{ .ns = self.clock.now() - started }, + .pending = pending, + .pendingDecision = pending_decision, + }; + } + + /// One tool call, all the way through. + fn performOne( + self: Runner, + call: provider.ToolUse, + context: policy_mod.Context, + seen: *std.ArrayList(Repeat), + repeat_limit: usize, + ) Error!Step { + var step: Step = .{ + .callId = call.id, + .toolName = call.name, + .reply = "", + }; + + // 1. Does the call become a typed request at all? Nothing is decided + // until it does, because a decision needs a resource to be about. + const request = toolschema.parse(self.arena, call.name, call.argumentsJson) catch |err| { + step.refused = true; + step.reply = toolschema.refusalText(err); + return step; + }; + step.request = request; + + // 2. Has this exact request already been asked for, over and over? A + // repeated call is counted before it is decided, so a model cannot + // spend the policy engine's time on the same refusal. + const fingerprint = try fingerprintOf(self.arena, request); + const count = try noteRepeat(self.arena, seen, fingerprint); + if (count > repeat_limit) { + step.refused = true; + step.reply = "You have asked for this several times and the answer has not changed. Try something else, or say what you need from the person."; + return step; + } + + // 3. The policy decides. The resource comes from the request, through + // the executor, not from anything the model wrote. + var engine = self.engine; + const decision = engine.decide(.{ + .capability = request.capability(), + .resource = executor_mod.resourceOf(request), + }, context) catch return error.OutOfMemory; + step.decision = decision; + + if (decision.effect == .require_human) { + step.refused = true; + step.reply = "This needs the person to allow it. The run has stopped and they are being asked."; + return step; + } + if (!decision.isAllowed()) { + step.refused = true; + step.result = .{ .outcome = .denied, .summary = decision.reason }; + step.reply = try toolschema.resultText(self.arena, step.result.?); + return step; + } + + // 4. A second capability, when the request needs one. An execute that + // wants the network asks for the network too, and either answer can + // stop it. + if (request.additionalCapability()) |extra| { + const also = engine.decide(.{ + .capability = extra, + .resource = executor_mod.resourceOf(request), + }, context) catch return error.OutOfMemory; + if (!also.isAllowed()) { + step.decision = also; + step.refused = true; + step.result = .{ .outcome = .denied, .summary = also.reason }; + step.reply = try toolschema.resultText(self.arena, step.result.?); + return step; + } + } + + // 5. The executor spends the decision. It checks the match again, on + // its own terms, because this loop is not what makes that safe. + step.executed = true; + const result = self.executor.run(decision, request) catch |err| { + step.result = .{ .outcome = .failed, .summary = executor_mod.refusalText(err) }; + step.reply = try toolschema.resultText(self.arena, step.result.?); + return step; + }; + step.result = result; + step.reply = try toolschema.resultText(self.arena, result); + return step; + } + + fn repeatedTooOften(self: Runner, seen: []const Repeat, limit: usize) bool { + _ = self; + for (seen) |entry| { + if (entry.count > limit) return true; + } + return false; + } +}; + +const Repeat = struct { + fingerprint: hashing.Hash, + count: usize, +}; + +fn noteRepeat( + arena: std.mem.Allocator, + seen: *std.ArrayList(Repeat), + fingerprint: hashing.Hash, +) !usize { + for (seen.items) |*entry| { + if (entry.fingerprint.eql(fingerprint)) { + entry.count += 1; + return entry.count; + } + } + try seen.append(arena, .{ .fingerprint = fingerprint, .count = 1 }); + return 1; +} + +/// What makes two tool calls the same call. +/// +/// The kind and the resource, and nothing else. Two reads of one file are the +/// same request even if the model asked for a different byte limit, because the +/// answer that matters — allowed or refused — will be identical. Counting them +/// as different would let a model defeat the repetition bound by varying a +/// field nobody decides on. +pub fn fingerprintOf(arena: std.mem.Allocator, request: ToolRequest) !hashing.Hash { + const resource = executor_mod.resourceOf(request); + var extra: []const u8 = ""; + switch (request) { + .execute => |e| extra = try e.commandText(arena), + .git => |g| extra = @tagName(g.operation), + .mcp => |m| extra = m.tool, + .search => |s| extra = s.query, + else => {}, + } + return hashing.Hash.ofParts(&.{ + @tagName(request), + resource.kindName(), + resource.text(), + extra, + }); +} + +const testing = std.testing; +const idmod = @import("../core/id.zig"); +const content_store = @import("../events/content_store.zig"); + +/// A clock a test drives by hand. +const TestClock = struct { + ns: i64 = 0, + + fn clock(self: *TestClock) Clock { + return .{ .context = self, .nowFn = read }; + } + + fn read(context: *anyopaque) i64 { + const self: *TestClock = @ptrCast(@alignCast(context)); + // Every reading moves a second on, which is enough for a test to reach + // a time bound without sleeping. + self.ns += timeutil.ns_per_s; + return self.ns; + } +}; + +const Fixture = struct { + arena_state: std.heap.ArenaAllocator, + engine: policy_mod.Engine, + recorded: transport_mod.Recorded, + root: []const u8, + tmp: std.testing.TmpDir, + threaded: std.Io.Threaded, + + fn init(rules: []const policy_mod.Rule, responses: []const transport_mod.Response) !*Fixture { + const self = try testing.allocator.create(Fixture); + self.* = .{ + .arena_state = std.heap.ArenaAllocator.init(testing.allocator), + .engine = undefined, + .recorded = undefined, + .root = undefined, + .tmp = std.testing.tmpDir(.{}), + .threaded = .init(testing.allocator, .{}), + }; + const scratch = self.arena_state.allocator(); + self.engine = policy_mod.Engine.init(scratch, .{ + .id = "test", + .name = "Test policy", + .description = "Whatever the test needs.", + .rules = rules, + }, 7); + self.recorded = .{ .responses = responses, .arena = scratch }; + self.root = try std.fmt.allocPrint(scratch, ".zig-cache/tmp/{s}", .{self.tmp.sub_path}); + return self; + } + + fn deinit(self: *Fixture) void { + self.tmp.cleanup(); + self.threaded.deinit(); + self.arena_state.deinit(); + testing.allocator.destroy(self); + } + + /// Put a file in the workspace for the model to find. + fn place(self: *Fixture, name: []const u8, data: []const u8) !void { + const path = try std.fmt.allocPrint(self.arena(), "{s}/{s}", .{ self.root, name }); + try std.Io.Dir.cwd().writeFile(self.threaded.io(), .{ .sub_path = path, .data = data }); + } + + fn arena(self: *Fixture) std.mem.Allocator { + return self.arena_state.allocator(); + } + + fn runner(self: *Fixture, clock: Clock) Runner { + return .{ + .arena = self.arena(), + .transport = .{ + .arena = self.arena(), + .engine = &self.engine, + .sender = self.recorded.sender(), + }, + .executor = executor_mod.Runner.init(self.arena(), .{ .root = self.root }), + .engine = &self.engine, + .clock = clock, + }; + } + + fn context(_: *Fixture) policy_mod.Context { + var ids = idmod.Generator.init(11, 0); + return .{ + .actor = ids.next(idmod.ActorId), + .session = ids.next(idmod.SessionId), + .agent = ids.next(idmod.AgentId), + .now = .{ .ns = 1_700_000_000 * timeutil.ns_per_s }, + }; + } + + fn options(self: *Fixture) Options { + _ = self; + return .{ + .connector = catalog.find("ollama").?, + .model = "test-model", + .system = "Do the work.", + }; + } +}; + +fn callResponse(arena: std.mem.Allocator, name: []const u8, arguments: []const u8) !transport_mod.Response { + const body = try std.fmt.allocPrint(arena, + \\{{"model":"test-model","message":{{"role":"assistant","content":"", + \\ "tool_calls":[{{"function":{{"name":"{s}","arguments":{s}}}}}]}}, + \\ "done":true,"done_reason":"stop","prompt_eval_count":10,"eval_count":5}} + , .{ name, arguments }); + return .{ .status = 200, .body = body }; +} + +fn textResponse(arena: std.mem.Allocator, text: []const u8) !transport_mod.Response { + const body = try std.fmt.allocPrint(arena, + \\{{"model":"test-model","message":{{"role":"assistant","content":"{s}"}}, + \\ "done":true,"done_reason":"stop","prompt_eval_count":8,"eval_count":4}} + , .{text}); + return .{ .status = 200, .body = body }; +} + +const allow_models_and_reading = [_]policy_mod.Rule{ + .{ + .id = "models", + .capabilities = &.{ .@"model.infer", .@"network.connect" }, + .effect = .allow, + .reason = "Models are allowed here.", + }, + .{ + .id = "reading", + .capabilities = &.{.@"fs.read"}, + .effect = .allow, + .reason = "Reading the workspace is allowed.", + }, +}; + +test "the loop asks, runs a tool under a decision, and asks again with the result" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + + // A file for the model to read, inside the workspace. + try fixture.place("notes.txt", "twelve bytes"); + + fixture.recorded.responses = try arena.dupe(transport_mod.Response, &.{ + try callResponse(arena, "read_file", + \\{"path":"notes.txt"} + ), + try textResponse(arena, "The file says twelve bytes."), + }); + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "What is in notes.txt?", + fixture.context(), + ); + + try testing.expectEqual(Ending.answered, transcript.ending); + try testing.expectEqual(@as(usize, 2), transcript.turns.len); + try testing.expectEqual(@as(usize, 1), transcript.toolCallCount()); + try testing.expect(transcript.ranAnything()); + + const step = transcript.turns[0].steps[0]; + try testing.expectEqualStrings("read_file", step.toolName); + try testing.expect(step.request.? == .read_file); + try testing.expect(step.decision.?.isAllowed()); + try testing.expectEqual(tools.Outcome.completed, step.result.?.outcome); + try testing.expect(std.mem.indexOf(u8, step.reply, "12 bytes") != null); + + try testing.expectEqualStrings("The file says twelve bytes.", transcript.answer); + // Both turns' tokens are added up, not just the last. + try testing.expectEqual(@as(u64, 18), transcript.usage.inputTokens); +} + +test "a refused tool call is reported to the model, and nothing runs" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + // Reading is allowed; writing was never mentioned, so it is refused. + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + + fixture.recorded.responses = try arena.dupe(transport_mod.Response, &.{ + try callResponse(arena, "delete", + \\{"path":"notes.txt"} + ), + try textResponse(arena, "I could not delete it."), + }); + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "Delete notes.txt", + fixture.context(), + ); + + try testing.expectEqual(Ending.answered, transcript.ending); + const step = transcript.turns[0].steps[0]; + try testing.expect(step.refused); + try testing.expect(!step.decision.?.isAllowed()); + try testing.expectEqual(tools.Outcome.denied, step.result.?.outcome); + // The model is told not to retry, which is the difference between a refusal + // and a failure. + try testing.expect(std.mem.indexOf(u8, step.reply, "Do not try it again") != null); + // Nothing was executed: the run has a step but never a completed one. + try testing.expect(!transcript.ranAnything()); +} + +test "a tool a model invented never becomes a request" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + + fixture.recorded.responses = try arena.dupe(transport_mod.Response, &.{ + try callResponse(arena, "run_arbitrary_shell", + \\{"command":"rm -rf /"} + ), + try textResponse(arena, "That tool does not exist."), + }); + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "Clean up", + fixture.context(), + ); + + const step = transcript.turns[0].steps[0]; + try testing.expect(step.refused); + // No request, so no decision was ever asked for. A name that is not a tool + // does not reach the policy engine at all. + try testing.expect(step.request == null); + try testing.expect(step.decision == null); + try testing.expect(step.result == null); +} + +test "a model that asks for the same refused thing is stopped rather than humoured" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + + var responses: std.ArrayList(transport_mod.Response) = .empty; + var index: usize = 0; + while (index < 10) : (index += 1) { + try responses.append(arena, try callResponse(arena, "delete", + \\{"path":"notes.txt"} + )); + } + fixture.recorded.responses = responses.items; + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "Delete notes.txt, really", + fixture.context(), + ); + + try testing.expectEqual(Ending.going_in_circles, transcript.ending); + // It stopped well before the turn budget, which is the point: the loop + // noticed rather than grinding through twelve identical refusals. + try testing.expect(transcript.turns.len < 12); +} + +test "a byte limit does not make a repeated read look like a new one" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + const arena = scratch.allocator(); + + // The same file, asked for with a different limit each time. A fingerprint + // that included the limit would let a model loop for ever by counting up. + const first = try fingerprintOf(arena, .{ .read_file = .{ .path = "a.txt", .maxBytes = 100 } }); + const second = try fingerprintOf(arena, .{ .read_file = .{ .path = "a.txt", .maxBytes = 101 } }); + try testing.expect(first.eql(second)); + + const other = try fingerprintOf(arena, .{ .read_file = .{ .path = "b.txt" } }); + try testing.expect(!first.eql(other)); + + // Two different commands are different, and the same command is the same. + const build = try fingerprintOf(arena, .{ .execute = .{ + .argv = &.{ "zig", "build" }, + .workingDirectory = "/repo", + } }); + const test_run = try fingerprintOf(arena, .{ .execute = .{ + .argv = &.{ "zig", "build", "test" }, + .workingDirectory = "/repo", + } }); + try testing.expect(!build.eql(test_run)); +} + +test "the turn budget ends a model that will not stop" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + try fixture.place("notes.txt", "x"); + + // Each turn asks to read a different file, so repetition never triggers and + // only the turn count can stop it. + var responses: std.ArrayList(transport_mod.Response) = .empty; + var index: usize = 0; + while (index < 20) : (index += 1) { + const arguments = try std.fmt.allocPrint(arena, "{{\"path\":\"file-{d}.txt\"}}", .{index}); + try responses.append(arena, try callResponse(arena, "read_file", arguments)); + } + fixture.recorded.responses = responses.items; + + var options = fixture.options(); + options.budget.turns = 4; + + const transcript = try fixture.runner(stopped_clock).run( + options, + "Read everything", + fixture.context(), + ); + try testing.expectEqual(Ending.out_of_turns, transcript.ending); + try testing.expectEqual(@as(usize, 4), transcript.turns.len); +} + +test "the time budget ends a run that takes too long" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + try fixture.place("notes.txt", "x"); + + var responses: std.ArrayList(transport_mod.Response) = .empty; + var index: usize = 0; + while (index < 20) : (index += 1) { + const arguments = try std.fmt.allocPrint(arena, "{{\"path\":\"file-{d}.txt\"}}", .{index}); + try responses.append(arena, try callResponse(arena, "read_file", arguments)); + } + fixture.recorded.responses = responses.items; + + var options = fixture.options(); + options.budget.time = .{ .ns = 3 * timeutil.ns_per_s }; + + var clock: TestClock = .{}; + const transcript = try fixture.runner(clock.clock()).run( + options, + "Take your time", + fixture.context(), + ); + try testing.expectEqual(Ending.out_of_time, transcript.ending); + try testing.expect(transcript.elapsed.ns > 0); +} + +test "the token budget ends a run that costs too much" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + try fixture.place("notes.txt", "x"); + + var responses: std.ArrayList(transport_mod.Response) = .empty; + var index: usize = 0; + while (index < 20) : (index += 1) { + const arguments = try std.fmt.allocPrint(arena, "{{\"path\":\"file-{d}.txt\"}}", .{index}); + try responses.append(arena, try callResponse(arena, "read_file", arguments)); + } + fixture.recorded.responses = responses.items; + + var options = fixture.options(); + // Each turn is worth 15 tokens in this fixture, so this lands on the third. + options.budget.tokens = 40; + + const transcript = try fixture.runner(stopped_clock).run( + options, + "Read a lot", + fixture.context(), + ); + try testing.expectEqual(Ending.out_of_tokens, transcript.ending); + try testing.expect(transcript.usage.total() >= 40); +} + +test "a run stops for a person rather than deciding on their behalf" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const rules = [_]policy_mod.Rule{ + .{ + .id = "models", + .capabilities = &.{ .@"model.infer", .@"network.connect" }, + .effect = .allow, + .reason = "Models are allowed here.", + }, + .{ + .id = "deleting-asks", + .capabilities = &.{.@"fs.delete"}, + .effect = .require_human, + .reason = "Deleting a file cannot be undone, so a person decides.", + }, + }; + const fixture = try Fixture.init(&rules, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + + fixture.recorded.responses = try arena.dupe(transport_mod.Response, &.{ + try callResponse(arena, "delete", + \\{"path":"notes.txt"} + ), + }); + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "Delete notes.txt", + fixture.context(), + ); + + try testing.expectEqual(Ending.waiting_for_a_person, transcript.ending); + // The request and the decision come out, so a caller with a person to ask + // has everything it needs to ask them. + try testing.expect(transcript.pending.? == .delete); + try testing.expectEqualStrings("notes.txt", transcript.pending.?.delete.path); + try testing.expectEqual(policy_mod.Effect.require_human, transcript.pendingDecision.?.effect); + try testing.expect(!transcript.ranAnything()); +} + +test "a run the policy will not allow at all never reaches the provider" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const rules = [_]policy_mod.Rule{.{ + .id = "no-models", + .capabilities = &.{.@"model.infer"}, + .effect = .deny, + .reason = "This workspace does not use models.", + }}; + const fixture = try Fixture.init(&rules, &.{}); + defer fixture.deinit(); + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "Anything at all", + fixture.context(), + ); + + try testing.expectEqual(Ending.stopped_by_policy, transcript.ending); + try testing.expectEqual(@as(usize, 0), fixture.recorded.seen.items.len); + // The turn is still on the record, with the decision that stopped it. + try testing.expectEqual(@as(usize, 1), transcript.turns.len); + try testing.expectEqual(@as(usize, 1), transcript.turns[0].attempt.decisions.len); +} + +test "a summary reads as sentences and counts what happened" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + const fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + try fixture.place("notes.txt", "twelve bytes"); + + fixture.recorded.responses = try arena.dupe(transport_mod.Response, &.{ + try callResponse(arena, "read_file", + \\{"path":"notes.txt"} + ), + try callResponse(arena, "delete", + \\{"path":"notes.txt"} + ), + try textResponse(arena, "Read it; could not delete it."), + }); + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "Read then delete", + fixture.context(), + ); + + var buffer: [1024]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try transcript.writeSummary(&w); + const text = w.buffered(); + + try testing.expect(std.mem.indexOf(u8, text, "The model finished.") != null); + try testing.expect(std.mem.indexOf(u8, text, "3 turns") != null); + try testing.expect(std.mem.indexOf(u8, text, "2 tool calls") != null); + try testing.expect(std.mem.indexOf(u8, text, "1 of them refused") != null); + try testing.expect(std.mem.indexOf(u8, text, "tokens") != null); +} diff --git a/src/ai/toolschema.zig b/src/ai/toolschema.zig new file mode 100644 index 0000000..f940454 --- /dev/null +++ b/src/ai/toolschema.zig @@ -0,0 +1,495 @@ +//! What a model is told it can do, and how what it asks for becomes typed. +//! +//! This file is the narrowest point in the whole system. Everything a model +//! wants to happen arrives here as a name and a blob of JSON, and leaves as a +//! `tools.ToolRequest` or as nothing at all. There is no third outcome and no +//! passthrough: a name that is not in the table is refused, and a request whose +//! arguments do not fit the type is refused, before any policy is consulted and +//! long before anything runs. +//! +//! Two properties are worth stating plainly, because they are the reason this +//! file exists rather than the parsing being done wherever a tool call lands. +//! +//! **The declarations are generated from the Zig types.** The schema a model +//! sees is written by `jsonschema.writeInline` from the same struct the +//! executor takes. A field added to `ExecuteRequest` appears in the schema on +//! the next build. There is no second copy of the shape to fall out of step +//! with the first, and a test asserts every field of every request type appears +//! in what is sent. +//! +//! **A model cannot name a capability.** The capability comes from the request +//! kind, through `ToolRequest.capability()`, which this file does not touch. A +//! model that writes `"capability": "fs.write"` into its arguments is writing a +//! field that does not exist, and the request is refused for that reason. This +//! is the property that makes prompt injection a nuisance here rather than an +//! escalation: text can make a model *ask* for anything, and asking is all it +//! can do. +//! +//! Two request kinds are deliberately absent from what a model is offered. +//! `infer` would let a model spend the workspace's credential on a request +//! nobody read, and `spawn_agent` would let it widen its own reach by creating +//! something to act for it. Both exist as typed requests, for a person to make. + +const std = @import("std"); +const tools = @import("tools.zig"); +const provider = @import("provider.zig"); +const jsonschema = @import("../interop/jsonschema.zig"); +const hashing = @import("../core/hash.zig"); +const timeutil = @import("../core/time.zig"); + +pub const ToolRequest = tools.ToolRequest; + +pub const Error = error{ + /// The model named a tool that this build does not offer. + UnknownTool, + /// The arguments were not JSON, or not an object. + MalformedArguments, + /// A field the request needs was missing, or was the wrong type. + ArgumentsDoNotFit, + /// The tool exists as a typed request but is not offered to a model. + NotOfferedToModels, +} || std.mem.Allocator.Error; + +/// What a person is told when a tool call does not become a request. +pub fn refusalText(err: Error) []const u8 { + return switch (err) { + error.UnknownTool => "The model asked for a tool this build does not have.", + error.MalformedArguments => "The model's tool call was not readable, so nothing ran.", + error.ArgumentsDoNotFit => "The model's tool call was missing something it needs, so nothing ran.", + error.NotOfferedToModels => "That tool is for a person to use, not a model.", + error.OutOfMemory => "There was not enough memory to read the tool call.", + }; +} + +/// One offered tool: the name a model uses and the type behind it. +pub const Offer = struct { + name: []const u8, + /// Written for the model, in the same plain words a person would get. + description: []const u8, + /// The `ToolRequest` variant this becomes. + kind: std.meta.Tag(ToolRequest), +}; + +/// Every tool a model may ask for, in the order a person would read them. +/// +/// `infer` and `spawn_agent` are not here, and their absence is enforced by a +/// test rather than left to whoever edits this list next. +pub const offers = [_]Offer{ + .{ + .name = "read_file", + .kind = .read_file, + .description = "Read a file in the workspace. The path is relative to the workspace, and a path that leaves it is refused.", + }, + .{ + .name = "write_file", + .kind = .write_file, + .description = "Write bytes that are already in the content store to a file in the workspace. Give the hash of the content, not the content: the bytes that land are the bytes that were reviewed.", + }, + .{ + .name = "run_command", + .kind = .execute, + .description = "Run a program. Give the program and its arguments already separated. There is no shell, so a semicolon or a redirect is an argument and not an instruction.", + }, + .{ + .name = "delete", + .kind = .delete, + .description = "Delete a file or directory in the workspace. This cannot be undone from the record, so it usually asks a person first.", + }, + .{ + .name = "search", + .kind = .search, + .description = "Search the workspace for text, or search the recorded history of earlier work.", + }, + .{ + .name = "git", + .kind = .git, + .description = "Read or change the repository: status, log, diff, branch, commit, push, or add a worktree. Each one is decided separately.", + }, + .{ + .name = "call_mcp_tool", + .kind = .mcp, + .description = "Call a tool on a Model Context Protocol server that this workspace is connected to.", + }, +}; + +/// The type behind one offer, as a comptime value. +/// +/// This is what keeps the schema and the executor from drifting: both come from +/// the same type, chosen here once. +pub fn Payload(comptime kind: std.meta.Tag(ToolRequest)) type { + for (@typeInfo(ToolRequest).@"union".fields) |field| { + if (std.mem.eql(u8, field.name, @tagName(kind))) return field.type; + } + @compileError("no payload for " ++ @tagName(kind)); +} + +pub fn find(name: []const u8) ?Offer { + for (offers) |offer| { + if (std.mem.eql(u8, offer.name, name)) return offer; + } + return null; +} + +/// The name a model uses for a request kind, when there is one. +pub fn nameOf(kind: std.meta.Tag(ToolRequest)) ?[]const u8 { + for (offers) |offer| { + if (offer.kind == kind) return offer.name; + } + return null; +} + +/// The declarations to put on the wire. +/// +/// Every schema is written inline, with nothing referenced, because a provider +/// has no document to resolve a reference against. +pub fn declarations(arena: std.mem.Allocator) ![]const provider.Tool { + var out: std.ArrayList(provider.Tool) = .empty; + inline for (offers) |offer| { + var schema: std.Io.Writer.Allocating = .init(arena); + try jsonschema.writeInline(Payload(offer.kind), &schema.writer); + try out.append(arena, .{ + .name = offer.name, + .description = offer.description, + .schemaJson = schema.written(), + // A provider that can hold a model to the schema should. It costs + // nothing here and removes a whole class of malformed call. + .strict = true, + }); + } + return out.items; +} + +/// Turn one tool call into a typed request, or refuse it. +/// +/// Nothing here trusts the name to match the arguments. The name selects the +/// type; the arguments must then fit that type or the call is refused. +pub fn parse( + arena: std.mem.Allocator, + name: []const u8, + argumentsJson: []const u8, +) Error!ToolRequest { + const offer = find(name) orelse { + // Named but withheld reads differently from never existing, and a + // person reading the record deserves the difference. + if (std.mem.eql(u8, name, "infer") or std.mem.eql(u8, name, "spawn_agent")) { + return error.NotOfferedToModels; + } + return error.UnknownTool; + }; + + const text = if (argumentsJson.len == 0) "{}" else argumentsJson; + const parsed = std.json.parseFromSlice(std.json.Value, arena, text, .{}) catch { + return error.MalformedArguments; + }; + const object = switch (parsed.value) { + .object => |o| o, + else => return error.MalformedArguments, + }; + + switch (offer.kind) { + .read_file => return .{ .read_file = .{ + .path = try requiredString(object, "path"), + .maxBytes = optionalUsize(object, "maxBytes") orelse 1 << 20, + } }, + .write_file => return .{ .write_file = .{ + .path = try requiredString(object, "path"), + .contentHash = hashing.Hash.parse(try requiredString(object, "contentHash")) catch { + return error.ArgumentsDoNotFit; + }, + .byteCount = optionalUsize(object, "byteCount") orelse 0, + .createIfMissing = optionalBool(object, "createIfMissing") orelse true, + } }, + .execute => { + const argv = try stringArray(arena, object, "argv"); + if (argv.len == 0) return error.ArgumentsDoNotFit; + return .{ .execute = .{ + .argv = argv, + .workingDirectory = try requiredString(object, "workingDirectory"), + .environmentPolicy = optionalEnum(tools.EnvironmentPolicy, object, "environmentPolicy") orelse .none, + .environment = stringArray(arena, object, "environment") catch &.{}, + .network = optionalEnum(tools.NetworkPolicy, object, "network") orelse .none, + .timeout = .{ .ns = @as(i64, @intCast(optionalUsize(object, "timeoutSeconds") orelse 120)) * timeutil.ns_per_s }, + .approval = optionalEnum(tools.ApprovalPolicy, object, "approval") orelse .policy_only, + } }; + }, + .delete => return .{ .delete = .{ + .path = try requiredString(object, "path"), + .recursive = optionalBool(object, "recursive") orelse false, + } }, + .search => return .{ .search = .{ + .query = try requiredString(object, "query"), + .root = try requiredString(object, "root"), + .maxResults = optionalUsize(object, "maxResults") orelse 100, + .includeHistory = optionalBool(object, "includeHistory") orelse false, + } }, + .git => return .{ .git = .{ + .operation = optionalEnum(tools.GitOperation, object, "operation") orelse + return error.ArgumentsDoNotFit, + .repository = try requiredString(object, "repository"), + .argument = optionalString(object, "argument") orelse "", + } }, + .mcp => return .{ .mcp = .{ + .server = try requiredString(object, "server"), + .tool = try requiredString(object, "tool"), + .argumentsJson = try rawField(arena, object, "argumentsJson"), + } }, + .infer, .spawn_agent => return error.NotOfferedToModels, + } +} + +fn requiredString(object: std.json.ObjectMap, field: []const u8) Error![]const u8 { + const value = object.get(field) orelse return error.ArgumentsDoNotFit; + return switch (value) { + .string => |s| s, + else => error.ArgumentsDoNotFit, + }; +} + +fn optionalString(object: std.json.ObjectMap, field: []const u8) ?[]const u8 { + const value = object.get(field) orelse return null; + return switch (value) { + .string => |s| s, + else => null, + }; +} + +fn optionalBool(object: std.json.ObjectMap, field: []const u8) ?bool { + const value = object.get(field) orelse return null; + return switch (value) { + .bool => |b| b, + else => null, + }; +} + +fn optionalUsize(object: std.json.ObjectMap, field: []const u8) ?usize { + const value = object.get(field) orelse return null; + return switch (value) { + .integer => |i| if (i < 0) null else @intCast(i), + else => null, + }; +} + +fn optionalEnum(comptime T: type, object: std.json.ObjectMap, field: []const u8) ?T { + const text = optionalString(object, field) orelse return null; + return std.meta.stringToEnum(T, text); +} + +fn stringArray( + arena: std.mem.Allocator, + object: std.json.ObjectMap, + field: []const u8, +) Error![]const []const u8 { + const value = object.get(field) orelse return error.ArgumentsDoNotFit; + const items = switch (value) { + .array => |a| a.items, + else => return error.ArgumentsDoNotFit, + }; + var out: std.ArrayList([]const u8) = .empty; + for (items) |item| { + switch (item) { + .string => |s| try out.append(arena, s), + else => return error.ArgumentsDoNotFit, + } + } + return out.items; +} + +/// A field that is itself JSON, kept as text. +/// +/// A model writing arguments for another tool may send them as an object or as +/// a string containing an object. Both are accepted, and both come back as the +/// text that will be forwarded, because the server on the other side is the one +/// that decides what its own arguments mean. +fn rawField( + arena: std.mem.Allocator, + object: std.json.ObjectMap, + field: []const u8, +) Error![]const u8 { + const value = object.get(field) orelse return "{}"; + if (value == .string) return value.string; + var out: std.Io.Writer.Allocating = .init(arena); + std.json.Stringify.value(value, .{}, &out.writer) catch return error.MalformedArguments; + return out.written(); +} + +/// Write a tool result the way a model reads one. +/// +/// A result is a short piece of text, not a structure, because that is what +/// every provider carries. What matters is that a refusal says so in words the +/// model can act on: "you may not" and "it did not work" lead to different next +/// moves, and a model that cannot tell them apart will retry the one it should +/// not. +pub fn resultText( + arena: std.mem.Allocator, + result: tools.ToolResult, +) ![]const u8 { + const lead = switch (result.outcome) { + .completed => "Done", + .failed => "It did not work", + .denied => "The policy did not allow this. Do not try it again; ask the person instead", + .cancelled => "This was stopped before it finished", + .timed_out => "This ran out of time and was stopped", + }; + if (result.summary.len == 0) return lead; + return std.fmt.allocPrint(arena, "{s}. {s}", .{ lead, result.summary }); +} + +const testing = std.testing; + +test "the declarations carry every field of the type behind them" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const declared = try declarations(arena); + try testing.expectEqual(offers.len, declared.len); + + inline for (offers, 0..) |offer, index| { + const tool = declared[index]; + try testing.expectEqualStrings(offer.name, tool.name); + try testing.expect(tool.description.len > 0); + + // Valid JSON, and an object schema. + const parsed = try std.json.parseFromSlice(std.json.Value, arena, tool.schemaJson, .{}); + try testing.expect(parsed.value == .object); + try testing.expectEqualStrings("object", parsed.value.object.get("type").?.string); + + // Nothing is referenced: a provider has no document to resolve against. + try testing.expect(std.mem.indexOf(u8, tool.schemaJson, "$ref") == null); + + // Every field of the Zig type is in the schema, so a field added to the + // executor's request cannot go unmentioned to the model. + const properties = parsed.value.object.get("properties").?.object; + inline for (std.meta.fields(Payload(offer.kind))) |field| { + if (comptime std.mem.eql(u8, field.name, "timeout")) continue; + try testing.expect(properties.contains(field.name)); + } + } +} + +test "a model is never offered the tools that would widen its own reach" { + for (offers) |offer| { + try testing.expect(offer.kind != .infer); + try testing.expect(offer.kind != .spawn_agent); + } + try testing.expect(nameOf(.infer) == null); + try testing.expect(nameOf(.spawn_agent) == null); + + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Asking for one by name is refused, and the refusal says which kind of no + // it is: withheld, not missing. + try testing.expectError(error.NotOfferedToModels, parse(arena, "infer", + \\{"provider":"anthropic","model":"claude-opus-5"} + )); + try testing.expectError(error.NotOfferedToModels, parse(arena, "spawn_agent", "{}")); + try testing.expectError(error.UnknownTool, parse(arena, "sudo", "{}")); +} + +test "a tool call becomes a typed request, and the capability comes from the kind" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const request = try parse(arena, "run_command", + \\{"argv":["zig","build","test"],"workingDirectory":"/home/user/zag"} + ); + try testing.expectEqual(tools.Capability.@"process.execute", request.capability()); + try testing.expectEqual(@as(usize, 3), request.execute.argv.len); + try testing.expectEqualStrings("zig", request.execute.argv[0]); + // Nothing was said about the network, so it gets none. + try testing.expectEqual(tools.NetworkPolicy.none, request.execute.network); + + const read = try parse(arena, "read_file", + \\{"path":"README.md"} + ); + try testing.expectEqual(tools.Capability.@"fs.read", read.capability()); + try testing.expectEqual(@as(usize, 1 << 20), read.read_file.maxBytes); +} + +test "a model cannot name its own capability" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The model tries to say what this is allowed to do. The field does not + // exist, so it is read as a read of that path and nothing more. + const request = try parse(arena, "read_file", + \\{"path":"README.md","capability":"fs.write","allow":true,"policy":"none"} + ); + try testing.expectEqual(tools.Capability.@"fs.read", request.capability()); + try testing.expectEqualStrings("README.md", request.read_file.path); +} + +test "arguments that do not fit are refused before anything is decided" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try testing.expectError(error.ArgumentsDoNotFit, parse(arena, "read_file", "{}")); + try testing.expectError(error.ArgumentsDoNotFit, parse(arena, "read_file", + \\{"path":42} + )); + // A command with no program to run is not a command. + try testing.expectError(error.ArgumentsDoNotFit, parse(arena, "run_command", + \\{"argv":[],"workingDirectory":"/tmp"} + )); + // An argument vector must be strings, not a nested structure. + try testing.expectError(error.ArgumentsDoNotFit, parse(arena, "run_command", + \\{"argv":["sh",{"x":1}],"workingDirectory":"/tmp"} + )); + try testing.expectError(error.MalformedArguments, parse(arena, "read_file", "not json")); + try testing.expectError(error.MalformedArguments, parse(arena, "read_file", "[1,2,3]")); + // A hash that is not a hash. + try testing.expectError(error.ArgumentsDoNotFit, parse(arena, "write_file", + \\{"path":"a.txt","contentHash":"nonsense"} + )); +} + +test "an unknown enum value falls to the safe default rather than being obeyed" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const request = try parse(arena, "run_command", + \\{"argv":["curl"],"workingDirectory":"/tmp","network":"everything","environmentPolicy":"give_me_it_all"} + ); + try testing.expectEqual(tools.NetworkPolicy.none, request.execute.network); + try testing.expectEqual(tools.EnvironmentPolicy.none, request.execute.environmentPolicy); +} + +test "another server's arguments pass through as text, in either shape" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const as_object = try parse(arena, "call_mcp_tool", + \\{"server":"docs","tool":"lookup","argumentsJson":{"term":"block"}} + ); + try testing.expect(std.mem.indexOf(u8, as_object.mcp.argumentsJson, "\"term\"") != null); + + const as_text = try parse(arena, "call_mcp_tool", + \\{"server":"docs","tool":"lookup","argumentsJson":"{\"term\":\"block\"}"} + ); + try testing.expect(std.mem.indexOf(u8, as_text.mcp.argumentsJson, "\"term\"") != null); +} + +test "a refusal tells the model which kind of no it was" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const denied = try resultText(arena, .{ .outcome = .denied, .summary = "The policy did not allow this." }); + try testing.expect(std.mem.indexOf(u8, denied, "Do not try it again") != null); + + const failed = try resultText(arena, .{ .outcome = .failed, .summary = "No such file." }); + try testing.expect(std.mem.indexOf(u8, failed, "did not work") != null); + // A failure invites another try; a refusal does not. They must not read the + // same, or a model will retry the one it should leave alone. + try testing.expect(std.mem.indexOf(u8, failed, "Do not try it again") == null); + + const done = try resultText(arena, .{ .outcome = .completed, .summary = "Read 12 bytes from a.txt." }); + try testing.expect(std.mem.startsWith(u8, done, "Done.")); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index eed3738..29b24f2 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -41,6 +41,7 @@ const Command = enum { providers, policy, ask, + why, fn parse(text: []const u8) ?Command { const table = [_]struct { name: []const u8, command: Command }{ @@ -76,6 +77,7 @@ const Command = enum { .{ .name = "providers", .command = .providers }, .{ .name = "policy", .command = .policy }, .{ .name = "ask", .command = .ask }, + .{ .name = "why", .command = .why }, }; for (table) |entry| { if (std.mem.eql(u8, entry.name, text)) return entry.command; @@ -117,7 +119,8 @@ pub const help_text = \\ term Open a shell in a terminal that records what you do. \\ providers List the model connectors and say which credentials are set. \\ policy Show the policy in force, and where it was read from. - \\ ask Ask a model, through the policy. Use --provider to choose one. + \\ ask Ask a model, through the policy. It can use tools, one decision each. + \\ why [n] Show what an event depended on, and what it went on to affect. \\ \\Options \\ --profile Use this conformance profile. The default is "default". @@ -129,6 +132,7 @@ pub const help_text = \\ --root Read the workspace in this directory. \\ --provider Which connector to use. Run "zag providers" to see them. \\ --model Which model to ask for. + \\ --turns How many times a model may be asked in one run. \\ --raw Start the shell with no added prompt marks. \\ --approve-truncate Apply the recovery plan after preserving the original log. \\ @@ -149,6 +153,7 @@ const Options = struct { approve_truncate: bool = false, provider: []const u8 = "", model: []const u8 = "", + turns: []const u8 = "", positional: []const []const u8 = &.{}, /// Everything after `--`. passthrough: []const []const u8 = &.{}, @@ -196,6 +201,7 @@ fn parseOptions(arena: std.mem.Allocator, args: []const []const u8) !Options { .{ .flag = "--root", .field = &options.root }, .{ .flag = "--provider", .field = &options.provider }, .{ .flag = "--model", .field = &options.model }, + .{ .flag = "--turns", .field = &options.turns }, }; var matched = false; for (named) |entry| { @@ -322,6 +328,7 @@ fn run( .providers => try listProviders(arena, io, w, options, environment), .policy => try showPolicy(arena, io, w, options), .ask => try askAModel(arena, io, w, options, environment), + .why => try whyDidThatHappen(arena, io, w, options), }; } @@ -354,6 +361,10 @@ fn doctor(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Opti zag.ai.catalog.count(), zag.ai.catalog.localCount(), }); + try w.print(" Agent loop, decided at each step: yes, {d} tools offered to a model\n", .{ + zag.ai.toolschema.offers.len, + }); + try w.writeAll(" Explain a failure from the record: yes, with zag why\n"); try w.writeAll(" Plain-language checks: yes\n"); try w.writeAll(" Terminology and metadata registry: yes\n"); try w.writeAll(" Standards registry and evidence: yes\n"); @@ -363,7 +374,7 @@ fn doctor(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Opti try w.writeAll(" Draw its own window. The renderer is not written; the accessibility tree it must publish is.\n"); try w.writeAll(" Talk to a language server. The editor surfaces are not written.\n"); try w.writeAll(" Read an answer as it arrives. A model request waits for the whole reply.\n"); - try w.writeAll(" Run a model's tool calls on its own. The executors are written; nothing loops them yet.\n"); + try w.writeAll(" Run more than one tool call at a time. A turn does them one after another.\n"); try w.writeAll(" Run on Windows or macOS terminals. The pseudoterminal layer is Linux only so far.\n"); try w.writeAll(" Split the screen. There are no tabs, panes or splits yet.\n"); try w.writeAll(" Be configured beyond the policy file. There are no key bindings and no theme file yet.\n\n"); @@ -1186,32 +1197,243 @@ fn askAModel( .credentials = view.credentials(), }; - var attempt: zag.ai.transport.Attempt = undefined; - const completion = transport.send(connector, .{ + // The loop, not a single question. A model that can read the workspace and + // run the tests is worth more than one that can only answer, and every one + // of those actions is a separate decision the policy makes. + const executor: zag.ai.executor.Runner = .init(arena, .{ + .root = options.root, + .io = io, + }); + const runner: zag.ai.loop.Runner = .{ + .arena = arena, + .transport = transport, + .executor = executor, + .engine = &engine, + .clock = monotonic(io), + }; + + var budget: zag.ai.loop.Budget = .{}; + if (options.turns.len > 0) { + budget.turns = std.fmt.parseInt(usize, options.turns, 10) catch { + try w.print("\"{s}\" is not a number of turns.\n", .{options.turns}); + return 2; + }; + } + + const transcript = try runner.run(.{ + .connector = connector, .model = if (options.model.len > 0) options.model else defaultModel(connector), - .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = question.items }} }}, - }, context, &attempt) catch |err| { - try w.print("{s}\n", .{zag.ai.transport.refusalText(err)}); - if (attempt.sendProblem.len > 0) try w.print("{s}\n", .{attempt.sendProblem}); - if (attempt.status) |status| { - try w.print("{s} answered {d}", .{ connector.name, status }); - if (attempt.providerError.len > 0) { - try w.print(": {s}", .{attempt.providerError}); - } + .system = agent_instructions, + .budget = budget, + }, question.items, context); + + if (transcript.answer.len > 0) try w.print("{s}\n\n", .{transcript.answer}); + + for (transcript.turns) |turn| { + for (turn.steps) |step| { + try w.print(" {s} {s}\n", .{ + if (step.ran()) "ran" else "did not run:", + step.toolName, + }); + if (!step.ran()) try w.print(" {s}\n", .{step.reply}); + } + } + if (transcript.toolCallCount() > 0) try w.writeAll("\n"); + + try transcript.writeSummary(w); + + if (transcript.pending) |request| { + try w.print("\nIt is waiting for you to allow: {s} on {s}.\n", .{ + request.capability().explain(), + zag.ai.executor.resourceOf(request).text(), + }); + try w.writeAll("Add a rule for it to the workspace policy, then run this again.\n"); + } + + // The decisions from the last model request, which is where a refusal that + // stopped the run will be. + if (transcript.turns.len > 0) { + const last = transcript.turns[transcript.turns.len - 1]; + if (last.attempt.refusedAt != null or !transcript.ending.succeeded()) { try w.writeAll("\n"); + if (last.attempt.sendProblem.len > 0) try w.print("{s}\n", .{last.attempt.sendProblem}); + if (last.attempt.providerError.len > 0) { + try w.print("{s} answered {d}: {s}\n", .{ + connector.name, + last.attempt.status orelse 0, + last.attempt.providerError, + }); + } + try writeDecisions(last.attempt, w); } - try w.writeAll("\n"); - try writeDecisions(attempt, w); + } + + return if (transcript.ending.succeeded()) 0 else 1; +} + +/// Show what one recorded event depended on, and what it went on to affect. +/// +/// The causal chain answers "what led to this". The dependency graph answers +/// the harder question: which earlier work this rests on through the files that +/// passed between them, even when nothing caused anything and the two happened +/// in different sessions hours apart. +fn whyDidThatHappen( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, +) !u8 { + if (options.positional.len == 0) { + try w.writeAll("Name the log file to read. For example: zag why .workspace/events.jsonl\n"); + return 2; + } + const path = options.positional[0]; + const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(256 << 20)) catch { + try w.print("zag could not read {s}.\n", .{path}); return 1; }; + const loaded = try zag.events.log.Log.loadJsonLines(arena, source, 1); + if (loaded.log.entries.items.len == 0) { + try w.writeAll("That log has no events in it.\n"); + return 1; + } + + // By default, the last thing that went wrong. Somebody running "why" has + // usually just watched something fail, and the last event in a log is + // almost always a session closing, which explains nothing. When nothing + // went wrong, the last event is the right answer. + var about: usize = lastFailure(loaded.log) orelse loaded.log.entries.items.len - 1; + if (options.positional.len > 1) { + about = std.fmt.parseInt(usize, options.positional[1], 10) catch { + try w.print("\"{s}\" is not an event number.\n", .{options.positional[1]}); + return 2; + }; + if (about >= loaded.log.entries.items.len) { + try w.print("There are {d} events, so {d} is not one of them.\n", .{ + loaded.log.entries.items.len, + about, + }); + return 2; + } + } + + const dag = try zag.events.lineage.Dag.build(arena, loaded.log); + if (!dag.isTopological()) { + try w.writeAll("This log has an event that comes before its own cause, so the graph cannot be trusted.\n"); + try w.writeAll("Run \"zag events\" on it: a log in that state has been changed since it was written.\n"); + return 1; + } + + try w.writeAll("What you asked about\n "); + try zag.ai.attention.writeEvent(loaded.log.entries.items[about], w); + try w.writeAll("\n\n"); + + const selection = try zag.ai.attention.select(arena, dag, about, .{ + .includeAffected = true, + .recent = 0, + }); + + var wrote_any = false; + inline for (.{ + .{ zag.ai.attention.Reason.depended_on, "What it depended on" }, + .{ zag.ai.attention.Reason.affected_by, "What it went on to affect" }, + }) |section| { + var first = true; + for (selection.chosen) |item| { + if (item.reason != section[0]) continue; + if (first) { + try w.print("{s}\n", .{section[1]}); + first = false; + wrote_any = true; + } + try w.print(" {d} step", .{item.distance}); + if (item.distance != 1) try w.writeAll("s"); + try w.print(" away, event {d}: ", .{item.index}); + try zag.ai.attention.writeEvent(item.envelope, w); + try w.writeAll("\n"); + } + if (!first) try w.writeAll("\n"); + } + if (!wrote_any) { + try w.writeAll("Nothing in this log connects to it, forwards or backwards.\n\n"); + } - const answer = try completion.text(arena); - if (answer.len > 0) try w.print("{s}\n\n", .{answer}); - try w.print("{s}\n\n", .{try attempt.summary(arena)}); - try writeDecisions(attempt, w); + const path_through = try dag.criticalPath(); + const cost = dag.criticalPathCost(path_through.items); + if (cost.ns > 0) { + try w.print("The longest chain in this log runs through {d} events and takes ", .{ + path_through.items.len, + }); + try zag.reports.notation.writeDuration(w, cost); + try w.writeAll(".\n"); + try w.writeAll("That is what decided how long the work took, not the sum of everything in it.\n"); + } return 0; } +/// The most recent event that reports something not working. +fn lastFailure(log: zag.events.log.Log) ?usize { + var index = log.entries.items.len; + while (index > 0) { + index -= 1; + switch (log.entries.items[index].payload) { + .command_finished => |e| if (e.exitStatus != 0) return index, + .diagnostic => |e| if (e.severity == .@"error") return index, + .tool_finished => |e| if (e.outcome != .completed) return index, + else => {}, + } + } + return null; +} + +/// What the model is told before it starts. +/// +/// This is prompt text. It shapes behaviour and grants nothing: every sentence +/// in it describes a boundary that is enforced elsewhere, so a model that +/// ignores all of it can still do only what the policy allows. It is written +/// anyway, because a model that knows the shape of the boundary wastes fewer +/// turns discovering it. +const agent_instructions = + \\You are working in a recorded workspace, through a policy that decides + \\every action separately. + \\ + \\Each tool call is decided on its own. A refusal is final for that call: + \\if you are told the policy did not allow something, do not ask for it + \\again. Say what you needed and why, and let the person decide. + \\ + \\Paths are inside the workspace. A path that leaves it is refused by the + \\operating system, not by a check you can talk your way around. + \\ + \\Commands are argument vectors. There is no shell, so a pipe, a semicolon + \\or a redirect is an argument and not an instruction. Run one program at a + \\time. + \\ + \\Work in small steps and say what you found. When you are finished, answer + \\in plain words. + \\ +; + +/// A monotonic clock for the run's time budget. +/// +/// Monotonic rather than wall clock, so that the system time changing under a +/// long run cannot cut it short or let it go on. +fn monotonic(io: std.Io) zag.ai.loop.Clock { + const Reader = struct { + io: std.Io, + fn read(context: *anyopaque) i64 { + const self: *@This() = @ptrCast(@alignCast(context)); + const raw = std.Io.Timestamp.now(self.io, .awake); + return @intCast(raw.nanoseconds); + } + }; + // The reader holds only the I/O handle, which outlives the run. + const holder = struct { + var state: Reader = undefined; + }; + holder.state = .{ .io = io }; + return .{ .context = &holder.state, .nowFn = Reader.read }; +} + /// The model a connector answers with when the person did not name one. /// /// There is no right answer here, so the rule is to name something that @@ -1354,6 +1576,55 @@ fn agentsFile(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: return 0; } +/// Join an argument vector back into a command line, without losing it. +/// +/// The workspace service runs a command through a shell, because that is what +/// produces the boundary marks a block is built from. So the vector a person +/// typed after `--` has to become one line. Joining it with spaces is what a +/// person reaches for, and it is wrong: `zag run -- sh -c 'exit 3'` becomes +/// `sh -c exit 3`, which runs `exit` with `3` as its name and reports success +/// for a command that failed. A workbench whose record says a failed command +/// worked is worse than no record. +/// +/// So each argument that contains anything a shell would act on is wrapped in +/// single quotes, and an embedded single quote is closed, escaped and reopened +/// — the one form that survives every POSIX shell. An argument of plain word +/// characters is left alone, so the recorded command line still reads the way +/// the person typed it. +pub fn quoteArgv(arena: std.mem.Allocator, argv: []const []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + for (argv, 0..) |argument, index| { + if (index > 0) try out.append(arena, ' '); + if (needsQuoting(argument)) { + try out.append(arena, '\''); + for (argument) |byte| { + if (byte == '\'') { + try out.appendSlice(arena, "'\\''"); + } else { + try out.append(arena, byte); + } + } + try out.append(arena, '\''); + } else { + try out.appendSlice(arena, argument); + } + } + return out.items; +} + +fn needsQuoting(argument: []const u8) bool { + if (argument.len == 0) return true; + for (argument) |byte| { + const safe = (byte >= 'a' and byte <= 'z') or + (byte >= 'A' and byte <= 'Z') or + (byte >= '0' and byte <= '9') or + byte == '_' or byte == '-' or byte == '.' or byte == '/' or + byte == ':' or byte == '=' or byte == '@' or byte == ',' or byte == '+'; + if (!safe) return true; + } + return false; +} + fn runCommand(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { if (options.passthrough.len == 0) { try w.writeAll("Put the command after two dashes. For example: zag run -- echo hello\n"); @@ -1383,7 +1654,7 @@ fn runCommand(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: return 1; } - const command_text = try std.mem.join(arena, " ", options.passthrough); + const command_text = try quoteArgv(arena, options.passthrough); const result = service.runCommand(command_text, zag.core.time.Duration.fromSeconds(120)) catch |err| switch (err) { error.ContentTooLarge => { try w.writeAll("The command produced more than 64 MiB of captured output. Redirect large output to a file, then run the command again.\n"); @@ -2549,8 +2820,12 @@ test "asking a model refuses before it sends, and says what the policy decided" try testing.expectEqual(@as(u8, 1), status); const text = w.buffered(); - try testing.expect(std.mem.indexOf(u8, text, "policy does not allow") != null); + // The run ended because the policy refused, and the reason is the one the + // policy gave, not one this command invented. + try testing.expect(std.mem.indexOf(u8, text, "The policy refused something the model needed") != null); try testing.expect(std.mem.indexOf(u8, text, "What the policy decided") != null); + // Nothing ran, so there are no tool calls to report. + try testing.expect(std.mem.indexOf(u8, text, "0 tool calls") != null); // Using a model was allowed; reaching that host was not, and the two are // shown separately because they are separate answers. try testing.expect(std.mem.indexOf(u8, text, "Allowed to send this work to a model provider") != null); @@ -2585,3 +2860,88 @@ test "every connector this build ships resolves and can be named on the command try testing.expect(defaultModel(found).len > 0 or connector.locality == .hosted); } } + +test "an argument vector survives being turned back into a command line" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The case that was wrong: joining with spaces turned this into + // "sh -c exit 3", which runs `exit` with `3` as its name, exits zero, and + // was recorded as a command that succeeded. + try testing.expectEqualStrings( + "sh -c 'exit 3'", + try quoteArgv(arena, &.{ "sh", "-c", "exit 3" }), + ); + + // Plain words are left alone, so the recorded line still reads the way it + // was typed. + try testing.expectEqualStrings( + "zig build test", + try quoteArgv(arena, &.{ "zig", "build", "test" }), + ); + try testing.expectEqualStrings( + "git commit -m 'a message with spaces'", + try quoteArgv(arena, &.{ "git", "commit", "-m", "a message with spaces" }), + ); + + // A single quote inside an argument is the case that breaks naive quoting. + try testing.expectEqualStrings( + "echo 'it'\\''s here'", + try quoteArgv(arena, &.{ "echo", "it's here" }), + ); + + // An empty argument is a real argument and must not vanish. + try testing.expectEqualStrings("echo '' x", try quoteArgv(arena, &.{ "echo", "", "x" })); + + // Everything a shell would act on is quoted. + for ([_][]const u8{ ";", "|", "&", "$x", "`x`", ">out", " |e| { + try testing.expectEqual(@as(u8, 3), e.exitStatus); + saw_failure = true; + }, + else => {}, + } + } + try testing.expect(saw_failure); +} diff --git a/src/core/scan.zig b/src/core/scan.zig new file mode 100644 index 0000000..41aa040 --- /dev/null +++ b/src/core/scan.zig @@ -0,0 +1,246 @@ +//! Byte scans, a vector at a time. +//! +//! Two loops in this program walk bytes one at a time and are the whole cost of +//! the paths they sit in: finding the line breaks in a document, and finding +//! where a run of ordinary printable text ends in a terminal stream. Both ask +//! the same shape of question — "where is the first byte that is interesting?" +//! — and both answer it a byte at a time, which on any machine built this +//! century wastes most of the register. +//! +//! `std.mem.indexOfScalar` is already vectorised, so anything that is a search +//! for one known byte should use it and this file does not duplicate it. What +//! is here is the two things the standard library has no shape for: +//! +//! * **Counting** every occurrence, rather than finding the first. Counting +//! newlines before allocating means a line index allocates once, at exactly +//! the right size, instead of growing a dozen times. +//! * **A range predicate.** "The first byte that is not printable ASCII" is +//! two comparisons and an OR per vector, and there is no standard function +//! that takes a predicate rather than a set of bytes. +//! +//! ## How this is kept honest +//! +//! Every function here has a scalar twin, kept beside it and never deleted, and +//! a test that runs both over pseudo-random bytes and every alignment and +//! length near a vector boundary, asserting they agree. That is the only way to +//! test this kind of code: a vectorised scan that is wrong is usually wrong +//! only in the tail, only at one length, and passes every example a person +//! thinks to write by hand. +//! +//! The vector width comes from `std.simd.suggestVectorLength`. On a target with +//! no vector registers it is null, the code takes the scalar path, and the +//! tests still pass — which is the point of keeping the twin. + +const std = @import("std"); + +/// How many bytes are compared at once. Null on a target without vectors. +pub const width: ?usize = if (std.simd.suggestVectorLength(u8)) |lanes| @as(usize, lanes) else null; + +/// Count how many times `needle` appears. +/// +/// Used before allocating, so a caller can size an array exactly rather than +/// growing it. One pass to count and one to fill beats a dozen reallocations. +pub fn countScalarBytes(haystack: []const u8, needle: u8) usize { + const lanes = width orelse return countScalarBytesSlow(haystack, needle); + + const Chunk = @Vector(lanes, u8); + const splat: Chunk = @splat(needle); + var total: usize = 0; + var index: usize = 0; + + while (index + lanes <= haystack.len) : (index += lanes) { + const chunk: Chunk = haystack[index..][0..lanes].*; + // A match is a lane of all ones. Summing the booleans as ones counts + // them without ever branching on where they are. + const matches: @Vector(lanes, u1) = @bitCast(@intFromBool(chunk == splat)); + total += @reduce(.Add, @as(@Vector(lanes, u8), matches)); + } + return total + countScalarBytesSlow(haystack[index..], needle); +} + +/// The plain version, kept for the tail, for targets without vectors, and as +/// the thing the vectorised version is checked against. +pub fn countScalarBytesSlow(haystack: []const u8, needle: u8) usize { + var total: usize = 0; + for (haystack) |byte| { + if (byte == needle) total += 1; + } + return total; +} + +/// The first byte that is not ordinary printable ASCII: below a space, or +/// `0x7f` and above. +/// +/// This is where a run of plain text ends in a terminal stream. Everything +/// before the answer can be printed without consulting a state machine, and +/// everything from the answer needs one. +/// +/// Returns null when the whole slice is printable. +pub fn indexOfNonPrintable(haystack: []const u8) ?usize { + const lanes = width orelse return indexOfNonPrintableSlow(haystack); + + const Chunk = @Vector(lanes, u8); + const space: Chunk = @splat(0x20); + const del: Chunk = @splat(0x7f); + var index: usize = 0; + + while (index + lanes <= haystack.len) : (index += lanes) { + const chunk: Chunk = haystack[index..][0..lanes].*; + // Two comparisons and an OR. A byte is interesting if it is a control + // character or if the high bit region begins. + const interesting = (chunk < space) | (chunk >= del); + if (@reduce(.Or, interesting)) { + // Something in this vector. Which lane is a scalar question and it + // is asked at most once per call. + for (haystack[index..][0..lanes], 0..) |byte, lane| { + if (byte < 0x20 or byte >= 0x7f) return index + lane; + } + } + } + if (indexOfNonPrintableSlow(haystack[index..])) |found| return index + found; + return null; +} + +pub fn indexOfNonPrintableSlow(haystack: []const u8) ?usize { + for (haystack, 0..) |byte, index| { + if (byte < 0x20 or byte >= 0x7f) return index; + } + return null; +} + +/// Fill `into` with the offset just after each `needle`, plus a leading zero. +/// +/// This is what a line index is: the start of every line. It is here rather +/// than in the caller so that the counting pass and the filling pass use the +/// same definition of a line break and cannot disagree about how many there +/// are. +/// +/// `into` must have room for `countScalarBytes(haystack, needle) + 1`. +pub fn fillStarts(haystack: []const u8, needle: u8, into: []usize) usize { + into[0] = 0; + var written: usize = 1; + var index: usize = 0; + while (std.mem.indexOfScalarPos(u8, haystack, index, needle)) |found| { + if (written == into.len) break; + into[written] = found + 1; + written += 1; + index = found + 1; + } + return written; +} + +const testing = std.testing; + +/// Bytes that exercise the boundaries: plenty of printable text, the odd +/// control character, the odd byte with the high bit set. +fn sample(buffer: []u8, seed: u64) void { + var random = std.Random.DefaultPrng.init(seed); + const r = random.random(); + for (buffer) |*byte| { + byte.* = switch (r.uintLessThan(u8, 10)) { + 0 => r.uintLessThan(u8, 0x20), + 1 => 0x80 | r.uintLessThan(u8, 0x80), + 2 => '\n', + else => 0x20 + r.uintLessThan(u8, 0x5f), + }; + } +} + +test "counting agrees with the plain version at every length near a boundary" { + const lanes = width orelse 16; + var buffer: [512]u8 = undefined; + + // Every length from nothing to four vectors, so a tail of every possible + // size is covered rather than one arbitrary size. + var length: usize = 0; + while (length <= @min(buffer.len, lanes * 4)) : (length += 1) { + sample(buffer[0..length], length + 1); + const fast = countScalarBytes(buffer[0..length], '\n'); + const slow = countScalarBytesSlow(buffer[0..length], '\n'); + try testing.expectEqual(slow, fast); + } +} + +test "the non-printable scan agrees with the plain version, at every offset" { + const lanes = width orelse 16; + var buffer: [512]u8 = undefined; + + var length: usize = 0; + while (length <= @min(buffer.len, lanes * 4)) : (length += 1) { + sample(buffer[0..length], length + 7); + try testing.expectEqual( + indexOfNonPrintableSlow(buffer[0..length]), + indexOfNonPrintable(buffer[0..length]), + ); + } + + // And the case the random data almost never produces: one interesting byte, + // walked through every position of a long printable run. A vectorised scan + // that gets the lane arithmetic wrong passes on random data and fails here. + var plain: [200]u8 = undefined; + @memset(&plain, 'x'); + for (0..plain.len) |position| { + plain[position] = 0x1b; + try testing.expectEqual(@as(?usize, position), indexOfNonPrintable(&plain)); + plain[position] = 'x'; + } + try testing.expect(indexOfNonPrintable(&plain) == null); +} + +test "a high byte ends a printable run, because it starts a character" { + // The first byte of a multi-byte character is not printable ASCII, and the + // fast path must hand it back to the decoder rather than printing it. + const text = "hello wörld"; + const found = indexOfNonPrintable(text).?; + try testing.expectEqual(@as(usize, 7), found); + try testing.expectEqual(@as(u8, 0xc3), text[found]); + + // `0x7f` is delete, not something to print. + try testing.expectEqual(@as(?usize, 2), indexOfNonPrintable("ab\x7fcd")); + // A tab is a control character, whatever it looks like. + try testing.expectEqual(@as(?usize, 2), indexOfNonPrintable("ab\tcd")); + try testing.expect(indexOfNonPrintable("plain ascii text") == null); + try testing.expect(indexOfNonPrintable("") == null); +} + +test "line starts come out right, and there is one more of them than newlines" { + const text = "one\ntwo\nthree"; + const count = countScalarBytes(text, '\n'); + try testing.expectEqual(@as(usize, 2), count); + + var starts: [3]usize = undefined; + const written = fillStarts(text, '\n', &starts); + try testing.expectEqual(@as(usize, 3), written); + try testing.expectEqual(@as(usize, 0), starts[0]); + try testing.expectEqual(@as(usize, 4), starts[1]); + try testing.expectEqual(@as(usize, 8), starts[2]); + + // A trailing newline opens a line that has nothing in it yet, which is the + // right answer: an offset at the very end belongs to that line. + const trailing = "a\n"; + var two: [2]usize = undefined; + try testing.expectEqual(@as(usize, 2), fillStarts(trailing, '\n', &two)); + try testing.expectEqual(@as(usize, 2), two[1]); + + // Nothing at all is still one line. + var one: [1]usize = undefined; + try testing.expectEqual(@as(usize, 1), fillStarts("", '\n', &one)); +} + +test "counting and filling agree, on text that is mostly newlines" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var buffer: [4096]u8 = undefined; + sample(&buffer, 99); + + const count = countScalarBytes(&buffer, '\n'); + const starts = try arena.alloc(usize, count + 1); + const written = fillStarts(&buffer, '\n', starts); + + // The count sized the array, so the fill must use exactly all of it. If + // these two ever disagree, one of them is walking the bytes differently. + try testing.expectEqual(count + 1, written); + try testing.expectEqual(starts.len, written); +} diff --git a/src/events/graph.zig b/src/events/graph.zig index be721a8..4cbf2f9 100644 --- a/src/events/graph.zig +++ b/src/events/graph.zig @@ -57,6 +57,10 @@ pub const Summary = struct { pub const Graph = struct { arena: std.mem.Allocator, nodes: []Node, + /// Where each event identifier sits. Kept so that looking one up is a hash + /// rather than a walk: a report that resolves a hundred identifiers over a + /// hundred thousand events should not cost ten million comparisons. + index_of: std.AutoArrayHashMapUnmanaged([16]u8, usize) = .empty, pub fn build(arena: std.mem.Allocator, log: log_mod.Log) !Graph { const entries = log.entries.items; @@ -78,22 +82,37 @@ pub const Graph = struct { } for (child_lists, 0..) |list, i| nodes[i].children = list.items; - // Depth, computed by walking up. Cycles are impossible because a cause - // always precedes its effect in the log. + // Depth, in one pass. + // + // A cause is appended before its effect, so a parent always sits at a + // lower index than its child and its depth is already final by the time + // the child is reached. Walking up from each node instead would cost + // the depth of the tree for every node, which on one long agent run — + // the exact case this exists for — is the square of its length. for (nodes, 0..) |*node, i| { - var depth: usize = 0; - var current = node.parent; - var guard: usize = 0; - while (current) |p| : (guard += 1) { - if (guard > entries.len) break; - depth += 1; - current = nodes[p].parent; - } - node.depth = depth; - _ = i; + const parent = node.parent orelse { + node.depth = 0; + continue; + }; + node.depth = if (parent < i) nodes[parent].depth + 1 else 0; } - return .{ .arena = arena, .nodes = nodes }; + return .{ .arena = arena, .nodes = nodes, .index_of = index_of }; + } + + /// True when every cause sits before its effect. + /// + /// This is the property the whole file rests on: it makes log order a + /// topological order, so nothing here has to sort, and it makes the depth + /// pass above correct. It holds because an event cannot be caused by one + /// that has not happened. It is checked rather than assumed, because a + /// merged or repaired log is exactly where it would stop holding. + pub fn causesPrecedeEffects(self: Graph) bool { + for (self.nodes, 0..) |node, i| { + const parent = node.parent orelse continue; + if (parent >= i) return false; + } + return true; } pub fn roots(self: Graph) !std.ArrayList(usize) { @@ -105,10 +124,40 @@ pub const Graph = struct { } pub fn indexOf(self: Graph, id: EventId) ?usize { - for (self.nodes, 0..) |node, i| { - if (node.envelope.id.eql(id)) return i; + return self.index_of.get(id.raw.bytes); + } + + /// The chain of causes above one event, from the root down to it. + /// + /// This is the answer to "why did this happen": each step is the event that + /// caused the next. It reads forwards, because that is the order a person + /// asking the question wants to read it in. + pub fn ancestry(self: Graph, index: usize) !std.ArrayList(usize) { + var upwards: std.ArrayList(usize) = .empty; + var current: ?usize = index; + var guard: usize = 0; + while (current) |i| : (guard += 1) { + if (guard > self.nodes.len) break; + try upwards.append(self.arena, i); + current = self.nodes[i].parent; + } + std.mem.reverse(usize, upwards.items); + return upwards; + } + + /// True when `cause` is somewhere above `effect`. + /// + /// Costs the depth of the tree, not a search of it, because there is only + /// ever one way up. + pub fn causedBy(self: Graph, effect: usize, cause: usize) bool { + var current = self.nodes[effect].parent; + var guard: usize = 0; + while (current) |i| : (guard += 1) { + if (guard > self.nodes.len) return false; + if (i == cause) return true; + current = self.nodes[i].parent; } - return null; + return false; } /// Every event caused by this one, directly or indirectly. diff --git a/src/events/lineage.zig b/src/events/lineage.zig new file mode 100644 index 0000000..3a24a66 --- /dev/null +++ b/src/events/lineage.zig @@ -0,0 +1,811 @@ +//! What depended on what, derived from the record rather than declared. +//! +//! The causal graph in `graph.zig` answers "what did this event lead to". It is +//! a forest: each event has at most one cause, so every question about it is a +//! walk up or down one chain. That is the right shape for "why did this happen", +//! and it is the wrong shape for the question a person actually asks after a +//! failure, which is: +//! +//! > Which of the things I did could this be about? +//! +//! Answering that needs edges the log does not write down. A command that reads +//! a file depends on whoever wrote that file last, even though neither event +//! caused the other and they may sit in different sessions hours apart. Adding +//! those edges turns the forest into a directed acyclic graph, and the graph is +//! what makes impact analysis and blame possible over recorded work. +//! +//! ## Where the edges come from +//! +//! Three kinds, all derived, none declared: +//! +//! * **Causation.** The `causedBy` link already in the log. +//! * **Data flow.** A file is written, then read or opened: the reader +//! depends on the last writer *before it*. Only the last one, because an +//! earlier write was already replaced and depending on it would be false. +//! * **Sequence within a block.** Output and a finish belong to the command +//! that produced them. +//! +//! ## Why log order is a topological order +//! +//! Every edge points from an earlier index to a later one. Causation does, +//! because a cause is appended before its effect. Data flow does, because the +//! writer is chosen from the writes that came before the read. So no sort is +//! needed anywhere in this file, no cycle is possible, and a single forward +//! pass computes longest paths. That invariant is checked, not assumed: +//! `isTopological` is the property every algorithm here rests on, and a merged +//! or repaired log is exactly where it would stop holding. +//! +//! ## Reachability +//! +//! "Did anything I changed this morning reach the test that failed?" is a +//! reachability question over that DAG. The answer is computed with bitsets, a +//! row per node, filled in one backwards pass: a node reaches whatever its +//! successors reach, and the union is a word-at-a-time OR. That costs +//! `nodes * edges / 64` words of work instead of a search per question, which +//! is what makes asking it about every node at once affordable. +//! +//! The memory is the honest limit: a row is one bit per node, so the whole +//! closure is `n²/8` bytes and a hundred thousand events would want more than a +//! gigabyte. So it is built for a chosen set of events — one run, one session, +//! one day — and `Reach.build` refuses a set larger than it can hold rather +//! than trying and failing partway. + +const std = @import("std"); +const event_mod = @import("event.zig"); +const graph_mod = @import("graph.zig"); +const log_mod = @import("log.zig"); +const timeutil = @import("../core/time.zig"); + +pub const Envelope = event_mod.Envelope; +pub const EventId = event_mod.EventId; + +pub const EdgeKind = enum { + /// The log's own `causedBy` link. + caused, + /// One event wrote a file, a later one read it. + data, + /// Output and completion belong to the command they came from. + sequence, + + pub fn text(self: EdgeKind) []const u8 { + return switch (self) { + .caused => "led to", + .data => "was read by", + .sequence => "belongs to", + }; + } +}; + +pub const Edge = struct { + from: usize, + to: usize, + kind: EdgeKind, + /// The file, when the edge is data flow. Empty otherwise. + through: []const u8 = "", +}; + +pub const Node = struct { + envelope: Envelope, + /// Edges out of this node, as indices into `edges`. + out: []const usize = &.{}, + /// Edges into this node. + in: []const usize = &.{}, + /// How long this event itself took, where that is known. Used for the + /// critical path, and zero for an event that is an instant. + cost: timeutil.Duration = .{ .ns = 0 }, +}; + +pub const Dag = struct { + arena: std.mem.Allocator, + nodes: []Node, + edges: []const Edge, + + /// Derive the graph from a log. + /// + /// One forward pass. A map from path to "who wrote it last" is carried + /// along, which is what makes data-flow edges linear rather than a search + /// back through the log for every read. + pub fn build(arena: std.mem.Allocator, log: log_mod.Log) !Dag { + const entries = log.entries.items; + var nodes = try arena.alloc(Node, entries.len); + for (entries, 0..) |entry, i| { + nodes[i] = .{ .envelope = entry, .cost = costOf(entry) }; + } + + var index_of: std.AutoArrayHashMapUnmanaged([16]u8, usize) = .empty; + for (entries, 0..) |entry, i| try index_of.put(arena, entry.id.raw.bytes, i); + + // The last writer of each path, and the event that opened each block. + var last_writer: std.StringArrayHashMapUnmanaged(usize) = .empty; + var block_start: std.AutoArrayHashMapUnmanaged([16]u8, usize) = .empty; + + var edges: std.ArrayList(Edge) = .empty; + + for (entries, 0..) |entry, i| { + if (entry.causedBy) |cause| { + if (index_of.get(cause.raw.bytes)) |from| { + if (from < i) try edges.append(arena, .{ .from = from, .to = i, .kind = .caused }); + } + } + + switch (entry.payload) { + .command_submitted => |e| try block_start.put(arena, e.block.raw.bytes, i), + .process_output => |e| { + if (block_start.get(e.block.raw.bytes)) |from| { + if (from < i) try edges.append(arena, .{ .from = from, .to = i, .kind = .sequence }); + } + }, + .command_finished => |e| { + if (block_start.get(e.block.raw.bytes)) |from| { + if (from < i) try edges.append(arena, .{ .from = from, .to = i, .kind = .sequence }); + } + }, + .file_changed => |e| { + // A read of the previous contents, then a new writer. The + // order matters: a change that reads and then replaces + // depends on what was there, and becomes what the next + // reader depends on. + if (last_writer.get(e.path)) |from| { + if (from < i) try edges.append(arena, .{ + .from = from, + .to = i, + .kind = .data, + .through = e.path, + }); + } + if (e.changeKind != .deleted) { + try last_writer.put(arena, e.path, i); + } else { + _ = last_writer.swapRemove(e.path); + } + }, + .file_opened => |e| { + if (last_writer.get(e.path)) |from| { + if (from < i) try edges.append(arena, .{ + .from = from, + .to = i, + .kind = .data, + .through = e.path, + }); + } + }, + .diagnostic => |e| { + // A diagnostic is about a file, so it depends on whoever + // last wrote it. This is what connects a compiler error to + // the change that caused it. + if (last_writer.get(e.path)) |from| { + if (from < i) try edges.append(arena, .{ + .from = from, + .to = i, + .kind = .data, + .through = e.path, + }); + } + }, + else => {}, + } + } + + // Fill the adjacency lists in two counting passes, so the whole build + // is linear and allocates once per direction rather than per node. + var out_counts = try arena.alloc(usize, entries.len); + var in_counts = try arena.alloc(usize, entries.len); + @memset(out_counts, 0); + @memset(in_counts, 0); + for (edges.items) |edge| { + out_counts[edge.from] += 1; + in_counts[edge.to] += 1; + } + + const out_storage = try arena.alloc(usize, edges.items.len); + const in_storage = try arena.alloc(usize, edges.items.len); + var out_at = try arena.alloc(usize, entries.len); + var in_at = try arena.alloc(usize, entries.len); + var running_out: usize = 0; + var running_in: usize = 0; + for (0..entries.len) |i| { + out_at[i] = running_out; + in_at[i] = running_in; + running_out += out_counts[i]; + running_in += in_counts[i]; + } + var out_filled = try arena.alloc(usize, entries.len); + var in_filled = try arena.alloc(usize, entries.len); + @memset(out_filled, 0); + @memset(in_filled, 0); + for (edges.items, 0..) |edge, e| { + out_storage[out_at[edge.from] + out_filled[edge.from]] = e; + out_filled[edge.from] += 1; + in_storage[in_at[edge.to] + in_filled[edge.to]] = e; + in_filled[edge.to] += 1; + } + for (0..entries.len) |i| { + nodes[i].out = out_storage[out_at[i] .. out_at[i] + out_counts[i]]; + nodes[i].in = in_storage[in_at[i] .. in_at[i] + in_counts[i]]; + } + + return .{ .arena = arena, .nodes = nodes, .edges = edges.items }; + } + + /// Every edge points forwards. See the note at the top of this file: it is + /// the property everything else here depends on. + pub fn isTopological(self: Dag) bool { + for (self.edges) |edge| { + if (edge.from >= edge.to) return false; + } + return true; + } + + /// What this event went on to affect, directly or not. + /// + /// The answer to "I changed this; what did it touch?". + pub fn affects(self: Dag, index: usize) !std.ArrayList(usize) { + return self.walk(index, .forwards); + } + + /// What this event depended on, directly or not. + /// + /// The answer to "this failed; what was it built on?". + pub fn dependsOn(self: Dag, index: usize) !std.ArrayList(usize) { + return self.walk(index, .backwards); + } + + const Direction = enum { forwards, backwards }; + + fn walk(self: Dag, start: usize, direction: Direction) !std.ArrayList(usize) { + var seen = try std.DynamicBitSetUnmanaged.initEmpty(self.arena, self.nodes.len); + var out: std.ArrayList(usize) = .empty; + var stack: std.ArrayList(usize) = .empty; + try stack.append(self.arena, start); + seen.set(start); + while (stack.pop()) |current| { + const list = switch (direction) { + .forwards => self.nodes[current].out, + .backwards => self.nodes[current].in, + }; + for (list) |e| { + const next = switch (direction) { + .forwards => self.edges[e].to, + .backwards => self.edges[e].from, + }; + if (seen.isSet(next)) continue; + seen.set(next); + try out.append(self.arena, next); + try stack.append(self.arena, next); + } + } + std.mem.sort(usize, out.items, {}, std.sort.asc(usize)); + return out; + } + + /// The path through the run that actually determined how long it took. + /// + /// Longest path by cost, in one backwards pass. Because every edge points + /// forwards, a node's longest remaining path is its own cost plus the best + /// of its successors, and every successor is already final by the time the + /// node is reached. No search, no memo table beyond one array. + /// + /// This is the honest answer to "why was that slow": not the slowest single + /// step, but the chain that nothing else could overtake. + pub fn criticalPath(self: Dag) !std.ArrayList(usize) { + if (self.nodes.len == 0) return .empty; + + const best = try self.arena.alloc(i64, self.nodes.len); + const next = try self.arena.alloc(?usize, self.nodes.len); + + var i: usize = self.nodes.len; + while (i > 0) { + i -= 1; + var longest: i64 = 0; + var chosen: ?usize = null; + for (self.nodes[i].out) |e| { + const to = self.edges[e].to; + if (best[to] > longest) { + longest = best[to]; + chosen = to; + } + } + best[i] = self.nodes[i].cost.ns + longest; + next[i] = chosen; + } + + var start: usize = 0; + for (best, 0..) |value, index| { + if (value > best[start]) start = index; + } + + var path: std.ArrayList(usize) = .empty; + var current: ?usize = start; + while (current) |index| { + try path.append(self.arena, index); + current = next[index]; + } + return path; + } + + /// How long the critical path takes. + pub fn criticalPathCost(self: Dag, path: []const usize) timeutil.Duration { + var total: i64 = 0; + for (path) |index| total += self.nodes[index].cost.ns; + return .{ .ns = total }; + } + + /// Say, in words, why one event and another are connected. + pub fn writeConnection( + self: Dag, + from: usize, + to: usize, + w: *std.Io.Writer, + ) std.Io.Writer.Error!void { + for (self.nodes[from].out) |e| { + const edge = self.edges[e]; + if (edge.to != to) continue; + try w.print("{s} {s} {s}", .{ + self.nodes[from].envelope.payload.kindName(), + edge.kind.text(), + self.nodes[to].envelope.payload.kindName(), + }); + if (edge.through.len > 0) try w.print(", through {s}", .{edge.through}); + return; + } + try w.writeAll("nothing connects these two directly"); + } +}; + +/// How long an event itself took, where the record says. +fn costOf(entry: Envelope) timeutil.Duration { + return switch (entry.payload) { + .command_finished => |e| e.duration, + .tool_finished => |e| e.duration, + else => .{ .ns = 0 }, + }; +} + +/// Reachability for a chosen set of events, as bitsets. +/// +/// One row per event, one bit per event: row `a` bit `b` is set when `a` can +/// reach `b`. Filled in one backwards pass, taking the union of each +/// successor's row, which the standard library does a machine word at a time. +/// +/// Built for a subset rather than a whole log, because the memory is the square +/// of the count. `build` refuses a set it cannot hold rather than trying. +pub const Reach = struct { + /// The events this was built for, in log order. + members: []const usize, + /// Row per member, in the same order. + rows: []std.DynamicBitSetUnmanaged, + /// Where each event index sits in `members`. + position: std.AutoArrayHashMapUnmanaged(usize, usize), + + /// The largest set this will build a closure for. + /// + /// At this size the rows come to about 128 MiB, which is a lot but is a + /// number somebody chose rather than one the machine discovered by running + /// out. A larger question is answered by `Dag.affects`, one walk at a time. + pub const max_members = 32_768; + + pub const Error = error{TooManyEvents} || std.mem.Allocator.Error; + + pub fn build(arena: std.mem.Allocator, dag: Dag, members: []const usize) Error!Reach { + if (members.len > max_members) return error.TooManyEvents; + + var position: std.AutoArrayHashMapUnmanaged(usize, usize) = .empty; + for (members, 0..) |member, slot| try position.put(arena, member, slot); + + const rows = try arena.alloc(std.DynamicBitSetUnmanaged, members.len); + for (rows) |*row| row.* = try std.DynamicBitSetUnmanaged.initEmpty(arena, members.len); + + // Backwards, so every successor's row is already complete. `members` is + // in log order and every edge points forwards, which is the whole + // reason this is one pass and not a fixed point. + var slot: usize = members.len; + while (slot > 0) { + slot -= 1; + const node = members[slot]; + for (dag.nodes[node].out) |e| { + const to = dag.edges[e].to; + const to_slot = position.get(to) orelse continue; + rows[slot].set(to_slot); + rows[slot].setUnion(rows[to_slot]); + } + } + + return .{ .members = members, .rows = rows, .position = position }; + } + + /// Can `from` reach `to`? + pub fn reaches(self: Reach, from: usize, to: usize) bool { + const a = self.position.get(from) orelse return false; + const b = self.position.get(to) orelse return false; + return self.rows[a].isSet(b); + } + + /// Neither one reaches the other: two pieces of work that could have + /// happened in either order. + pub fn concurrent(self: Reach, a: usize, b: usize) bool { + if (a == b) return false; + return !self.reaches(a, b) and !self.reaches(b, a); + } + + /// How many events this one reaches. The blast radius of a change. + pub fn reachCount(self: Reach, from: usize) usize { + const slot = self.position.get(from) orelse return 0; + return self.rows[slot].count(); + } +}; + +const testing = std.testing; +const idmod = @import("../core/id.zig"); + +/// A log built by hand, so a test can put events in a chosen relationship. +const Builder = struct { + arena: std.mem.Allocator, + log: log_mod.Log, + ids: idmod.Generator, + at: i64 = 1_700_000_000 * timeutil.ns_per_s, + + fn init(arena: std.mem.Allocator) Builder { + return .{ + .arena = arena, + .log = log_mod.Log.init(arena, 1), + .ids = idmod.Generator.init(2, 0), + }; + } + + fn append( + self: *Builder, + payload: event_mod.WorkspaceEvent, + caused_by: ?EventId, + ) !EventId { + self.at += timeutil.ns_per_s; + const envelope = try self.log.append(payload, .{ + .at = .{ .ns = self.at }, + .actor = .{ + .id = self.ids.next(idmod.ActorId), + .kind = .person, + .label = "you", + }, + .causedBy = caused_by, + }); + return envelope.id; + } + + fn session(self: *Builder) idmod.SessionId { + return self.ids.next(idmod.SessionId); + } +}; + +test "a read depends on the last write of that file, and nothing earlier" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.session(); + + // Two writes to one file, then a read. The read depends on the second + // write only: the first was already replaced. + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "src/a.zig", + .changeKind = .created, + } }, null); + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "src/a.zig", + .changeKind = .modified, + } }, null); + _ = try builder.append(.{ .file_opened = .{ + .session = session, + .path = "src/a.zig", + .contentHash = .zero, + } }, null); + + const dag = try Dag.build(arena, builder.log); + try testing.expect(dag.isTopological()); + + // The reader depends on the second write, and reaches back to the first + // through it, but has no direct edge to the first. + const depends = try dag.dependsOn(2); + try testing.expectEqual(@as(usize, 2), depends.items.len); + try testing.expectEqual(@as(usize, 0), depends.items[0]); + try testing.expectEqual(@as(usize, 1), depends.items[1]); + + var direct: usize = 0; + for (dag.nodes[2].in) |e| { + try testing.expectEqual(@as(usize, 1), dag.edges[e].from); + direct += 1; + } + try testing.expectEqual(@as(usize, 1), direct); +} + +test "a file that was deleted is not something a later read depends on" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.session(); + + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "gone.txt", + .changeKind = .created, + } }, null); + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "gone.txt", + .changeKind = .deleted, + } }, null); + _ = try builder.append(.{ .file_opened = .{ + .session = session, + .path = "gone.txt", + .contentHash = .zero, + } }, null); + + const dag = try Dag.build(arena, builder.log); + // The delete depends on the create. The read that follows depends on + // nothing, because there is nothing there to depend on: whatever it read, + // this record does not say it came from here. + try testing.expectEqual(@as(usize, 0), dag.nodes[2].in.len); + try testing.expectEqual(@as(usize, 1), dag.nodes[1].in.len); +} + +test "a diagnostic points back at the change that produced the file" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.session(); + + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "src/broken.zig", + .changeKind = .modified, + } }, null); + _ = try builder.append(.{ .diagnostic = .{ + .session = session, + .path = "src/broken.zig", + .line = 12, + .column = 3, + .severity = .@"error", + .source = "zig", + .message = "expected type", + } }, null); + + const dag = try Dag.build(arena, builder.log); + const blame = try dag.dependsOn(1); + try testing.expectEqual(@as(usize, 1), blame.items.len); + try testing.expectEqual(@as(usize, 0), blame.items[0]); + + var buffer: [256]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try dag.writeConnection(0, 1, &w); + try testing.expect(std.mem.indexOf(u8, w.buffered(), "through src/broken.zig") != null); +} + +test "work in two sessions is joined by the file that passed between them" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const morning = builder.session(); + const afternoon = builder.session(); + + // Nothing causes anything here: two separate sessions, hours apart. The + // only thing joining them is the file, which is exactly the case the + // causal forest cannot answer and this graph can. + _ = try builder.append(.{ .file_changed = .{ + .session = morning, + .path = "src/parser.zig", + .changeKind = .modified, + } }, null); + _ = try builder.append(.{ .file_opened = .{ + .session = afternoon, + .path = "src/parser.zig", + .contentHash = .zero, + } }, null); + + const dag = try Dag.build(arena, builder.log); + const affected = try dag.affects(0); + try testing.expectEqual(@as(usize, 1), affected.items.len); + try testing.expectEqual(@as(usize, 1), affected.items[0]); + + // The causal graph, correctly, sees no relationship at all. The two graphs + // answer different questions and neither replaces the other. + const causal = try graph_mod.Graph.build(arena, builder.log); + try testing.expect(!causal.causedBy(1, 0)); +} + +test "output and completion belong to the command that produced them" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.session(); + const block = builder.ids.next(idmod.BlockId); + + _ = try builder.append(.{ .command_submitted = .{ + .block = block, + .session = session, + .commandText = "zig build test", + .workingDirectory = "/repo", + } }, null); + _ = try builder.append(.{ .process_output = .{ + .block = block, + .session = session, + .contentHash = .zero, + .byteCount = 40, + } }, null); + _ = try builder.append(.{ .command_finished = .{ + .block = block, + .session = session, + .exitStatus = 1, + .duration = .{ .ns = 4 * timeutil.ns_per_s }, + } }, null); + + const dag = try Dag.build(arena, builder.log); + const affected = try dag.affects(0); + try testing.expectEqual(@as(usize, 2), affected.items.len); +} + +test "the critical path is the chain nothing could overtake" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.session(); + + // One command that leads to a slow one, and a fast one alongside it that + // is not on the path even though it happened in between. + const first = builder.ids.next(idmod.BlockId); + const slow = builder.ids.next(idmod.BlockId); + const quick = builder.ids.next(idmod.BlockId); + + const start = try builder.append(.{ .command_submitted = .{ + .block = first, + .session = session, + .commandText = "generate", + .workingDirectory = "/repo", + } }, null); + _ = try builder.append(.{ .command_finished = .{ + .block = first, + .session = session, + .exitStatus = 0, + .duration = .{ .ns = 1 * timeutil.ns_per_s }, + } }, null); + _ = try builder.append(.{ .command_submitted = .{ + .block = quick, + .session = session, + .commandText = "fmt", + .workingDirectory = "/repo", + } }, start); + _ = try builder.append(.{ .command_finished = .{ + .block = quick, + .session = session, + .exitStatus = 0, + .duration = .{ .ns = 2 * timeutil.ns_per_s }, + } }, null); + _ = try builder.append(.{ .command_submitted = .{ + .block = slow, + .session = session, + .commandText = "build", + .workingDirectory = "/repo", + } }, start); + _ = try builder.append(.{ .command_finished = .{ + .block = slow, + .session = session, + .exitStatus = 0, + .duration = .{ .ns = 30 * timeutil.ns_per_s }, + } }, null); + + const dag = try Dag.build(arena, builder.log); + const path = try dag.criticalPath(); + const cost = dag.criticalPathCost(path.items); + + // Thirty seconds, not thirty-three. The first command's own one second and + // the two seconds of formatting are branches off the start, not steps + // before the build, so nothing waited for them. That is the difference + // between a critical path and a total, and it is the reason to compute one. + try testing.expectEqual(@as(i64, 30 * timeutil.ns_per_s), cost.ns); + try testing.expectEqual(@as(usize, 3), path.items.len); + try testing.expectEqual(@as(usize, 0), path.items[0]); + try testing.expectEqual(@as(usize, 4), path.items[1]); + try testing.expectEqual(@as(usize, 5), path.items[2]); + + // The formatting command is not on it, even though it took two seconds and + // happened in the middle of the run. + for (path.items) |index| try testing.expect(index != 3); +} + +test "reachability answers about every pair at once, and says what is concurrent" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.session(); + + // a.zig is changed, then read. b.zig is changed separately and never read. + // The two lines of work are concurrent: neither reaches the other. + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "a.zig", + .changeKind = .modified, + } }, null); + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "b.zig", + .changeKind = .modified, + } }, null); + _ = try builder.append(.{ .file_opened = .{ + .session = session, + .path = "a.zig", + .contentHash = .zero, + } }, null); + + const dag = try Dag.build(arena, builder.log); + const members = try arena.dupe(usize, &.{ 0, 1, 2 }); + const reach = try Reach.build(arena, dag, members); + + try testing.expect(reach.reaches(0, 2)); + try testing.expect(!reach.reaches(2, 0)); + try testing.expect(reach.concurrent(1, 0)); + try testing.expect(reach.concurrent(1, 2)); + try testing.expectEqual(@as(usize, 1), reach.reachCount(0)); + try testing.expectEqual(@as(usize, 0), reach.reachCount(1)); +} + +test "reachability refuses a set too large to hold rather than trying" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const builder = Builder.init(arena); + const dag = try Dag.build(arena, builder.log); + + const members = try arena.alloc(usize, Reach.max_members + 1); + @memset(members, 0); + try testing.expectError(error.TooManyEvents, Reach.build(arena, dag, members)); +} + +test "reachability agrees with walking, on a chain long enough to tell" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var builder = Builder.init(arena); + const session = builder.session(); + + // A chain of a hundred writes and reads of the same file. Every event + // reaches every later one. + var i: usize = 0; + while (i < 100) : (i += 1) { + _ = try builder.append(.{ .file_changed = .{ + .session = session, + .path = "chain.txt", + .changeKind = .modified, + } }, null); + } + + const dag = try Dag.build(arena, builder.log); + try testing.expect(dag.isTopological()); + + const members = try arena.alloc(usize, 100); + for (members, 0..) |*member, index| member.* = index; + const reach = try Reach.build(arena, dag, members); + + // The two ways of asking must give the same answer, or one of them is + // wrong and there is no way to tell which from either alone. + const walked = try dag.affects(0); + try testing.expectEqual(@as(usize, 99), walked.items.len); + try testing.expectEqual(@as(usize, 99), reach.reachCount(0)); + for (walked.items) |target| { + try testing.expect(reach.reaches(0, target)); + } + try testing.expectEqual(@as(usize, 0), reach.reachCount(99)); +} diff --git a/src/interop/jsonschema.zig b/src/interop/jsonschema.zig index 9731da3..30e76d2 100644 --- a/src/interop/jsonschema.zig +++ b/src/interop/jsonschema.zig @@ -146,11 +146,30 @@ pub const defs_prefix = "#/$defs/"; /// `ref_prefix` selects where those references point, so the same generator /// serves standalone schema files (`#/$defs/`) and OpenAPI components. pub fn writeType(comptime T: type, w: *std.Io.Writer, comptime ref_prefix: []const u8) std.Io.Writer.Error!void { + return writeTypeMode(T, w, ref_prefix); +} + +/// Write a schema with nothing referenced: every nested type is written where +/// it is used. +/// +/// A model provider takes one self-contained schema for each tool. It has no +/// document to resolve a `$ref` against, so a schema that references anything +/// arrives with holes in it. This writes the same shape with the references +/// expanded. +/// +/// A type that contains itself would expand forever. Nothing in this build does, +/// and the compiler's branch quota stops a mistake at compile time rather than +/// at run time. +pub fn writeInline(comptime T: type, w: *std.Io.Writer) std.Io.Writer.Error!void { + return writeTypeMode(T, w, null); +} + +fn writeTypeMode(comptime T: type, w: *std.Io.Writer, comptime ref_prefix: ?[]const u8) std.Io.Writer.Error!void { @setEvalBranchQuota(200_000); switch (@typeInfo(T)) { .optional => |o| { try w.writeAll("{\"anyOf\":["); - try writeType(o.child, w, ref_prefix); + try writeTypeMode(o.child, w, ref_prefix); try w.writeAll(",{\"type\":\"null\"}]}"); }, .bool => try w.writeAll("{\"type\":\"boolean\"}"), @@ -249,10 +268,12 @@ pub fn writeType(comptime T: type, w: *std.Io.Writer, comptime ref_prefix: []con } } -fn writeRef(comptime T: type, w: *std.Io.Writer, comptime ref_prefix: []const u8) std.Io.Writer.Error!void { +fn writeRef(comptime T: type, w: *std.Io.Writer, comptime ref_prefix: ?[]const u8) std.Io.Writer.Error!void { + if (comptime ref_prefix == null) return writeTypeMode(T, w, null); + const prefix: []const u8 = comptime ref_prefix.?; const U = Unwrapped(T); if (isComposite(U) and U == T) { - try w.print("{{\"$ref\":\"{s}{s}\"}}", .{ ref_prefix, typeName(U) }); + try w.print("{{\"$ref\":\"{s}{s}\"}}", .{ prefix, typeName(U) }); return; } if (isComposite(U)) { @@ -287,7 +308,7 @@ fn writeRef(comptime T: type, w: *std.Io.Writer, comptime ref_prefix: []const u8 else => {}, } } - try writeType(T, w, ref_prefix); + try writeTypeMode(T, w, ref_prefix); } /// Write a complete schema document for `T`, with every reachable composite diff --git a/src/language/text.zig b/src/language/text.zig index af80739..bb5236d 100644 --- a/src/language/text.zig +++ b/src/language/text.zig @@ -7,6 +7,7 @@ //! things in two checkers is worse than no rule. const std = @import("std"); +const scan = @import("../core/scan.zig"); pub const Span = struct { start: usize, @@ -37,13 +38,17 @@ pub const Position = struct { pub const LineIndex = struct { starts: []const usize, + /// Count the line breaks, then allocate once and fill. + /// + /// Both passes are vectorised, and together they beat one growing list: + /// counting reads the bytes without touching memory, and the fill lands in + /// an array that is already exactly the right size, so nothing is ever + /// copied to make room. pub fn build(arena: std.mem.Allocator, source: []const u8) !LineIndex { - var starts: std.ArrayList(usize) = .empty; - try starts.append(arena, 0); - for (source, 0..) |c, i| { - if (c == '\n') try starts.append(arena, i + 1); - } - return .{ .starts = starts.items }; + const breaks = scan.countScalarBytes(source, '\n'); + const starts = try arena.alloc(usize, breaks + 1); + const written = scan.fillStarts(source, '\n', starts); + return .{ .starts = starts[0..written] }; } pub fn positionOf(self: LineIndex, offset: usize) Position { diff --git a/src/reports/notation.zig b/src/reports/notation.zig index 13a9abf..d606975 100644 --- a/src/reports/notation.zig +++ b/src/reports/notation.zig @@ -140,6 +140,55 @@ pub const Table = struct { } }; +/// Write a length of time the way a person says one. +/// +/// `core/time.zig` keeps the ISO 8601 form, which is right for storage and for +/// anything another program reads. `PT0.00618854S` is not right for a line a +/// person reads at the end of a run, so the plain form lives here, in the +/// presentation layer, with the rest of the notation. +/// +/// The unit is chosen by size and the figure is kept to one decimal at most, +/// because a duration reported to nine digits invites a precision the +/// measurement does not have. +pub fn writeDuration(w: *std.Io.Writer, duration: timeutil.Duration) std.Io.Writer.Error!void { + var ns = duration.ns; + if (ns < 0) { + try w.writeAll("-"); + ns = -ns; + } + if (ns == 0) return w.writeAll("no time at all"); + if (ns < 1_000) return w.print("{d} nanoseconds", .{ns}); + if (ns < 1_000_000) return writeScaled(w, ns, 1_000, "millisecond"); + if (ns < timeutil.ns_per_s) return writeScaled(w, ns, 1_000_000, "millisecond"); + if (ns < timeutil.ns_per_min) return writeScaled(w, ns, timeutil.ns_per_s, "second"); + if (ns < timeutil.ns_per_hour) return writeScaled(w, ns, timeutil.ns_per_min, "minute"); + if (ns < timeutil.ns_per_day) return writeScaled(w, ns, timeutil.ns_per_hour, "hour"); + return writeScaled(w, ns, timeutil.ns_per_day, "day"); +} + +fn writeScaled( + w: *std.Io.Writer, + ns: i64, + per_unit: i64, + name: []const u8, +) std.Io.Writer.Error!void { + const whole = @divTrunc(ns, per_unit); + const tenths = @divTrunc((ns - whole * per_unit) * 10, per_unit); + if (whole >= 10 or tenths == 0) { + try w.print("{d} {s}", .{ whole, name }); + if (whole != 1) try w.writeAll("s"); + return; + } + try w.print("{d}.{d} {s}s", .{ whole, tenths, name }); +} + +/// The same thing, into a buffer, for a caller that is building a sentence. +pub fn durationText(buffer: []u8, duration: timeutil.Duration) []const u8 { + var w = std.Io.Writer.fixed(buffer); + writeDuration(&w, duration) catch return ""; + return w.buffered(); +} + pub const footnote = "How to read this: a plus sign means the figure moved in the better direction " ++ "for that measure, a minus sign means it moved the worse way, and a tilde means " ++ @@ -339,3 +388,29 @@ test "a chart states its unit, its direction and its scale" { try testing.expect(std.mem.indexOf(u8, text, "The longest bar is 18.2 ms.") != null); try testing.expect(std.mem.indexOf(u8, text, "####") != null); } + +test "a length of time reads the way a person says one" { + var buffer: [64]u8 = undefined; + + // The case that prompted this: a run that took a few milliseconds, which + // ISO 8601 writes as PT0.00618854S. + try testing.expectEqualStrings("6.1 milliseconds", durationText(&buffer, .{ .ns = 6_188_540 })); + + try testing.expectEqualStrings("no time at all", durationText(&buffer, .{ .ns = 0 })); + try testing.expectEqualStrings("400 nanoseconds", durationText(&buffer, .{ .ns = 400 })); + try testing.expectEqualStrings("1.5 seconds", durationText(&buffer, .{ .ns = 1_500_000_000 })); + try testing.expectEqualStrings("1 second", durationText(&buffer, .{ .ns = 1_000_000_000 })); + try testing.expectEqualStrings("2 minutes", durationText(&buffer, .{ .ns = 120 * timeutil.ns_per_s })); + try testing.expectEqualStrings("1.5 hours", durationText(&buffer, .{ .ns = 90 * timeutil.ns_per_min })); + try testing.expectEqualStrings("3 days", durationText(&buffer, .{ .ns = 3 * timeutil.ns_per_day })); + + // Past ten of a unit the decimal is dropped: a precision nobody measured + // should not be printed. + try testing.expectEqualStrings("42 seconds", durationText(&buffer, .{ .ns = 42_400_000_000 })); + + // The ISO form is still there, unchanged, for anything a program reads. + var iso: [64]u8 = undefined; + var w = std.Io.Writer.fixed(&iso); + try (timeutil.Duration{ .ns = 6_188_540 }).writeIso(&w); + try testing.expectEqualStrings("PT0.00618854S", w.buffered()); +} diff --git a/src/root.zig b/src/root.zig index 6701ed5..2bac4eb 100644 --- a/src/root.zig +++ b/src/root.zig @@ -10,6 +10,7 @@ const std = @import("std"); pub const core = struct { pub const hash = @import("core/hash.zig"); + pub const scan = @import("core/scan.zig"); pub const id = @import("core/id.zig"); pub const time = @import("core/time.zig"); }; @@ -68,6 +69,7 @@ pub const events = struct { pub const event = @import("events/event.zig"); pub const log = @import("events/log.zig"); pub const graph = @import("events/graph.zig"); + pub const lineage = @import("events/lineage.zig"); pub const content_store = @import("events/content_store.zig"); pub const recovery = @import("events/recovery.zig"); }; @@ -100,6 +102,7 @@ pub const ai = struct { pub const policy_file = @import("ai/policy_file.zig"); pub const approval = @import("ai/approval.zig"); pub const tools = @import("ai/tools.zig"); + pub const toolschema = @import("ai/toolschema.zig"); pub const runtime = @import("ai/runtime.zig"); pub const risk = @import("ai/risk.zig"); pub const impact = @import("ai/impact.zig"); @@ -112,6 +115,8 @@ pub const ai = struct { pub const provider = @import("ai/provider.zig"); pub const catalog = @import("ai/catalog.zig"); pub const transport = @import("ai/transport.zig"); + pub const loop = @import("ai/loop.zig"); + pub const attention = @import("ai/attention.zig"); pub const wire = struct { pub const anthropic = @import("ai/wire/anthropic.zig"); pub const openai = @import("ai/wire/openai.zig"); diff --git a/src/terminal/grid.zig b/src/terminal/grid.zig index 64140f8..9551852 100644 --- a/src/terminal/grid.zig +++ b/src/terminal/grid.zig @@ -6,6 +6,7 @@ //! machine, replayed, or searched long after the process has gone. const std = @import("std"); +const scan = @import("../core/scan.zig"); const vt = @import("vt.zig"); pub const Color = union(enum) { @@ -174,9 +175,33 @@ pub const Screen = struct { } /// Feed bytes from the pseudoterminal. + /// Write a whole buffer, taking the fast road through ordinary text. + /// + /// Almost every byte a terminal receives is printable ASCII arriving while + /// the parser is in its ground state, and for those the parser does nothing + /// but hand the byte straight back to be printed. A vectorised scan finds + /// where that run ends, and the run is printed without going through the + /// state machine, the action union, or the switch that dispatches it, once + /// per byte. + /// + /// The state machine is still the definition. Every byte the scan stops at + /// goes through it, and a test feeds the same bytes both ways and asserts + /// the screen ends up identical, so the shortcut cannot start disagreeing + /// with the thing it is a shortcut for. pub fn write(self: *Screen, bytes: []const u8) !void { - for (bytes) |byte| { - _ = try self.writeByte(byte); + var index: usize = 0; + while (index < bytes.len) { + if (self.parser.inPlainGround()) { + const rest = bytes[index..]; + const run = scan.indexOfNonPrintable(rest) orelse rest.len; + if (run > 0) { + for (rest[0..run]) |byte| try self.putChar(byte); + index += run; + continue; + } + } + _ = try self.writeByte(bytes[index]); + index += 1; } } @@ -795,3 +820,80 @@ test "line wrapping does not lose characters" { try testing.expectEqualStrings("abcd", try screen.rowText(arena, 0)); try testing.expectEqualStrings("efg", try screen.rowText(arena, 1)); } + +test "the fast road leaves the screen exactly as the byte-at-a-time road does" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A stream that mixes long printable runs with everything that ends one: + // colours, cursor moves, line breaks, multi-byte characters, an operating + // system command, and a device control string. + var stream: std.ArrayList(u8) = .empty; + var random = std.Random.DefaultPrng.init(20260907); + const r = random.random(); + var pieces: usize = 0; + while (pieces < 600) : (pieces += 1) { + switch (r.uintLessThan(u8, 7)) { + 0 => try stream.appendSlice(arena, "\x1b[31;1m"), + 1 => try stream.appendSlice(arena, "\x1b]0;a title\x07"), + 2 => try stream.appendSlice(arena, "\r\n"), + 3 => try stream.appendSlice(arena, "wörld — ✓ 日本"), + 4 => try stream.appendSlice(arena, "\x1b[2J\x1b[H"), + 5 => try stream.appendSlice(arena, "\tcolumn\tstops\t"), + else => { + const run = r.uintLessThan(usize, 300) + 1; + var i: usize = 0; + while (i < run) : (i += 1) { + try stream.append(arena, 0x20 + r.uintLessThan(u8, 0x5f)); + } + }, + } + } + + var fast = try Screen.init(arena, 80, 24, 500); + try fast.write(stream.items); + + var slow = try Screen.init(arena, 80, 24, 500); + for (stream.items) |byte| _ = try slow.writeByte(byte); + + // Every visible row, and the cursor, must match. A shortcut that got the + // wrapping or the scrolling wrong would show up here and nowhere else. + try testing.expectEqual(slow.cursor.row, fast.cursor.row); + try testing.expectEqual(slow.cursor.column, fast.cursor.column); + var row: u16 = 0; + while (row < 24) : (row += 1) { + try testing.expectEqualStrings( + try slow.rowText(arena, row), + try fast.rowText(arena, row), + ); + } + try testing.expectEqual(slow.scrollbackCount(), fast.scrollbackCount()); +} + +test "a buffer split anywhere writes the same screen" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A read from a pseudoterminal lands wherever it lands, so a character or a + // sequence can arrive in two pieces. The fast path must not care. + const stream = "plain \x1b[1;32mgreen\x1b[0m text wörld\r\nsecond line \x1b]0;t\x07 end"; + + var whole = try Screen.init(arena, 40, 6, 20); + try whole.write(stream); + + var split: usize = 0; + while (split <= stream.len) : (split += 1) { + var pieces = try Screen.init(arena, 40, 6, 20); + try pieces.write(stream[0..split]); + try pieces.write(stream[split..]); + var row: u16 = 0; + while (row < 6) : (row += 1) { + try testing.expectEqualStrings( + try whole.rowText(arena, row), + try pieces.rowText(arena, row), + ); + } + } +} diff --git a/src/terminal/vt.zig b/src/terminal/vt.zig index 8007416..fef1fab 100644 --- a/src/terminal/vt.zig +++ b/src/terminal/vt.zig @@ -13,6 +13,7 @@ //! tests, and it makes no claim to be a full terminal. const std = @import("std"); +const scan = @import("../core/scan.zig"); pub const max_params = 16; pub const max_intermediates = 2; @@ -134,6 +135,16 @@ pub const Parser = struct { self.* = .{}; } + /// True when the next printable ASCII byte would simply be printed. + /// + /// The ground state with no partly-decoded character in hand is the only + /// state where that holds, and it is the state a terminal spends almost all + /// of its time in. A caller that wants to take a shortcut over a run of + /// ordinary text asks this first. + pub fn inPlainGround(self: Parser) bool { + return self.state == .ground and self.utf8_remaining == 0; + } + /// Feed one byte. Returns the action it completes, if any. /// /// Slices inside the returned action point into the parser and stay valid @@ -191,9 +202,36 @@ pub const Parser = struct { } /// Feed a slice, appending each action to `out`. + /// Feed a whole buffer, taking the fast road through ordinary text. + /// + /// Almost every byte a terminal receives is printable ASCII in the ground + /// state, and for those the state machine does nothing but hand the byte + /// back. So when the parser is in the ground state with no partly-decoded + /// character in hand, a vectorised scan finds where the next interesting + /// byte is, and the whole run before it is printed in a tight loop with the + /// output capacity reserved once. + /// + /// The state machine is still the definition. It runs for every byte the + /// scan stops at, and a test feeds the same bytes through both roads and + /// asserts the actions are identical, so the shortcut cannot quietly start + /// disagreeing with the thing it is a shortcut for. pub fn feed(self: *Parser, arena: std.mem.Allocator, bytes: []const u8, out: *std.ArrayList(Action)) !void { - for (bytes) |byte| { - if (self.advance(byte)) |action| try out.append(arena, action); + var index: usize = 0; + while (index < bytes.len) { + if (self.inPlainGround()) { + const rest = bytes[index..]; + const run = scan.indexOfNonPrintable(rest) orelse rest.len; + if (run > 0) { + try out.ensureUnusedCapacity(arena, run); + for (rest[0..run]) |byte| { + out.appendAssumeCapacity(.{ .print = byte }); + } + index += run; + continue; + } + } + if (self.advance(bytes[index])) |action| try out.append(arena, action); + index += 1; } } @@ -698,3 +736,103 @@ test "parameter values are clamped rather than wrapped" { const actions = try collect(arena, "\x1b[999999999H"); try testing.expectEqual(@as(u16, 65535), actions.items[0].csi.params[0]); } + +test "the fast road through plain text produces exactly what the state machine does" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A stream that mixes long printable runs with the things that end one: + // escape sequences, control characters, and multi-byte characters. The + // fast path has to hand every one of those back. + var stream: std.ArrayList(u8) = .empty; + var random = std.Random.DefaultPrng.init(4242); + const r = random.random(); + var pieces: usize = 0; + while (pieces < 400) : (pieces += 1) { + switch (r.uintLessThan(u8, 6)) { + 0 => try stream.appendSlice(arena, "\x1b[31;1m"), + 1 => try stream.appendSlice(arena, "\x1b]133;A\x07"), + 2 => try stream.appendSlice(arena, "\r\n"), + 3 => try stream.appendSlice(arena, "wörld — ✓"), + 4 => try stream.appendSlice(arena, "\x1bP1$rm\x1b\\"), + else => { + const run = r.uintLessThan(usize, 200) + 1; + var i: usize = 0; + while (i < run) : (i += 1) { + try stream.append(arena, 0x20 + r.uintLessThan(u8, 0x5f)); + } + }, + } + } + + // The whole buffer, through `feed`. + var fast_parser = Parser.init(); + var fast: std.ArrayList(Action) = .empty; + try fast_parser.feed(arena, stream.items, &fast); + + // The same bytes, one at a time, through `advance`. + var slow_parser = Parser.init(); + var slow: std.ArrayList(Action) = .empty; + for (stream.items) |byte| { + if (slow_parser.advance(byte)) |action| try slow.append(arena, copyAction(arena, action)); + } + + try testing.expectEqual(slow.items.len, fast.items.len); + for (fast.items, slow.items) |a, b| { + try testing.expectEqual(std.meta.activeTag(a), std.meta.activeTag(b)); + switch (a) { + .print => |value| try testing.expectEqual(value, b.print), + else => {}, + } + } + // The stream really did exercise both roads: thousands of printable bytes + // that took the shortcut, and escape sequences that could not. + var printed: usize = 0; + var sequences: usize = 0; + for (fast.items) |action| { + switch (action) { + .print => printed += 1, + .csi, .osc, .esc, .dcs => sequences += 1, + else => {}, + } + } + try testing.expect(printed > 3_000); + try testing.expect(sequences > 100); + try testing.expectEqual(State.ground, fast_parser.state); +} + +/// Actions can point into the parser, which the next byte overwrites. A test +/// that keeps them has to copy the parts it keeps. +fn copyAction(arena: std.mem.Allocator, action: Action) Action { + return switch (action) { + .osc => |o| .{ .osc = .{ .raw = arena.dupe(u8, o.raw) catch o.raw } }, + else => action, + }; +} + +test "a buffer split at every point gives the same actions as one piece" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A read from a pseudoterminal lands wherever it lands, so a sequence can + // arrive in two pieces. The fast path must not change that. + const stream = "plain text \x1b[1;32mgreen\x1b[0m more text wörld \x1b]133;D;0\x07 end"; + + var whole_parser = Parser.init(); + var whole: std.ArrayList(Action) = .empty; + try whole_parser.feed(arena, stream, &whole); + + var split: usize = 0; + while (split <= stream.len) : (split += 1) { + var parser = Parser.init(); + var pieces: std.ArrayList(Action) = .empty; + try parser.feed(arena, stream[0..split], &pieces); + try parser.feed(arena, stream[split..], &pieces); + try testing.expectEqual(whole.items.len, pieces.items.len); + for (whole.items, pieces.items) |a, b| { + try testing.expectEqual(std.meta.activeTag(a), std.meta.activeTag(b)); + } + } +} From 4fdb9cd6ba338d01b30f28440f3e6cbbc6aa7bb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 01:53:20 +0000 Subject: [PATCH 03/12] Spend the decision, and stop the record claiming what it did not check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external review said the kernel jail and the decision-spend check sat in a module the runtime never called. Verified, and it was worse than reported. The agent runtime's executor seam took only a request. It could not receive a decision, so the runtime obtained one and discarded it with `_ = decision`. The seam now takes the decision, and four tests fail if a forged one is passed instead. Wiring the two halves together failed immediately, and that is the second finding: there were two definitions of what a request touches. The policy engine decided on `zig build test`; the executor's check derived `zig`. A git push was a remote to one and a path to the other. Neither half had ever been tested against a decision the other made. There is now one definition, in `tools.ToolRequest.resource`, and the copy is gone. The new check then failed the approval path, which is the third finding: a person's approval was never becoming an allowed decision. The runtime re-spent the pending decision, whose effect is still `require_human`, so an executor that checked would refuse it. `Engine.allowedByPerson` mints the decision the answer deserves, recorded with the name of the person who gave it. Fourth: a permission spread to resources its author never named. A rule scoped to hosts also allowed `network.connect` on a command or a path, because an unnamed axis meant "no restriction on this axis". Right for a refusal, catastrophic for a permission. A scope now covers only the axes it names, and a deny keeps the wider reading, so tightening this narrowed no refusal anywhere. Fifth: `::1` never matched a rule that named it, including this repository's own shipped policy. The host matcher split on the first colon. A colon is not a port separator in an address made of them. Identifiers came from a default-seeded generator, so two workspaces opened in the same millisecond minted the same first identifier and a graph that looks events up by identifier would fold two events into one. The seed is drawn from the operating system unless a test asks for one. Timestamps were held in nanoseconds and written in milliseconds, so an instant did not survive being recorded. They are written at nanosecond precision now, always nine digits, so one instant has one representation. The conformance statement recorded `Hash.of("vocabulary")` — the hash of the word, not the artefact — with an epoch timestamp, and marked WCAG target-size and name-role-value as passing against an example tree in a build that draws no window. Artefacts are hashed, the clock is real, those two are `not_tested` with the reason, and the two ISO 24495 requirements that no checker can establish are `partial` rather than `pass`. CI now diffs the statement against what is committed, so it cannot drift from the code it describes. One claim in the review did not hold: durations round-trip exactly through JSON, including values whose fraction is not a dyadic rational. A test pins that rather than leaving it to be re-litigated. Verified: 486 tests pass; each fix has a test that fails when the fix is reverted; zag check clean; zig fmt clean; three cross-compile targets build; the loop was driven end to end against a local server, running one tool and being refused another. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- .github/workflows/ci.yml | 17 ++- docs/roadmap.md | 9 ++ docs/threat-model.md | 3 + evidence/conformance.jsonl | 34 ++--- src/ai/executor.zig | 80 ++++++++--- src/ai/loop.zig | 10 +- src/ai/policy.zig | 237 +++++++++++++++++++++++++++++-- src/ai/runtime.zig | 214 +++++++++++++++++++++++++++- src/cli/main.zig | 125 ++++++++++++++-- src/core/id.zig | 28 +++- src/core/time.zig | 75 +++++++++- src/workspace/service.zig | 74 +++++++++- standards/registry/language.toml | 1 - 13 files changed, 822 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f466ea4..dd537d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,8 +68,21 @@ jobs: ./zig-out/bin/zag knowledge ./zig-out/bin/zagd verify - - name: Write the conformance statement - run: ./zig-out/bin/zag evidence + - name: Write the conformance statement, and check it matches what is committed + run: | + ./zig-out/bin/zag evidence + # The statement records when it was produced, so that field differs on + # every run. Everything else must not: the results, the subjects and + # the hashes of the artefacts they are about are what the committed + # file is for. Without this check the file could sit in the repository + # claiming passes for code that had changed underneath it. + strip_time() { sed 's/"created_at":"[^"]*"/"created_at":""/g'; } + strip_time < evidence/conformance.jsonl > /tmp/produced.jsonl + git show HEAD:evidence/conformance.jsonl | strip_time > /tmp/committed.jsonl + if ! diff -u /tmp/committed.jsonl /tmp/produced.jsonl; then + echo "The conformance statement has changed. Run './zig-out/bin/zag evidence' and commit the result." + exit 1 + fi - name: Show the accessibility statement run: ./zig-out/bin/zag accessibility diff --git a/docs/roadmap.md b/docs/roadmap.md index c0203b1..41c492e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,6 +73,15 @@ of everything in it. What a model pays attention to is chosen by walking that graph from the event being asked about, nearest first. Recency is only a tie-break. +Four things in this list were not true until recently, and are worth naming +because the tests were green throughout. The agent runtime obtained a decision +and discarded it, calling an executor that could not receive one. A second copy +of "what resource does this request touch" disagreed with the first, so the two +halves of the security model had never been tested against each other. A policy +rule scoped to hosts also allowed requests on any other kind of resource. And +`::1` never matched a rule that named it, because the host matcher split on the +first colon. Each now has a test that fails if the fix is removed. + These statements are covered by automated tests. They do not establish power-loss survival on every filesystem, hostile tamper resistance, a safe garbage collector, a complete terminal, a graphical product or organisational diff --git a/docs/threat-model.md b/docs/threat-model.md index 416b6fd..4d336a8 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -37,6 +37,9 @@ against that actor. | ID | Threat | Current control | Residual work | | --- | --- | --- | --- | | T1 | Repository text tells an agent that it has more permission | Permissions come only from typed policy; instruction files that try to grant permission are reported | Test every concrete executor against the decision it receives | +| T14 | The decision is obtained and then not spent | The executor takes the decision as a parameter and refuses one that does not authorise the request, so a caller that does not pass the right one cannot act. The agent runtime's seam used to take only the request, and the runtime discarded the decision it had just obtained; four tests now fail if a decision stops being checked | Keep the seam shaped so a decision cannot be omitted. A new executor that ignores its decision parameter would not be caught by a type | +| T15 | Two definitions of what a request touches | One definition, in `tools.ToolRequest.resource`. A second copy in the executor narrowed an execute request to its program name and collapsed a git push to a path, so a decision made on `git push origin main` could not authorise the request it was made for. The two halves of the security model had never agreed, because each was only ever tested against decisions it built itself | An end-to-end test from a written policy file through a decision to a real executor run | +| T16 | A permission spreads to a resource its author never named | A rule's scope now covers only the axes it names. An allow rule scoped to hosts used to match `network.connect` on a command or a path resource, because an unnamed axis meant "no restriction on this axis". A refusal keeps the older, wider reading, so tightening this did not narrow any deny | Enforce at the socket as well: the host is checked before the request is built, not when the address is resolved | | T2 | A path uses `..` or a symlink to escape the workspace | Policy rejects lexical traversal and mismatched path roots; executors open every path through a workspace directory handle with `RESOLVE_BENEATH` and `RESOLVE_NO_MAGICLINKS`, so the kernel refuses an escaping path in any component; final event-log, object and recovery-evidence entries reject symbolic links | Only Linux enforces this. A build for another platform must refuse to execute rather than fall back to a lexical check | | T3 | A command string gains unintended shell meaning | Agent tool requests carry argument vectors, and the executor passes the vector straight to `execve` with no shell; a test asserts that an argument containing a semicolon and a redirect creates no file. The interactive `zag run` command is explicitly a person-requested shell command | Keep the assertion as a release gate; a future convenience that joins a vector back into a line would undo it | | T4 | An event is edited, reordered or appended by a stale writer | Content and chain hashes, strict decoding, prefix fingerprint and an exclusive writer lock | Signed external heads are needed to detect whole-log replacement, truncation or a hostile non-cooperating writer | diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index c448a91..7beedd3 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:42b64249dbf8a2681a90b206c62ec3790e09cb019c1e0cfdf70f22ec111784ea","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:42b64249dbf8a2681a90b206c62ec3790e09cb019c1e0cfdf70f22ec111784ea","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:c97190417aca79dda75ff3104c6ac72c5d3daa0462578926e5fb25d8f5cb55ef","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"the published accessibility tree","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:6baa6c9f461fe834a9da0ebb62e0f6fb8c11988a58cbc51b8da8040aea19bc3a","note":""} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"the published accessibility tree","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:6baa6c9f461fe834a9da0ebb62e0f6fb8c11988a58cbc51b8da8040aea19bc3a","note":""} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:337d60563d1e11a6e4ef447920333043bed43ad3ef1ad89292e6164ae9266357","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:bde9fcfb24306748dff3e56378185a17f54052048eaee63810f183c7776680bb","note":"The plain-language checker found no blocking problem. It cannot establish that a reader can act on the text."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:bde9fcfb24306748dff3e56378185a17f54052048eaee63810f183c7776680bb","note":"The plain-language checker found no blocking problem. It cannot establish that a reader can act on the text."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:bde9fcfb24306748dff3e56378185a17f54052048eaee63810f183c7776680bb","note":"The plain-language checker found no blocking problem. It cannot establish that a reader can act on the text."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:bde9fcfb24306748dff3e56378185a17f54052048eaee63810f183c7776680bb","note":"The plain-language checker found no blocking problem. It cannot establish that a reader can act on the text."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"1970-01-01T00:00:00.000Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/ai/executor.zig b/src/ai/executor.zig index 888cae6..d7a0e22 100644 --- a/src/ai/executor.zig +++ b/src/ai/executor.zig @@ -64,23 +64,21 @@ pub fn refusalText(err: Error) []const u8 { }; } -/// The resource a request acts on, derived from the request itself. +/// The resource a request acts on. +/// +/// There is one definition of this, in `tools.ToolRequest.resource`, and this +/// file deliberately does not have a second one. It used to: a copy here +/// narrowed an execute request to its program name and collapsed a git push to +/// a path, so a decision made by the policy engine on `git push origin main` +/// could never authorise the request it was made for. Two definitions of what a +/// request touches is two security models, and the one that is easier to +/// satisfy wins by accident. /// /// This is the function that makes a decision unforgeable in practice: the /// executor asks the request what it touches, rather than believing what the /// caller said when the decision was made. -pub fn resourceOf(request: ToolRequest) Resource { - return switch (request) { - .execute => |e| .{ .command = if (e.argv.len > 0) e.argv[0] else "" }, - .read_file => |r| .{ .path = r.path }, - .write_file => |w| .{ .path = w.path }, - .delete => |d| .{ .path = d.path }, - .search => |s| .{ .path = s.root }, - .git => |g| .{ .path = g.repository }, - .mcp => |m| .{ .service = m.server }, - .spawn_agent => |a| .{ .service = a.label }, - .infer => |i| .{ .service = i.provider }, - }; +pub fn resourceOf(arena: std.mem.Allocator, request: ToolRequest) !Resource { + return request.resource(arena); } /// True when `decision` authorises exactly `request`. @@ -88,10 +86,10 @@ pub fn resourceOf(request: ToolRequest) Resource { /// Both halves are compared. A decision carrying the right capability for the /// wrong path is not a permission to act on that path, and the difference /// between those two is the whole of the security model. -pub fn authorises(decision: Decision, request: ToolRequest) bool { +pub fn authorises(arena: std.mem.Allocator, decision: Decision, request: ToolRequest) !bool { if (!decision.isAllowed()) return false; if (decision.request.capability != request.capability()) return false; - const wanted = resourceOf(request); + const wanted = try request.resource(arena); return sameResource(decision.request.resource, wanted); } @@ -130,7 +128,7 @@ pub const Runner = struct { /// the request before anything is opened, started or sent. pub fn run(self: Runner, decision: Decision, request: ToolRequest) Error!ToolResult { if (!decision.isAllowed()) return error.NotAllowed; - if (!authorises(decision, request)) return error.DecisionDoesNotMatchRequest; + if (!try authorises(self.arena, decision, request)) return error.DecisionDoesNotMatchRequest; return switch (request) { .read_file => |r| self.readFile(r), @@ -290,26 +288,56 @@ fn decisionFor(capability: Capability, resource: Resource, effect: policy_mod.Ef } test "a decision authorises the one request it names" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const request: ToolRequest = .{ .read_file = .{ .path = "notes.md", .maxBytes = 1 << 20 } }; const good = decisionFor(.@"fs.read", .{ .path = "notes.md" }, .allow); - try testing.expect(authorises(good, request)); + try testing.expect(try authorises(arena, good, request)); // The right capability for a different file is not a permission for this // file. This is the case that turns a policy into a decoration. const other_path = decisionFor(.@"fs.read", .{ .path = "/etc/shadow" }, .allow); - try testing.expect(!authorises(other_path, request)); + try testing.expect(!try authorises(arena, other_path, request)); // The right file under a different capability is not a permission either. const other_capability = decisionFor(.@"fs.write", .{ .path = "notes.md" }, .allow); - try testing.expect(!authorises(other_capability, request)); + try testing.expect(!try authorises(arena, other_capability, request)); // A decision that refused authorises nothing. const denied = decisionFor(.@"fs.read", .{ .path = "notes.md" }, .deny); - try testing.expect(!authorises(denied, request)); + try testing.expect(!try authorises(arena, denied, request)); // So does one that is still waiting for a person. const waiting = decisionFor(.@"fs.read", .{ .path = "notes.md" }, .require_human); - try testing.expect(!authorises(waiting, request)); + try testing.expect(!try authorises(arena, waiting, request)); + + // An execute request is identified by the whole command line, because that + // is what the policy engine decides on. A definition that used only the + // program name would let a decision about `git status` authorise + // `git push`. + const build: ToolRequest = .{ .execute = .{ + .argv = &.{ "zig", "build", "test" }, + .workingDirectory = "/repo", + } }; + const for_build = decisionFor(.@"process.execute", .{ .command = "zig build test" }, .allow); + try testing.expect(try authorises(arena, for_build, build)); + + const for_zig_alone = decisionFor(.@"process.execute", .{ .command = "zig" }, .allow); + try testing.expect(!try authorises(arena, for_zig_alone, build)); + + // A push is decided on its remote, not on the repository it came from. + const push: ToolRequest = .{ .git = .{ + .operation = .push, + .repository = "/repo", + .argument = "git@example.test:org/zag.git", + } }; + const for_remote = decisionFor(.@"git.push", .{ .remote = "git@example.test:org/zag.git" }, .allow); + try testing.expect(try authorises(arena, for_remote, push)); + + const for_repository = decisionFor(.@"git.push", .{ .path = "/repo" }, .allow); + try testing.expect(!try authorises(arena, for_repository, push)); } test "the resource comes from the request, not from the caller" { @@ -319,7 +347,9 @@ test "the resource comes from the request, not from the caller" { .contentHash = hashing.Hash.of("hello"), .byteCount = 5, } }; - const resource = resourceOf(request); + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const resource = try resourceOf(arena_state.allocator(), request); try testing.expectEqualStrings("src/main.zig", resource.text()); try testing.expectEqual(Capability.@"fs.write", request.capability()); } @@ -400,7 +430,11 @@ test "a command runs from its argument vector, with no shell in between" { .argv = &argv, .workingDirectory = ".", } }; - const decision = decisionFor(.@"process.execute", .{ .command = "/bin/echo" }, .allow); + // The decision names the whole command line, because that is what the + // policy engine decides on and what the executor re-derives. + const decision = decisionFor(.@"process.execute", .{ + .command = "/bin/echo one; rm -rf /; echo two > /tmp/zag-should-not-exist", + }, .allow); const result = try runner.run(decision, request); try testing.expect(result.succeeded()); diff --git a/src/ai/loop.zig b/src/ai/loop.zig index e676aa1..7c18247 100644 --- a/src/ai/loop.zig +++ b/src/ai/loop.zig @@ -456,9 +456,13 @@ pub const Runner = struct { // 3. The policy decides. The resource comes from the request, through // the executor, not from anything the model wrote. var engine = self.engine; + // The resource comes from the request, through the one definition the + // executor will re-derive it from. Deciding on anything else would make + // a decision the executor then refuses to honour. + const resource = request.resource(self.arena) catch return error.OutOfMemory; const decision = engine.decide(.{ .capability = request.capability(), - .resource = executor_mod.resourceOf(request), + .resource = resource, }, context) catch return error.OutOfMemory; step.decision = decision; @@ -480,7 +484,7 @@ pub const Runner = struct { if (request.additionalCapability()) |extra| { const also = engine.decide(.{ .capability = extra, - .resource = executor_mod.resourceOf(request), + .resource = resource, }, context) catch return error.OutOfMemory; if (!also.isAllowed()) { step.decision = also; @@ -541,7 +545,7 @@ fn noteRepeat( /// as different would let a model defeat the repetition bound by varying a /// field nobody decides on. pub fn fingerprintOf(arena: std.mem.Allocator, request: ToolRequest) !hashing.Hash { - const resource = executor_mod.resourceOf(request); + const resource = try request.resource(arena); var extra: []const u8 = ""; switch (request) { .execute => |e| extra = try e.commandText(arena), diff --git a/src/ai/policy.zig b/src/ai/policy.zig index 36f7159..f8a065a 100644 --- a/src/ai/policy.zig +++ b/src/ai/policy.zig @@ -54,7 +54,33 @@ pub const Scope = struct { /// The rule stops applying after this instant. valid_until: ?Timestamp = null, - pub fn matches(self: Scope, resource: Resource, agent: ?[]const u8, now: Timestamp) bool { + /// True when this scope names no resource axis at all. + /// + /// Such a scope says nothing about *what* the rule applies to, only + /// possibly about who and until when, so it applies to every resource. + pub fn namesNoResource(self: Scope) bool { + return self.paths.len == 0 and self.commands.len == 0 and + self.hosts.len == 0 and self.remotes.len == 0 and + self.credentials.len == 0; + } + + /// Does this scope cover `resource`? + /// + /// `widen` is the direction the caller is safe to be wrong in. An allow + /// rule must not cover a resource its author did not name, so it passes + /// false and an unnamed axis means no match. A deny rule must not be + /// narrowed by the same reasoning, so it passes true and an unnamed axis + /// still matches. + /// + /// The asymmetry is the point, and it is the same principle as + /// `Effect.strongest`: when the rules are ambiguous, refuse. + /// + /// Without it the axes are orthogonal, and a rule that says "you may + /// connect to localhost" also allows `network.connect` on a *command* + /// resource, because the scope names no commands and an unnamed axis used + /// to mean "no restriction on this axis". That reading is right for a deny + /// and catastrophic for an allow. + pub fn matches(self: Scope, resource: Resource, agent: ?[]const u8, now: Timestamp, widen: bool) bool { if (self.valid_until) |until| { if (now.ns > until.ns) return false; } @@ -66,14 +92,18 @@ pub const Scope = struct { } if (!found) return false; } + if (self.namesNoResource()) return true; + return switch (resource) { - .none => true, - .path => |p| self.paths.len == 0 or matchesAnyPath(self.paths, p), - .command => |c| self.commands.len == 0 or matchesAnyCommand(self.commands, c), - .host => |h| self.hosts.len == 0 or matchesAnyHost(self.hosts, h), - .remote => |r| self.remotes.len == 0 or matchesAnyPrefix(self.remotes, r), - .credential => |c| self.credentials.len == 0 or matchesAnyPrefix(self.credentials, c), - .service => |s| self.hosts.len == 0 or matchesAnyPrefix(self.hosts, s), + // A capability with no resource is covered by a scope that names + // one only if that scope is allowed to widen. + .none => widen, + .path => |p| if (self.paths.len == 0) widen else matchesAnyPath(self.paths, p), + .command => |c| if (self.commands.len == 0) widen else matchesAnyCommand(self.commands, c), + .host => |h| if (self.hosts.len == 0) widen else matchesAnyHost(self.hosts, h), + .remote => |r| if (self.remotes.len == 0) widen else matchesAnyPrefix(self.remotes, r), + .credential => |c| if (self.credentials.len == 0) widen else matchesAnyPrefix(self.credentials, c), + .service => |s| if (self.hosts.len == 0) widen else matchesAnyPrefix(self.hosts, s), }; } }; @@ -142,8 +172,32 @@ fn matchesAnyCommand(names: []const []const u8, command: []const u8) bool { return false; } +/// Strip a port from a host, leaving the host itself. +/// +/// A colon does not mean a port. `::1` is a whole address made of them, and a +/// bracketed address carries its port outside the brackets. Splitting on the +/// first colon turned `::1` into the empty string, so a policy that listed it — +/// as this repository's own shipped policy does — silently never matched, and +/// the workspace was one rule short of what its author wrote. +pub fn hostWithoutPort(host: []const u8) []const u8 { + if (host.len == 0) return host; + if (host[0] == '[') { + // `[::1]:8080` — the address is inside the brackets. + if (std.mem.indexOfScalar(u8, host, ']')) |close| return host[1..close]; + return host[1..]; + } + // A bare IPv6 address has more than one colon and no port. + var colons: usize = 0; + for (host) |byte| { + if (byte == ':') colons += 1; + } + if (colons > 1) return host; + if (std.mem.indexOfScalar(u8, host, ':')) |colon| return host[0..colon]; + return host; +} + fn matchesAnyHost(patterns: []const []const u8, host: []const u8) bool { - const name = if (std.mem.indexOfScalar(u8, host, ':')) |colon| host[0..colon] else host; + const name = hostWithoutPort(host); for (patterns) |pattern| { if (std.mem.eql(u8, pattern, name)) return true; if (std.mem.startsWith(u8, pattern, "*.")) { @@ -175,7 +229,9 @@ pub const Rule = struct { if (c == request.capability) capability_matches = true; } if (!capability_matches) return false; - return self.scope.matches(request.resource, agent, now); + // A refusal may cover more than its author spelled out; a permission + // may not. + return self.scope.matches(request.resource, agent, now, self.effect == .deny); } }; @@ -312,6 +368,41 @@ pub const Engine = struct { return decision; } + /// Mint a decision that a person made, rather than a rule. + /// + /// When a policy says "ask a person", the answer is not the decision: the + /// pending decision still says `require_human`, and an executor handed that + /// one correctly refuses to act on it. So the person's answer has to become + /// its own decision, recorded like any other, saying that a person allowed + /// this and who they were. + /// + /// This is the only way to produce an allowed decision without a rule, and + /// it lives here because the engine is the only thing that mints decisions. + pub fn allowedByPerson( + self: *Engine, + request: Request, + context: Context, + by: idmod.ActorId, + standing: bool, + ) !Decision { + self.ids.clock_ms = @divFloor(context.now.ns, timeutil.ns_per_ms); + const decision: Decision = .{ + .id = self.ids.next(idmod.DecisionId), + .request = request, + .effect = .allow, + .rule_id = null, + .policy_id = self.policy.id, + .reason = if (standing) "You allowed this here, and asked not to be asked again." else "You allowed this, once.", + .actor = by, + .session = context.session, + .agent = context.agent, + .decided_at = context.now, + .from_standing_grant = standing, + }; + try self.decisions.append(self.arena, decision); + return decision; + } + /// Record a person's "always allow here". pub fn grant(self: *Engine, g: Grant) !void { try self.grants.append(self.arena, g); @@ -577,3 +668,129 @@ test "every decision is kept as a record" { try testing.expectEqual(Effect.deny, engine.decisions.items[1].effect); try testing.expect(std.mem.indexOf(u8, engine.decisions.items[1].reason, "not allowed") != null); } + +test "a permission does not spread to a resource its author never named" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // "You may reach a model on this computer." Nothing else. + const rules = [_]Rule{.{ + .id = "local-only", + .capabilities = &.{.@"network.connect"}, + .scope = .{ .hosts = &.{ "localhost", "127.0.0.1", "::1" } }, + .effect = .allow, + .reason = "Only a model on this computer may be reached.", + }}; + var engine = Engine.init(arena, .{ + .id = "t", + .name = "Local only", + .description = "Only a model on this computer.", + .rules = &rules, + }, 7); + var gen = idmod.Generator.init(3, 0); + const context: Context = .{ .actor = gen.next(idmod.ActorId), .now = .{ .ns = 1 } }; + + // The hosts it names are allowed. `::1` among them: a colon is not a port + // separator when the address is made of them. + for ([_][]const u8{ "localhost", "127.0.0.1", "::1", "localhost:11434", "[::1]:8080" }) |host| { + const decision = try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .host = host }, + }, context); + try testing.expect(decision.isAllowed()); + } + const elsewhere = try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .host = "api.example.test" }, + }, context); + try testing.expect(!elsewhere.isAllowed()); + + // And the hole this closes: the same rule used to allow `network.connect` + // on a command or a path, because it named no commands and no paths and an + // unnamed axis meant "no restriction on this axis". A rule about hosts + // must say nothing about anything else. + const as_command = try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .command = "npm install" }, + }, context); + try testing.expect(!as_command.isAllowed()); + + const as_path = try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .path = "/etc/shadow" }, + }, context); + try testing.expect(!as_path.isAllowed()); + + const as_credential = try engine.decide(.{ + .capability = .@"network.connect", + .resource = .{ .credential = "ANTHROPIC_API_KEY" }, + }, context); + try testing.expect(!as_credential.isAllowed()); +} + +test "a refusal still covers what its author did not spell out" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The asymmetry that makes the rule above safe. A refusal scoped to one + // axis must not be narrowed by the same reasoning that keeps a permission + // from spreading, or tightening the engine would quietly loosen every deny + // in every workspace. + const rules = [_]Rule{ + .{ + .id = "allow-everything", + .capabilities = &.{.@"fs.write"}, + .effect = .allow, + .reason = "Writing is allowed here.", + }, + .{ + .id = "not-in-etc", + .capabilities = &.{.@"fs.write"}, + .scope = .{ .paths = &.{"/etc"} }, + .effect = .deny, + .reason = "Nothing writes to /etc.", + }, + }; + var engine = Engine.init(arena, .{ + .id = "t", + .name = "Write, not /etc", + .description = "Writing is allowed outside /etc.", + .rules = &rules, + }, 8); + var gen = idmod.Generator.init(3, 0); + const context: Context = .{ .actor = gen.next(idmod.ActorId), .now = .{ .ns = 1 } }; + + const in_repo = try engine.decide(.{ + .capability = .@"fs.write", + .resource = .{ .path = "/repo/a.zig" }, + }, context); + try testing.expect(in_repo.isAllowed()); + + const in_etc = try engine.decide(.{ + .capability = .@"fs.write", + .resource = .{ .path = "/etc/passwd" }, + }, context); + try testing.expect(!in_etc.isAllowed()); + + // A write request arriving with a resource kind the deny does not name is + // still refused, because a refusal reaches further than it spells out. + const odd = try engine.decide(.{ + .capability = .@"fs.write", + .resource = .{ .service = "something-unexpected" }, + }, context); + try testing.expect(!odd.isAllowed()); +} + +test "a colon is not always a port" { + try testing.expectEqualStrings("localhost", hostWithoutPort("localhost")); + try testing.expectEqualStrings("localhost", hostWithoutPort("localhost:11434")); + try testing.expectEqualStrings("127.0.0.1", hostWithoutPort("127.0.0.1:8080")); + // The case that was broken: an address made of colons. + try testing.expectEqualStrings("::1", hostWithoutPort("::1")); + try testing.expectEqualStrings("::1", hostWithoutPort("[::1]:8080")); + try testing.expectEqualStrings("fe80::1", hostWithoutPort("fe80::1")); + try testing.expectEqualStrings("2001:db8::1", hostWithoutPort("[2001:db8::1]:443")); + try testing.expectEqualStrings("", hostWithoutPort("")); +} diff --git a/src/ai/runtime.zig b/src/ai/runtime.zig index 45d081e..5546c86 100644 --- a/src/ai/runtime.zig +++ b/src/ai/runtime.zig @@ -13,6 +13,7 @@ const std = @import("std"); const tools = @import("tools.zig"); const policy_mod = @import("policy.zig"); const approval_mod = @import("approval.zig"); +const executor_mod = @import("executor.zig"); const capability_mod = @import("capability.zig"); const event_mod = @import("../events/event.zig"); const log_mod = @import("../events/log.zig"); @@ -29,15 +30,54 @@ pub const Timestamp = timeutil.Timestamp; /// Something that can carry out a tool request. The runtime never executes /// anything itself, so a test, a sandbox and a remote daemon are all just /// different executors. +/// +/// The decision is part of the call, and that is the whole point of this type's +/// shape. An executor that is handed only a request cannot check that the +/// request is the one that was decided, and a seam that cannot carry the +/// decision is a seam where the security model quietly stops. It used to be +/// shaped that way here, and the runtime discarded the decision it had just +/// obtained; the parameter exists so that cannot happen again. pub const Executor = struct { ptr: *anyopaque, - executeFn: *const fn (ptr: *anyopaque, arena: std.mem.Allocator, request: ToolRequest) anyerror!ToolResult, + executeFn: *const fn ( + ptr: *anyopaque, + arena: std.mem.Allocator, + decision: Decision, + request: ToolRequest, + ) anyerror!ToolResult, - pub fn execute(self: Executor, arena: std.mem.Allocator, request: ToolRequest) !ToolResult { - return self.executeFn(self.ptr, arena, request); + pub fn execute( + self: Executor, + arena: std.mem.Allocator, + decision: Decision, + request: ToolRequest, + ) !ToolResult { + return self.executeFn(self.ptr, arena, decision, request); } }; +/// The executor that really runs things: files opened beneath the workspace by +/// the kernel, commands run from an argument vector with no shell, and the +/// decision re-checked against the request before any of that. +/// +/// This is what a deployment uses. `executor.Runner` refuses a decision whose +/// capability or resource differs from the request, so the check happens in the +/// component that performs the action rather than in the one that asked for it. +pub fn sandboxed(runner: *executor_mod.Runner) Executor { + return .{ .ptr = runner, .executeFn = runSandboxed }; +} + +fn runSandboxed( + ptr: *anyopaque, + arena: std.mem.Allocator, + decision: Decision, + request: ToolRequest, +) anyerror!ToolResult { + _ = arena; + const runner: *executor_mod.Runner = @ptrCast(@alignCast(ptr)); + return runner.run(decision, request); +} + pub const Step = struct { /// What this step is for, in the words shown to the person. description: []const u8, @@ -253,7 +293,25 @@ pub const Agent = struct { self.steps_denied += 1; return .{ .denied = pending.decision }; } - return .{ .completed = try self.run(pending.step, pending.decision) }; + + // The person's answer becomes its own decision. The pending one still + // says "ask a person", and an executor handed that would refuse it — + // correctly, because nobody had allowed anything at the moment it was + // made. What authorises the action is the answer, and the record says + // so, with the name of the person who gave it. + const allowed = try self.engine.allowedByPerson( + pending.decision.request, + .{ + .actor = self.actor.id, + .session = self.session, + .agent = self.id, + .agent_name = self.options.label, + .now = self.clock, + }, + given.decided_by, + given.outcome == .allow_always_here, + ); + return .{ .completed = try self.run(pending.step, allowed) }; } fn run(self: *Agent, step: Step, decision: Decision) !ToolResult { @@ -280,7 +338,11 @@ pub const Agent = struct { }); const before = self.clock; - const result = self.executor.execute(self.arena, request) catch |err| ToolResult{ + // The decision is spent here, not merely consulted. The executor + // re-derives the resource from the request and refuses unless it + // matches what was decided, so a decision to read one file cannot be + // spent on another even by the code holding it. + const result = self.executor.execute(self.arena, decision, request) catch |err| ToolResult{ .outcome = .failed, .summary = try std.fmt.allocPrint(self.arena, "The tool could not finish: {s}.", .{@errorName(err)}), }; @@ -335,7 +397,6 @@ pub const Agent = struct { else => {}, } - _ = decision; self.steps_run += 1; return result; } @@ -421,8 +482,18 @@ const RecordingExecutor = struct { calls: std.ArrayList([]const u8) = .empty, arena: std.mem.Allocator, - fn execute(ptr: *anyopaque, arena: std.mem.Allocator, request: ToolRequest) anyerror!ToolResult { + fn execute( + ptr: *anyopaque, + arena: std.mem.Allocator, + decision: Decision, + request: ToolRequest, + ) anyerror!ToolResult { const self: *RecordingExecutor = @ptrCast(@alignCast(ptr)); + // Even the test executor refuses a decision that does not authorise + // the request. One that ran whatever it was handed would let a runtime + // that stopped passing the decision pass every test in this file, and + // that is exactly how the check went missing before. + if (!try executor_mod.authorises(arena, decision, request)) return error.DecisionDoesNotMatchRequest; try self.calls.append(self.arena, request.toolName()); return .{ .outcome = .completed, @@ -609,3 +680,132 @@ test "the run produces provenance a reviewer can act on" { try testing.expect(record.completeness().has_model); try testing.expect(record.completeness().has_sources); } + +test "the runtime spends its decision, and the executor refuses one that does not match" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var gen: idmod.Generator = .init(701, 1_788_000_000_000); + var log = log_mod.Log.init(arena, 702); + const robot: event_mod.Actor = .{ .id = gen.next(idmod.ActorId), .kind = .agent, .label = "agent" }; + const session = gen.next(idmod.SessionId); + const now = try Timestamp.parseIso("2026-09-07T09:00:00Z"); + var engine = policy_mod.Engine.init(arena, try policy_mod.repositoryWriteNoNetwork("/repo", arena), 703); + + // An executor that keeps the decision it was handed, so the test can assert + // on what actually crossed the seam rather than on what the runtime meant. + const Witness = struct { + seen: ?Decision = null, + request_matched: bool = false, + + fn run( + ptr: *anyopaque, + scratch: std.mem.Allocator, + decision: Decision, + request: ToolRequest, + ) anyerror!ToolResult { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.seen = decision; + self.request_matched = try executor_mod.authorises(scratch, decision, request); + if (!self.request_matched) return error.DecisionDoesNotMatchRequest; + return .{ .outcome = .completed, .summary = "ran" }; + } + + fn executor(self: *@This()) Executor { + return .{ .ptr = self, .executeFn = run }; + } + }; + + var witness: Witness = .{}; + var agent = Agent.init(arena, &log, &engine, witness.executor(), session, robot, gen.next(idmod.AgentId), now, .{}); + _ = try agent.start("read a file", null); + + const step: Step = .{ + .description = "Read the notes.", + .request = .{ .read_file = .{ .path = "/repo/notes.md" } }, + }; + const outcome = try agent.perform(step); + try testing.expect(outcome == .completed); + + // A decision reached the executor at all. Before this was wired, the + // runtime obtained one and threw it away, and no test could tell. + const seen = witness.seen orelse return error.TestUnexpectedResult; + try testing.expect(seen.isAllowed()); + + // And it is the decision for this exact request: same capability, same + // resource, derived from the request rather than taken on trust. + try testing.expect(witness.request_matched); + try testing.expectEqual(capability_mod.Capability.@"fs.read", seen.request.capability); + try testing.expectEqualStrings("/repo/notes.md", seen.request.resource.text()); + + // The other half of the property: a decision for a different file does not + // authorise this request, whatever the code holding it intends. + const elsewhere: Decision = .{ + .id = gen.next(idmod.DecisionId), + .request = .{ .capability = .@"fs.read", .resource = .{ .path = "/etc/shadow" } }, + .effect = .allow, + .rule_id = null, + .policy_id = "test", + .reason = "forged", + .actor = robot.id, + .session = session, + .agent = null, + .decided_at = now, + }; + try testing.expect(!try executor_mod.authorises(arena, elsewhere, step.request)); +} + +test "a person's approval becomes a decision of its own, which is what gets spent" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var gen: idmod.Generator = .init(801, 1_788_000_000_000); + var log = log_mod.Log.init(arena, 802); + const person = gen.next(idmod.ActorId); + const robot: event_mod.Actor = .{ .id = gen.next(idmod.ActorId), .kind = .agent, .label = "agent" }; + const session = gen.next(idmod.SessionId); + const now = try Timestamp.parseIso("2026-09-07T09:00:00Z"); + var engine = policy_mod.Engine.init(arena, try policy_mod.repositoryWriteNoNetwork("/repo", arena), 803); + + var recording: RecordingExecutor = .{ .arena = arena }; + var agent = Agent.init(arena, &log, &engine, recording.executor(), session, robot, gen.next(idmod.AgentId), now, .{}); + _ = try agent.start("tidy up", null); + + const step: Step = .{ + .description = "Delete the build folder.", + .request = .{ .delete = .{ .path = "/repo/build", .recursive = true } }, + }; + const waiting = try agent.perform(step); + // The policy asked for a person, so nothing ran and the pending decision + // does not authorise anything. + try testing.expect(waiting == .waiting_for_person); + try testing.expectEqual(@as(usize, 0), recording.calls.items.len); + // The pending decision is the one the agent is holding while it waits. + const pending = agent.pending.?.decision; + try testing.expectEqual(policy_mod.Effect.require_human, pending.effect); + try testing.expect(!try executor_mod.authorises(arena, pending, step.request)); + + const before = engine.decisions.items.len; + const after = try agent.answer(.{ + .outcome = .allow_once, + .decided_by = person, + .decided_at = now, + .note = "Fine.", + }); + try testing.expect(after.completed.succeeded()); + try testing.expectEqual(@as(usize, 1), recording.calls.items.len); + + // The answer minted a new decision, and it is on the record with the name + // of the person who gave it. Reusing the pending one would have been an + // executor refusing to act, or worse, one that did not check. + try testing.expectEqual(before + 1, engine.decisions.items.len); + const approved = engine.decisions.items[engine.decisions.items.len - 1]; + try testing.expectEqual(policy_mod.Effect.allow, approved.effect); + try testing.expect(approved.rule_id == null); + try testing.expect(approved.actor.eql(person)); + try testing.expect(!approved.from_standing_grant); + try testing.expect(std.mem.indexOf(u8, approved.reason, "You allowed this") != null); + try testing.expect(try executor_mod.authorises(arena, approved, step.request)); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 29b24f2..df91d37 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1245,7 +1245,7 @@ fn askAModel( if (transcript.pending) |request| { try w.print("\nIt is waiting for you to allow: {s} on {s}.\n", .{ request.capability().explain(), - zag.ai.executor.resourceOf(request).text(), + (try request.resource(arena)).text(), }); try w.writeAll("Add a rule for it to the workspace policy, then run this again.\n"); } @@ -1722,6 +1722,48 @@ fn lifecycleReport(arena: std.mem.Allocator, w: *std.Io.Writer) !u8 { /// `zag evidence` — run the checks that can be run, record what each one /// established, and write the conformance statement that follows from it. +/// Hash the bytes of the artefact a piece of evidence is about. +/// +/// Evidence used to carry `Hash.of("vocabulary")` — the hash of the word, not +/// of the thing. That is worse than no hash: it looks like a fingerprint, it is +/// stable across every change to the file, and it would let a record about one +/// version of an artefact be presented as a record about another. +/// +/// A file that cannot be read produces the zero hash, which is the honest +/// answer: the artefact was not fingerprinted, and a reader can tell. +fn artefactHash(arena: std.mem.Allocator, io: std.Io, path: []const u8) !zag.core.hash.Hash { + const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 << 20)) catch { + return zag.core.hash.Hash.zero; + }; + return zag.core.hash.Hash.of(bytes); +} + +/// Hash the themes that were actually checked. +/// +/// The colours are compiled in, so there is no file to read; hashing their +/// values is what makes the record specific to the palette that passed. +fn themeHash(arena: std.mem.Allocator) !zag.core.hash.Hash { + var out: std.Io.Writer.Allocating = .init(arena); + inline for ([_]zag.accessibility.contrast.Theme{ + zag.accessibility.contrast.default_dark, + zag.accessibility.contrast.default_light, + }) |theme| { + try out.writer.writeAll(theme.name); + for (theme.pairs) |pair| { + try out.writer.print("|{s}:{x:0>2}{x:0>2}{x:0>2}:{x:0>2}{x:0>2}{x:0>2}", .{ + pair.name, + pair.foreground.r, + pair.foreground.g, + pair.foreground.b, + pair.background.r, + pair.background.g, + pair.background.b, + }); + } + } + return zag.core.hash.Hash.of(out.written()); +} + fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { const profile = zag.standards.profile.byId(options.profile) orelse { try w.print("\"{s}\" is not a profile zag knows. Run \"zag standards profiles\" to see them.\n", .{options.profile}); @@ -1734,7 +1776,10 @@ fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, optio var ledger = zag.standards.evidence.Ledger.init(arena); var ids: zag.core.id.Generator = .init(0x2026_0904, 1_788_000_000_000); const tool: zag.standards.evidence.ToolIdentity = .{ .name = "zag check", .version = zag.version }; - const now = zag.core.time.Timestamp.epoch; + // The time the evidence was produced, from the clock. It used to be the + // epoch, so every record claimed to have been made on the first of January + // 1970 and nothing could be ordered or expired. + const now = wallClock(io); // Terminology. const system = try zag.knowledge.vocabulary.build(arena); @@ -1747,7 +1792,7 @@ fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, optio .result = if (term_findings.items.len == 0) .pass else .fail, .tool = tool, .created_at = now, - .content_hash = zag.core.hash.Hash.of("vocabulary"), + .content_hash = try artefactHash(arena, io, "src/knowledge/vocabulary.zig"), .note = "Every concept was checked for a definition that is present, not circular and not negative.", }); try ledger.record(.{ @@ -1758,7 +1803,7 @@ fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, optio .result = if (term_findings.items.len == 0) .pass else .fail, .tool = tool, .created_at = now, - .content_hash = zag.core.hash.Hash.of("vocabulary"), + .content_hash = try artefactHash(arena, io, "src/knowledge/vocabulary.zig"), .note = "No preferred term designates two concepts.", }); @@ -1807,20 +1852,26 @@ fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, optio .result = if (contrast_failures == 0) .pass else .fail, .tool = tool, .created_at = now, - .content_hash = zag.core.hash.Hash.of("themes"), + .content_hash = try themeHash(arena), }); - const tree_findings = try zag.accessibility.semantic_tree.check(arena, zag.accessibility.semantic_tree.exampleTree(), .{}); + // Target size and name-role-value are properties of a user interface. This + // build has no renderer, so there is no interface to measure them on. The + // checker runs against an example tree, and a pass against a fixture is + // evidence about the fixture: recording it as a pass for the product would + // be the exact thing the conformance rules forbid. It is recorded as not + // tested, with the reason, until there is something to test. inline for (.{ "WCAG-2.2:R-TARGET-SIZE", "WCAG-2.2:R-NAME-ROLE-VALUE" }) |requirement_id| { try ledger.record(.{ .id = ids.next(zag.core.id.EvidenceId), .requirement_id = requirement_id, - .subject = "the published accessibility tree", + .subject = "no user interface exists yet", .method = .automated_test, - .result = if (tree_findings.items.len == 0) .pass else .fail, + .result = .not_tested, .tool = tool, .created_at = now, - .content_hash = zag.core.hash.Hash.of("tree"), + .content_hash = zag.core.hash.Hash.zero, + .note = "This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see.", }); } @@ -1834,7 +1885,7 @@ fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, optio .result = if ((try risk_register.review()).items.len == 0) .pass else .fail, .tool = tool, .created_at = now, - .content_hash = zag.core.hash.Hash.of("risks"), + .content_hash = try artefactHash(arena, io, "src/ai/risk.zig"), }); const assessment = try zag.ai.impact.workbenchAssessment(arena); try ledger.record(.{ @@ -1854,17 +1905,53 @@ fn evidenceReport(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, optio for (help_report.findings) |finding| { if (finding.severity == .blocking) blocking += 1; } - inline for (.{ "ISO-24495-1:R-ERROR-NEXT-STEP", "ISO-24495-1:R-LABEL-ACTION", "ISO-24495-1:R-UNDERSTANDABLE", "ISO-24495-1:R-FINDABLE" }) |requirement_id| { + // Each requirement answers for its own rule, not for "nothing blocking". + // + // Only two rules in the whole checker can produce a blocking finding, so + // "no blocking problem" was a much weaker statement than the `pass` beside + // it implied: a document could break sentence length, leave abbreviations + // undefined and label a button "OK", and still be recorded as passing four + // requirements. A machine reading the record sees the result, not the note. + // + // Two of these have a rule that checks them, so they are answered by that + // rule. The other two are about whether a reader can find and understand + // the text, which no checker establishes, so they are recorded as partly + // established and say what is missing. + const NarrowRule = struct { + requirement: []const u8, + rule: zag.language.plain.Rule, + }; + inline for ([_]NarrowRule{ + .{ .requirement = "ISO-24495-1:R-ERROR-NEXT-STEP", .rule = .error_without_next_step }, + .{ .requirement = "ISO-24495-1:R-LABEL-ACTION", .rule = .label_without_action }, + }) |checked| { + var breaches: usize = 0; + for (help_report.findings) |finding| { + if (finding.rule == checked.rule) breaches += 1; + } + try ledger.record(.{ + .id = ids.next(zag.core.id.EvidenceId), + .requirement_id = checked.requirement, + .subject = "the product's own text and the tool's help", + .method = .static_analysis, + .result = if (breaches == 0) .pass else .fail, + .tool = tool, + .created_at = now, + .content_hash = zag.core.hash.Hash.of(help_text), + .note = "A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems.", + }); + } + inline for (.{ "ISO-24495-1:R-UNDERSTANDABLE", "ISO-24495-1:R-FINDABLE" }) |requirement_id| { try ledger.record(.{ .id = ids.next(zag.core.id.EvidenceId), .requirement_id = requirement_id, .subject = "the product's own text and the tool's help", .method = .static_analysis, - .result = if (blocking == 0) .pass else .fail, + .result = if (blocking == 0) .partial else .fail, .tool = tool, .created_at = now, .content_hash = zag.core.hash.Hash.of(help_text), - .note = "The plain-language checker found no blocking problem. It cannot establish that a reader can act on the text.", + .note = "A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done.", }); } @@ -2278,6 +2365,18 @@ fn check(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Optio problems += 1; } + // 1b. Any file a standard points at has to be there. One entry named a + // requirements file that had never existed; the field was parsed, never + // read, and never checked, so nothing noticed. + for (registry.standards.values()) |standard| { + const named = standard.requirements_file orelse continue; + const full = try std.fs.path.join(arena, &.{ options.repo, named }); + dir.access(io, full, .{}) catch { + try w.print(" REGISTRY {s} names {s}, which is not there.\n", .{ standard.identifier, named }); + problems += 1; + }; + } + // 2. The vocabulary. const system = try zag.knowledge.vocabulary.build(arena); const term_findings = try system.validate(); diff --git a/src/core/id.zig b/src/core/id.zig index 62be102..3714747 100644 --- a/src/core/id.zig +++ b/src/core/id.zig @@ -172,17 +172,41 @@ pub const TaskId = TypedId("tsk"); pub const ToolCallId = TypedId("tcl"); pub const MetricId = TypedId("met"); -/// Deterministic identifier source. Tests and replays need reproducible -/// identifiers; production uses a seeded cryptographic source. +/// Where identifiers come from. +/// +/// The random half of an identifier is what keeps two of them apart when they +/// are minted in the same millisecond, so where the seed comes from decides +/// whether that works. A fixed seed makes a run reproducible, which is what a +/// test and a replay need. It is exactly wrong anywhere else: two workspaces +/// opened at the same moment with the same seed mint the same first identifier, +/// and a graph that looks events up by identifier then folds two different +/// events into one. +/// +/// So `init` is for tests, `secure` is for everything else, and the two are +/// named rather than distinguished by a default nobody reads. pub const Generator = struct { prng: std.Random.DefaultPrng, clock_ms: i64, step_ms: i64, + /// A reproducible source, for tests and replays. pub fn init(seed: u64, start_ms: i64) Generator { return .{ .prng = std.Random.DefaultPrng.init(seed), .clock_ms = start_ms, .step_ms = 1 }; } + /// A source seeded from the operating system. + /// + /// The seed is drawn once, from the same place a cryptographic key would + /// come from. What follows is still a fast pseudo-random sequence — these + /// identifiers order records and tell them apart, they are not secrets — + /// but two processes starting at the same instant no longer produce the + /// same one. + pub fn secure(io: std.Io, start_ms: i64) !Generator { + var seed_bytes: [8]u8 = undefined; + try io.randomSecure(&seed_bytes); + return init(std.mem.readInt(u64, &seed_bytes, .little), start_ms); + } + pub fn next(self: *Generator, comptime T: type) T { const id = T.generate(self.prng.random(), self.clock_ms); self.clock_ms += self.step_ms; diff --git a/src/core/time.zig b/src/core/time.zig index 50f4eef..fdc1e08 100644 --- a/src/core/time.zig +++ b/src/core/time.zig @@ -216,9 +216,19 @@ pub const Timestamp = struct { /// Instants cross interfaces as ISO 8601 strings, never as numbers, so /// that a stored value is readable without knowing our epoch or unit. + /// Written at nanosecond precision, because that is what is held. + /// + /// Memory carries nanoseconds; the record used to carry milliseconds, so a + /// timestamp did not survive being written and read back. An event recorded + /// at 22:13:20.123456789 came back as 22:13:20.123, and every duration + /// computed from two such times lost the difference. A record that rounds + /// what it was given is not a record of what happened. + /// + /// ISO 8601 allows nine fractional digits, so this is still an ordinary + /// timestamp that anything else can read. pub fn jsonStringify(self: Timestamp, jw: *std.json.Stringify) !void { var buf: [text_len_max]u8 = undefined; - try jw.write(self.toIso(&buf, .millisecond)); + try jw.write(self.toIso(&buf, .nanosecond)); } pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !Timestamp { @@ -588,10 +598,71 @@ test "instants and durations round trip through json" { var aw: std.Io.Writer.Allocating = .init(gpa); defer aw.deinit(); try std.json.Stringify.value(original, .{}, &aw.writer); - try std.testing.expectEqualStrings("{\"at\":\"2026-09-04T20:41:31.500Z\",\"took\":\"PT1H30M\"}", aw.written()); + // Nine fractional digits, always. A fixed width means one instant has one + // representation, which is what a record that is hashed needs: two writers + // of the same instant produce the same bytes. + try std.testing.expectEqualStrings( + "{\"at\":\"2026-09-04T20:41:31.500000000Z\",\"took\":\"PT1H30M\"}", + aw.written(), + ); const parsed = try std.json.parseFromSlice(Holder, gpa, aw.written(), .{}); defer parsed.deinit(); try std.testing.expectEqual(original.at.ns, parsed.value.at.ns); try std.testing.expectEqual(original.took.ns, parsed.value.took.ns); } + +test "a timestamp survives being written and read back, to the nanosecond" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The times a real clock produces, not the round ones a test invents. + for ([_]i64{ + 1_700_000_000_123_456_789, + 1_700_000_000_000_000_001, + 1_788_000_000_999_999_999, + 0, + -1_000_000_000, + }) |ns| { + const original: Timestamp = .{ .ns = ns }; + var out: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(original, .{}, &out.writer); + const parsed = try std.json.parseFromSlice(Timestamp, arena, out.written(), .{}); + try std.testing.expectEqual(original.ns, parsed.value.ns); + } + + // Two instants a fraction of a millisecond apart are two instants in the + // record, not one. They used to be the same text. + const a: Timestamp = .{ .ns = 1_700_000_000_123_000_000 }; + const b: Timestamp = .{ .ns = 1_700_000_000_123_456_789 }; + var first: std.Io.Writer.Allocating = .init(arena); + var second: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(a, .{}, &first.writer); + try std.json.Stringify.value(b, .{}, &second.writer); + try std.testing.expect(!std.mem.eql(u8, first.written(), second.written())); +} + +test "a duration survives the round trip at nanosecond precision" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Wall-clock durations, including ones whose fraction is not a dyadic + // rational. The parse goes through a float, so these are the values that + // would show it if that were lossy. + for ([_]i64{ + 1_005_000_123, + 1, + 999_999_999, + 3_600_000_000_007, + 0, + -250_000_001, + }) |ns| { + const original: Duration = .{ .ns = ns }; + var out: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(original, .{}, &out.writer); + const parsed = try std.json.parseFromSlice(Duration, arena, out.written(), .{}); + try std.testing.expectEqual(original.ns, parsed.value.ns); + } +} diff --git a/src/workspace/service.zig b/src/workspace/service.zig index 20a0851..6e52313 100644 --- a/src/workspace/service.zig +++ b/src/workspace/service.zig @@ -161,7 +161,12 @@ pub const Options = struct { /// Injected, so a replay produces the same log twice. now: Timestamp, /// Seed for the identifiers the service mints. - seed: u64 = 1, + /// + /// Left unset in a deployment, so the seed is drawn from the operating + /// system and two workspaces opened at the same instant do not mint the + /// same first identifier. A test sets it, because a replay that produces a + /// different log every time proves nothing. + seed: ?u64 = null, /// When false, the service holds the log in memory only. Tests use this. persist: bool = true, }; @@ -193,7 +198,12 @@ pub const Service = struct { /// Open a workspace: read the log, verify it, fold it, and read the /// knowledge base. Never repairs anything. pub fn open(arena: std.mem.Allocator, io: std.Io, options: Options) !Opened { - var log = log_mod.Log.init(arena, options.seed); + const seed: u64 = if (options.seed) |chosen| chosen else blk: { + var bytes: [8]u8 = undefined; + try io.randomSecure(&bytes); + break :blk std.mem.readInt(u64, &bytes, .little); + }; + var log = log_mod.Log.init(arena, seed); var created = false; var chain_break: ?log_mod.Break = null; var torn_bytes: usize = 0; @@ -207,7 +217,7 @@ pub const Service = struct { persisted_bytes = contents.len; persisted_fingerprint = hashing.Hash.of(contents); if (contents.len > 0) { - const loaded = try log_mod.Log.loadJsonLines(arena, contents, options.seed); + const loaded = try log_mod.Log.loadJsonLines(arena, contents, seed); log = loaded.log; torn_bytes = loaded.torn_bytes; malformed = loaded.malformed; @@ -248,8 +258,8 @@ pub const Service = struct { .log = log, .content = content_store_mod.Store.init(arena, options.root), .knowledge = knowledge, - .policy = policy_mod.Engine.init(arena, policy, options.seed), - .ids = idmod.Generator.init(options.seed, @divFloor(options.now.ns, timeutil.ns_per_ms)), + .policy = policy_mod.Engine.init(arena, policy, seed), + .ids = idmod.Generator.init(seed, @divFloor(options.now.ns, timeutil.ns_per_ms)), .persist = options.persist, .sealed = chain_break != null or torn_bytes > 0 or malformed != null, .persisted_bytes = persisted_bytes, @@ -1171,3 +1181,57 @@ test "history search runs against a service's own log" { try testing.expectEqual(@as(usize, 1), results.matchCount); try testing.expectEqualStrings("zig build test", results.hits[0].block.commandText.?); } + +test "two workspaces opened at the same instant do not mint the same identifiers" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var first_tmp = std.testing.tmpDir(.{}); + defer first_tmp.cleanup(); + var second_tmp = std.testing.tmpDir(.{}); + defer second_tmp.cleanup(); + + // The same instant, deliberately: the timestamp half of an identifier is + // then identical, so anything that keeps them apart has to come from the + // random half. With a fixed default seed, it did not. + const at = try Timestamp.parseIso("2026-09-07T10:00:00.000Z"); + + var first = (try Service.open(arena, io, .{ + .root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{first_tmp.sub_path}), + .actor = testActor(), + .now = at, + })).service; + var second = (try Service.open(arena, io, .{ + .root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{second_tmp.sub_path}), + .actor = testActor(), + .now = at, + })).service; + + const a = first.ids.next(idmod.SessionId); + const b = second.ids.next(idmod.SessionId); + try testing.expect(!a.eql(b)); + + // They still agree about when they were minted, which is the half that is + // meant to be the same. + try testing.expectEqual(a.timestampMillis(), b.timestampMillis()); + + // And a test that asks for a seed still gets the same identifiers twice, + // because a replay that cannot be repeated proves nothing. + var replay_one = (try Service.open(arena, io, .{ + .root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{first_tmp.sub_path}), + .actor = testActor(), + .now = at, + .seed = 42, + })).service; + var replay_two = (try Service.open(arena, io, .{ + .root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{second_tmp.sub_path}), + .actor = testActor(), + .now = at, + .seed = 42, + })).service; + try testing.expect(replay_one.ids.next(idmod.SessionId).eql(replay_two.ids.next(idmod.SessionId))); +} diff --git a/standards/registry/language.toml b/standards/registry/language.toml index e7dee18..8c1ea7c 100644 --- a/standards/registry/language.toml +++ b/standards/registry/language.toml @@ -12,7 +12,6 @@ status = "published" enforcement = "mandatory" valid_from = 2023-07-01T00:00:00Z purpose = "Product text must let the intended reader find, understand and use what they need." -requirements_file = "standards/registry/requirements-language.toml" [[standard]] namespace = "iso" From c423182895587027ef7331352ff2a929856132be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 02:18:10 +0000 Subject: [PATCH 04/12] The model path enters the record, and the record says who wrote it Four things the last review left open, and one bug found while closing them. zag ask talked to a provider, ran tools and printed to the screen without writing a single event. The only model path in a product built around an event log did not enter it. It now writes a session, the agent that started, every tool it asked for with the capability and resource, what the policy decided, and what happened - as one causal tree that zag why and the dependency graph read like any other work. Refusals are on the record: a run that wrote down only what it managed to do would be the less useful half of the story. The loop reports each moment as it reaches it rather than handing over a transcript at the end, so an interrupted run has still written what it did, and each moment is timed when it happened. Every event carried sixteen zero bytes as its actor, in all four production writers, so the envelope could not say who wrote it or even whether a person was involved. An actor is now derived from the account and machine names: the same person is the same actor in every workspace they open, two people differ, and the workbench acting on its own is a different actor of a different kind. It names an account, not a human being, and the file says so - nothing here verifies anything, and a deployment that needs evidence about somebody else needs a signed identity it does not have. Production writers set neither causedBy nor correlation, so the execution graph folded fields nobody filled and a real session came back as a flat list of unrelated roots. A session now has one root; output and a finish are caused by the command that produced them; everything carries the session as its correlation, so one unit of work is one query. The record format is pinned by golden vectors - lines this program produced, kept byte for byte. One test asserts a stored line still hashes to its stored hash, from the original bytes rather than a parsed and re-encoded version, which is the only direction that notices an encoder and a decoder drifting apart together. Another asserts the encoder still produces those exact bytes. Renaming one payload field now fails fourteen tests instead of silently re-interpreting every record ever written. A third test documents the threat the chain does not stop: a whole file rewritten with freshly computed hashes verifies perfectly. The bug: appending straight to service.log left the unwritten-event counter at zero, so the next flush returned early. The first version of the agent recorder reported writing seven events and left no file behind. Everything now goes through Service.record, which keeps the count, and a test fails if that is bypassed. Verified: 502 tests pass; each fix has a test that fails when the fix is reverted; zag check clean; zig fmt clean; four cross-compile targets build; a full agent run was driven against a local server and its record read back with zag events and zag why. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- README.md | 4 +- docs/roadmap.md | 18 +- docs/threat-model.md | 5 + evidence/conformance.jsonl | 34 ++-- src/ai/attention.zig | 11 +- src/ai/loop.zig | 117 +++++++++++- src/cli/main.zig | 139 ++++++++++++-- src/core/identity.zig | 215 ++++++++++++++++++++++ src/daemon/main.zig | 6 +- src/events/event.zig | 10 ++ src/events/format.zig | 211 ++++++++++++++++++++++ src/root.zig | 2 + src/terminal/session.zig | 101 ++++++++++- src/workspace/service.zig | 358 +++++++++++++++++++++++++++++++++++++ 14 files changed, 1179 insertions(+), 52 deletions(-) create mode 100644 src/core/identity.zig create mode 100644 src/events/format.zig diff --git a/README.md b/README.md index 49e0482..779d1c1 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ Here the workspace event stream owns the truth: - The interface does not own truth. It is a fold over the log. - Agents do not own truth. They propose typed requests; the log records what - happened. + happened. `zag ask` writes its whole run into the same log a command goes + into, refusals included. An agent run and a shell command read the same way + afterwards. - The pseudoterminal does not own truth. It produces bytes, which become events. Everything else follows from that. A block list, a session tab, an execution diff --git a/docs/roadmap.md b/docs/roadmap.md index 41c492e..369df2e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,7 +73,23 @@ of everything in it. What a model pays attention to is chosen by walking that graph from the event being asked about, nearest first. Recency is only a tie-break. -Four things in this list were not true until recently, and are worth naming +The model path enters the record. `zag ask` writes a session, the agent that +started, every tool it asked for, what the policy decided, and what happened. It +is one causal tree, which `zag why` and the dependency graph read like any other +work. Until now it talked to a provider, ran tools and printed to the screen +without writing a single event. + +Every event says who wrote it. The actor is derived from the account and the +machine name, so the same person is the same actor in every workspace they open. +The workbench acting on its own is a different actor of a different kind. Every +event used to carry sixteen zero bytes. + +The record format is pinned by golden vectors: lines this program produced, kept +byte for byte. Tests assert that a stored line still hashes to its stored hash, +and that the encoder still produces those exact bytes. A renamed field now fails +a test instead of silently re-interpreting every record ever written. + +Five things in this list were not true until recently, and are worth naming because the tests were green throughout. The agent runtime obtained a decision and discarded it, calling an executor that could not receive one. A second copy of "what resource does this request touch" disagreed with the first, so the two diff --git a/docs/threat-model.md b/docs/threat-model.md index 4d336a8..e5746b3 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -39,6 +39,8 @@ against that actor. | T1 | Repository text tells an agent that it has more permission | Permissions come only from typed policy; instruction files that try to grant permission are reported | Test every concrete executor against the decision it receives | | T14 | The decision is obtained and then not spent | The executor takes the decision as a parameter and refuses one that does not authorise the request, so a caller that does not pass the right one cannot act. The agent runtime's seam used to take only the request, and the runtime discarded the decision it had just obtained; four tests now fail if a decision stops being checked | Keep the seam shaped so a decision cannot be omitted. A new executor that ignores its decision parameter would not be caught by a type | | T15 | Two definitions of what a request touches | One definition, in `tools.ToolRequest.resource`. A second copy in the executor narrowed an execute request to its program name and collapsed a git push to a path, so a decision made on `git push origin main` could not authorise the request it was made for. The two halves of the security model had never agreed, because each was only ever tested against decisions it built itself | An end-to-end test from a written policy file through a decision to a real executor run | +| T17 | The record cannot say who acted | Every event carries an actor derived from the account and machine names, and a person, the workbench and an agent are three different actors in three different namespaces. Every event used to carry sixteen zero bytes, so the envelope could not answer the question an audit trail exists for | The identity is derived from an environment the person controls, so it names an account and is not evidence about a human being. A deployment that needs the second thing needs a signed identity | +| T18 | The record format changes underneath the hashes | Golden vectors pin the exact bytes this program writes, and a test asserts a stored line still hashes to its stored hash — the original bytes, not a parsed-and-re-encoded version, which is the only direction that notices the encoder and decoder drifting together | The vectors cover the shapes in use today. A payload kind added without a vector is unpinned until one is written | | T16 | A permission spreads to a resource its author never named | A rule's scope now covers only the axes it names. An allow rule scoped to hosts used to match `network.connect` on a command or a path resource, because an unnamed axis meant "no restriction on this axis". A refusal keeps the older, wider reading, so tightening this did not narrow any deny | Enforce at the socket as well: the host is checked before the request is built, not when the address is resolved | | T2 | A path uses `..` or a symlink to escape the workspace | Policy rejects lexical traversal and mismatched path roots; executors open every path through a workspace directory handle with `RESOLVE_BENEATH` and `RESOLVE_NO_MAGICLINKS`, so the kernel refuses an escaping path in any component; final event-log, object and recovery-evidence entries reject symbolic links | Only Linux enforces this. A build for another platform must refuse to execute rather than fall back to a lexical check | | T3 | A command string gains unintended shell meaning | Agent tool requests carry argument vectors, and the executor passes the vector straight to `execve` with no shell; a test asserts that an argument containing a semicolon and a redirect creates no file. The interactive `zag run` command is explicitly a person-requested shell command | Keep the assertion as a release gate; a future convenience that joins a vector back into a line would undo it | @@ -94,6 +96,9 @@ Review this model before merging a change that: - implements file write, delete, git push or remote execution; - sends workspace content to a model provider; - adds a connector, a wire format or a credential variable; +- appends an event anywhere other than through the service, which is what keeps + the unwritten-event count honest; +- changes how an actor identity is derived, or what it is derived from; - adds a tool to the set a model is offered, or changes how a tool call is parsed into a typed request; - joins an argument vector back into a command line; diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index 7beedd3..dbe1be3 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T01:52:58.137318563Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/ai/attention.zig b/src/ai/attention.zig index 186f25c..40f6cc7 100644 --- a/src/ai/attention.zig +++ b/src/ai/attention.zig @@ -326,7 +326,16 @@ pub fn writeEvent(envelope: Envelope, w: *std.Io.Writer) std.Io.Writer.Error!voi .agent_message => |e| try w.print("The agent said ({s}): {s}", .{ @tagName(e.role), e.text }), .agent_started => |e| try w.print("An agent started, for: {s}", .{e.request}), .tool_requested => |e| try w.print("A tool was asked for: {s} ({s})", .{ e.tool, e.capability }), - .tool_finished => |e| try w.print("That tool {s}. {s}", .{ @tagName(e.outcome), e.summary }), + .tool_finished => |e| { + try w.print("That tool {s}.", .{switch (e.outcome) { + .completed => "finished", + .failed => "did not work", + .denied => "was refused by the policy", + .cancelled => "was stopped", + .timed_out => "ran out of time", + }}); + if (e.summary.len > 0) try w.print(" {s}", .{e.summary}); + }, .approval_requested => |e| try w.print("You were asked: {s}", .{e.promptText}), .approval_resolved => |e| try w.print("You answered: {s}.", .{@tagName(e.outcome)}), .git_changed => |e| { diff --git a/src/ai/loop.zig b/src/ai/loop.zig index 7c18247..ed5ca6d 100644 --- a/src/ai/loop.zig +++ b/src/ai/loop.zig @@ -269,12 +269,76 @@ pub const stopped_clock: Clock = .{ }.now, }; +/// Something that happened, offered to whoever is keeping the record. +/// +/// The loop reports each of these as it reaches it, rather than handing over a +/// transcript at the end. A run that is interrupted has still written what it +/// did up to that point, and each moment is recorded at the time it happened +/// rather than all of them at the time the run stopped. +pub const Moment = union(enum) { + started: struct { + request: []const u8, + provider: []const u8, + model: []const u8, + policy: []const u8, + }, + /// The model said something. + said: struct { + role: enum { plan, progress, result, question, refusal }, + text: []const u8, + }, + /// A tool call became a typed request and a decision was reached. + asked: struct { + tool: []const u8, + capability: []const u8, + resource: []const u8, + argumentsJson: []const u8, + decision: ?Decision, + }, + /// The call is over, whether it ran or was refused. + settled: struct { + tool: []const u8, + outcome: tools.Outcome, + summary: []const u8, + resultHash: hashing.Hash, + }, + finished: struct { ending: Ending }, +}; + +/// Where the record of a run goes. +/// +/// Passed in rather than reached for, so the loop has no opinion about where a +/// workspace lives and a test can run the whole thing without one. A run with +/// no journal writes nothing, which is what the tests here do and what nothing +/// a person invokes should do: `zag ask` used to talk to a provider, run tools +/// and print to standard output without a single event, so the only model path +/// in a product built around an event log did not enter it. +pub const Journal = struct { + context: *anyopaque, + recordFn: *const fn (context: *anyopaque, moment: Moment) anyerror!void, + + pub fn record(self: Journal, moment: Moment) void { + // A record that could not be written must not stop the work, and must + // not be silently equivalent to one that was. The caller owns the + // journal and is the one that can tell a person; here the run carries + // on, because losing a tool result to a full disk is worse than losing + // a line of the log. + self.recordFn(self.context, moment) catch {}; + } +}; + pub const Runner = struct { arena: std.mem.Allocator, transport: transport_mod.Transport, executor: executor_mod.Runner, engine: *policy_mod.Engine, clock: Clock = stopped_clock, + /// Where this run is written down. None means nothing is written. + journal: ?Journal = null, + + fn note(self: Runner, moment: Moment) void { + if (self.journal) |journal| journal.record(moment); + } /// Run the loop until it ends, for one reason or another. pub fn run( @@ -285,6 +349,13 @@ pub const Runner = struct { ) Error!Transcript { const started = self.clock.now(); + self.note(.{ .started = .{ + .request = question, + .provider = options.connector.id, + .model = options.model, + .policy = self.engine.policy.id, + } }); + var messages: std.ArrayList(provider.Message) = .empty; try messages.append(self.arena, .{ .role = .user, @@ -329,7 +400,13 @@ pub const Runner = struct { usage.cacheWriteTokens += completion.usage.cacheWriteTokens; const said = try completion.text(self.arena); - if (said.len > 0) answer = said; + if (said.len > 0) { + answer = said; + self.note(.{ .said = .{ + .role = if (completion.stopReason == .refusal) .refusal else .progress, + .text = said, + } }); + } // The model's own turn goes into the conversation exactly as it // came, so that a tool result has a call to belong to. @@ -362,6 +439,7 @@ pub const Runner = struct { const step = try self.performOne(call, context, &seen, options.budget.repeats); try steps.append(self.arena, step); + self.recordStep(step); try results.append(self.arena, .{ .tool_result = .{ .toolUseId = call.id, .content = step.reply, @@ -408,6 +486,8 @@ pub const Runner = struct { } } + self.note(.{ .finished = .{ .ending = ending } }); + return .{ .ending = ending, .turns = turns.items, @@ -419,6 +499,41 @@ pub const Runner = struct { }; } + /// Write down one call, whether or not it ran. + /// + /// Both halves are recorded. A refused call is the interesting one: the + /// record has to show what was asked for and what the policy said, or a + /// person reading it later cannot tell a refusal from a call that was never + /// made. + fn recordStep(self: Runner, step: Step) void { + const request = step.request orelse { + // The name was not a tool. Nothing was decided and nothing ran, and + // saying so is more useful than silence. + self.note(.{ .settled = .{ + .tool = step.toolName, + .outcome = .denied, + .summary = step.reply, + .resultHash = hashing.Hash.zero, + } }); + return; + }; + const resource = request.resource(self.arena) catch capability_mod.Resource{ .none = {} }; + self.note(.{ .asked = .{ + .tool = step.toolName, + .capability = request.capability().text(), + .resource = resource.text(), + .argumentsJson = "", + .decision = step.decision, + } }); + const result = step.result orelse return; + self.note(.{ .settled = .{ + .tool = step.toolName, + .outcome = result.outcome, + .summary = if (result.summary.len > 0) result.summary else step.reply, + .resultHash = result.contentHash, + } }); + } + /// One tool call, all the way through. fn performOne( self: Runner, diff --git a/src/cli/main.zig b/src/cli/main.zig index df91d37..38b9977 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -317,7 +317,7 @@ fn run( .objects => try contentObjects(arena, io, w, options), .recover => try recoverWorkspace(arena, io, w, options), .agents => try agentsFile(arena, io, w, options), - .run => try runCommand(arena, io, w, options), + .run => try runCommand(arena, io, w, options, environment), .shell_hook => try shellHook(w, options), .workflow => try workflowReport(arena, w, options), .lifecycle => try lifecycleReport(arena, w), @@ -1006,12 +1006,21 @@ fn knowledgeIndex(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, optio } /// The actor the tool acts as when it opens a workspace of its own accord. +/// +/// This is the workbench doing its job, not a person asking for something, and +/// the record has to be able to tell those apart. fn workbenchActor() zag.events.event.Actor { - return .{ - .id = zag.core.id.ActorId.fromRaw(.{ .bytes = [_]u8{0} ** 16 }), - .kind = .system, - .label = "zag", - }; + return zag.events.event.Actor.of(zag.core.identity.program("zag")); +} + +/// The person running the tool. +/// +/// Derived from the account and machine names, so the same person is the same +/// actor in every workspace they open. Every event used to carry sixteen zero +/// bytes here, which meant the record could not say who wrote it or even +/// whether a person was involved. +fn personActor(arena: std.mem.Allocator, environment: []const []const u8) !zag.events.event.Actor { + return zag.events.event.Actor.of(try zag.core.identity.person(arena, environment)); } /// The wall clock, read through the platform's input and output layer rather @@ -1178,6 +1187,26 @@ fn askAModel( return 1; } + // The run is recorded in the workspace, like everything else. A model path + // that talked to a provider, ran tools and printed to standard output + // without writing a single event was the one place this product did not do + // what it says it does. + const opened = zag.workspace.service.Service.open(arena, io, .{ + .root = options.root, + .actor = try personActor(arena, environment), + .now = wallClock(io), + }) catch { + try w.print("zag could not open the workspace in {s}, so this run would not be recorded.\n", .{options.root}); + return 1; + }; + var service = opened.service; + if (!opened.report.logIsHealthy()) { + try opened.report.writeSummary(w); + try w.writeAll("Keep the file as it is. A broken log is evidence, not a fault to repair.\n"); + return 1; + } + var recorder = zag.workspace.service.Service.AgentRecorder.init(&service); + var engine = zag.ai.policy.Engine.init(arena, loaded.policy, @bitCast(wallClock(io).ns)); var identifiers = zag.core.id.Generator.init(@bitCast(wallClock(io).ns), 0); const context: zag.ai.policy.Context = .{ @@ -1210,6 +1239,7 @@ fn askAModel( .executor = executor, .engine = &engine, .clock = monotonic(io), + .journal = recorder.journal(), }; var budget: zag.ai.loop.Budget = .{}; @@ -1227,6 +1257,13 @@ fn askAModel( .budget = budget, }, question.items, context); + // The record is committed before anything is printed, so what a person + // reads on screen is what the log already holds. + service.flush(io) catch |err| { + try w.print("The run finished, but zag could not commit its record: {s}.\n", .{@errorName(err)}); + return 1; + }; + if (transcript.answer.len > 0) try w.print("{s}\n\n", .{transcript.answer}); for (transcript.turns) |turn| { @@ -1241,6 +1278,14 @@ fn askAModel( if (transcript.toolCallCount() > 0) try w.writeAll("\n"); try transcript.writeSummary(w); + if (recorder.failure) |problem| { + try w.print("Part of this run could not be recorded: {s}.\n", .{problem}); + } else { + try w.print("Recorded as {d} events in {s}/.workspace. Read it with \"zag why\".\n", .{ + recorder.recorded, + options.root, + }); + } if (transcript.pending) |request| { try w.print("\nIt is waiting for you to allow: {s} on {s}.\n", .{ @@ -1494,11 +1539,7 @@ fn interactiveTerminal( const opened = zag.workspace.service.Service.open(arena, io, .{ .root = options.root, - .actor = .{ - .id = zag.core.id.ActorId.fromRaw(.{ .bytes = [_]u8{0} ** 16 }), - .kind = .person, - .label = "you", - }, + .actor = try personActor(arena, environment), .now = wallClock(io), }) catch { try w.print("zag could not open the workspace in {s}.\n", .{options.root}); @@ -1625,7 +1666,13 @@ fn needsQuoting(argument: []const u8) bool { return false; } -fn runCommand(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { +fn runCommand( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, + environment: []const []const u8, +) !u8 { if (options.passthrough.len == 0) { try w.writeAll("Put the command after two dashes. For example: zag run -- echo hello\n"); return 2; @@ -1637,11 +1684,7 @@ fn runCommand(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: const opened = zag.workspace.service.Service.open(arena, io, .{ .root = options.root, - .actor = .{ - .id = zag.core.id.ActorId.fromRaw(.{ .bytes = [_]u8{0} ** 16 }), - .kind = .person, - .label = "you", - }, + .actor = try personActor(arena, environment), .now = wallClock(io), }) catch { try w.print("zag could not open the workspace in {s}.\n", .{options.root}); @@ -3021,7 +3064,7 @@ test "a command that fails is recorded as failed, with its status" { const status = try runCommand(arena, io, &w, .{ .root = root, .passthrough = &.{ "sh", "-c", "exit 3" }, - }); + }, &.{ "USER=tester", "HOSTNAME=test-machine" }); try testing.expectEqual(@as(u8, 3), status); try testing.expect(std.mem.indexOf(u8, w.buffered(), "[failed]") != null); @@ -3044,3 +3087,63 @@ test "a command that fails is recorded as failed, with its status" { } try testing.expect(saw_failure); } + +test "the record says who wrote it, and tells a person from the workbench" { + if (!zag.terminal.pty.supported) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + + var buffer: [8192]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + _ = try runCommand(arena, io, &w, .{ + .root = root, + .passthrough = &.{ "echo", "hello" }, + }, &.{ "USER=sean", "HOSTNAME=workshop" }); + + const path = try std.fmt.allocPrint(arena, "{s}/.workspace/events.jsonl", .{root}); + const source = try std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)); + const loaded = try zag.events.log.Log.loadJsonLines(arena, source, 1); + try testing.expect(loaded.log.entries.items.len > 0); + + const zero = zag.core.id.ActorId.fromRaw(.{ .bytes = [_]u8{0} ** 16 }); + const expected = try zag.core.identity.person(arena, &.{ "USER=sean", "HOSTNAME=workshop" }); + + for (loaded.log.entries.items) |entry| { + // Not the zero identifier, which is what every event used to carry. + try testing.expect(!entry.actor.id.eql(zero)); + // The person who ran it, and readable as such. + try testing.expect(entry.actor.id.eql(expected.id)); + try testing.expectEqual(zag.data.provenance.ProducerKind.person, entry.actor.kind); + try testing.expectEqualStrings("sean", entry.actor.label); + } + + // The same account on the same machine is the same actor in another + // workspace, so a person can be followed across their own work. + var elsewhere = std.testing.tmpDir(.{}); + defer elsewhere.cleanup(); + var second_buffer: [8192]u8 = undefined; + var second = std.Io.Writer.fixed(&second_buffer); + _ = try runCommand(arena, io, &second, .{ + .root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{elsewhere.sub_path}), + .passthrough = &.{ "echo", "again" }, + }, &.{ "USER=sean", "HOSTNAME=workshop" }); + const second_path = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}/.workspace/events.jsonl", .{elsewhere.sub_path}); + const second_source = try std.Io.Dir.cwd().readFileAlloc(io, second_path, arena, .limited(1 << 20)); + const second_log = try zag.events.log.Log.loadJsonLines(arena, second_source, 1); + try testing.expect(second_log.log.entries.items[0].actor.id.eql(expected.id)); + + // And the workbench acting on its own is a different actor, of a different + // kind. A governance framework that cannot tell those apart describes + // nothing. + const workbench = workbenchActor(); + try testing.expect(!workbench.id.eql(expected.id)); + try testing.expectEqual(zag.data.provenance.ProducerKind.system, workbench.kind); +} diff --git a/src/core/identity.zig b/src/core/identity.zig new file mode 100644 index 0000000..9c12aac --- /dev/null +++ b/src/core/identity.zig @@ -0,0 +1,215 @@ +//! Who wrote a record. +//! +//! Every event carries an actor, and until now every actor written by the +//! product was sixteen zero bytes. Three programs — the command-line tool, the +//! recording terminal and the daemon — all wrote the same identifier, so the +//! envelope could not answer the one question an audit trail exists to answer. +//! Worse, it could not distinguish a person from the workbench acting on its +//! own, which is the distinction the whole governance layer is built on. +//! +//! ## What an identity is here +//! +//! It is a stable name for *an account on a machine*, and nothing more. It is +//! derived, not stored: the same person on the same computer gets the same +//! identifier in every workspace, on the first run, with no file to create and +//! nothing to migrate. Two people on one machine differ. One person on two +//! machines differs, which is correct — those are two places work can happen +//! and the record should say which. +//! +//! ## What it is not +//! +//! It is not a claim about a human being. It cannot be, because nothing here +//! verifies anything: the name comes from the environment, and a person who +//! edits their own environment can write whatever they like into their own +//! record. That is fine for what this is for — reading back what happened on +//! your own machine — and it is not fine as evidence about somebody else. A +//! deployment that needs the second thing needs a signed identity, and does not +//! have one yet. +//! +//! ## Why it is derived rather than random +//! +//! A random identifier stored in the workspace would be stable too, and would +//! need a file, a first-run path, a migration, and an answer for what happens +//! when the file is deleted or copied along with the repository. Deriving it +//! from the account name and the machine name costs none of that, and gives the +//! property that actually matters: the same person is recognisably the same +//! person across every workspace they open. + +const std = @import("std"); +const idmod = @import("id.zig"); +const hashing = @import("hash.zig"); +const provenance = @import("../data/provenance.zig"); + +pub const ActorId = idmod.ActorId; + +/// An actor, ready to be put on an event. +pub const Identity = struct { + id: ActorId, + kind: provenance.ProducerKind, + /// What a person reads. Never used to decide anything. + label: []const u8, +}; + +/// Build an identifier from text, deterministically. +/// +/// The identifier is the first sixteen bytes of the hash of a namespaced +/// string. An actor is not an event, so nothing asks it when it was created, +/// and the timestamp bytes a generated identifier would carry are spent on more +/// hash instead. +pub fn idFrom(namespace: []const u8, name: []const u8) ActorId { + const digest = hashing.Hash.ofParts(&.{ namespace, name }); + var bytes: [16]u8 = undefined; + @memcpy(&bytes, digest.bytes[0..16]); + return ActorId.fromRaw(.{ .bytes = bytes }); +} + +/// Look a variable up in an environment given as `NAME=value` entries. +/// +/// The environment is passed in rather than read, so that this is testable and +/// so that nothing in the library reaches for the process on its own. +pub fn lookup(environment: []const []const u8, name: []const u8) ?[]const u8 { + for (environment) |entry| { + const equals = std.mem.indexOfScalar(u8, entry, '=') orelse continue; + if (!std.mem.eql(u8, entry[0..equals], name)) continue; + const value = entry[equals + 1 ..]; + return if (value.len > 0) value else null; + } + return null; +} + +/// The person at this computer. +/// +/// The account name comes from `USER`, then `LOGNAME`, then `USERNAME` for a +/// Windows shell. The machine name comes from `HOSTNAME` or `COMPUTERNAME`. +/// When neither is set the identity is still stable — it is the identity of +/// "somebody on a machine that does not say who it is", which is a true +/// statement and a useful one to be able to tell apart from a named account. +pub fn person(arena: std.mem.Allocator, environment: []const []const u8) !Identity { + const account = lookup(environment, "USER") orelse + lookup(environment, "LOGNAME") orelse + lookup(environment, "USERNAME"); + const machine = lookup(environment, "HOSTNAME") orelse + lookup(environment, "COMPUTERNAME"); + + const key = try std.fmt.allocPrint(arena, "{s}@{s}", .{ + account orelse "unnamed-account", + machine orelse "unnamed-machine", + }); + + return .{ + .id = idFrom("person", key), + .kind = .person, + // The label is what a person reads in their own record. Their own + // account name is the thing they will recognise; "you" is what to say + // when the environment did not offer one. + .label = account orelse "you", + }; +} + +/// The workbench itself, acting on its own behalf. +/// +/// Used for the events a program writes because it is doing its job rather than +/// because a person asked for something: opening a workspace, recording that a +/// check ran. Keeping this distinct from a person is the point — a governance +/// framework that cannot tell an automated action from a human one is not +/// describing anything. +pub fn program(name: []const u8) Identity { + return .{ + .id = idFrom("program", name), + .kind = .system, + .label = name, + }; +} + +/// An agent the workbench is running. +pub fn agent(label: []const u8) Identity { + return .{ + .id = idFrom("agent", label), + .kind = .agent, + .label = label, + }; +} + +const testing = std.testing; + +test "the same account on the same machine is the same actor, every time" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const environment = [_][]const u8{ "PATH=/usr/bin", "USER=sean", "HOSTNAME=workshop" }; + const first = try person(arena, &environment); + const second = try person(arena, &environment); + try testing.expect(first.id.eql(second.id)); + try testing.expectEqualStrings("sean", first.label); + try testing.expectEqual(provenance.ProducerKind.person, first.kind); + + // The identity does not depend on the order of the environment, or on what + // else is in it. + const shuffled = [_][]const u8{ "HOSTNAME=workshop", "TERM=xterm", "USER=sean" }; + try testing.expect(first.id.eql((try person(arena, &shuffled)).id)); +} + +test "two people, and two machines, are told apart" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const here = try person(arena, &.{ "USER=sean", "HOSTNAME=workshop" }); + const colleague = try person(arena, &.{ "USER=alex", "HOSTNAME=workshop" }); + const elsewhere = try person(arena, &.{ "USER=sean", "HOSTNAME=laptop" }); + + try testing.expect(!here.id.eql(colleague.id)); + // The same person on another machine is another place work happened, and + // the record should be able to say which. + try testing.expect(!here.id.eql(elsewhere.id)); +} + +test "an environment that says nothing still gives a stable identity" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const anonymous = try person(arena, &.{"PATH=/usr/bin"}); + const again = try person(arena, &.{}); + try testing.expect(anonymous.id.eql(again.id)); + try testing.expectEqualStrings("you", anonymous.label); + + // And it is not the zero identifier, which is what every event used to + // carry and what nothing should carry. + const zero = ActorId.fromRaw(.{ .bytes = [_]u8{0} ** 16 }); + try testing.expect(!anonymous.id.eql(zero)); + + // A named account is a different actor from an unnamed one, so a record + // cannot quietly merge them. + const named = try person(arena, &.{ "USER=sean", "HOSTNAME=workshop" }); + try testing.expect(!anonymous.id.eql(named.id)); +} + +test "a person, a program and an agent are never the same actor" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const human = try person(arena, &.{ "USER=zag", "HOSTNAME=zag" }); + const tool = program("zag"); + const robot = agent("zag"); + + // The names collide on purpose. The namespace is what keeps them apart, so + // an automated action can never be read as a human one. + try testing.expect(!human.id.eql(tool.id)); + try testing.expect(!human.id.eql(robot.id)); + try testing.expect(!tool.id.eql(robot.id)); + + try testing.expectEqual(provenance.ProducerKind.system, tool.kind); + try testing.expectEqual(provenance.ProducerKind.agent, robot.kind); +} + +test "an identifier is text a person can read back and compare" { + const tool = program("zagd"); + var buffer: [ActorId.text_len]u8 = undefined; + const text = tool.id.toText(&buffer); + try testing.expect(std.mem.startsWith(u8, text, "act_")); + const parsed = try ActorId.parse(text); + try testing.expect(tool.id.eql(parsed)); +} diff --git a/src/daemon/main.zig b/src/daemon/main.zig index 9aeffef..2ea1fa9 100644 --- a/src/daemon/main.zig +++ b/src/daemon/main.zig @@ -147,11 +147,7 @@ pub fn main(init: std.process.Init) !u8 { fn openWorkspace(arena: std.mem.Allocator, io: std.Io, options: Options) !zag.workspace.service.Opened { return zag.workspace.service.Service.open(arena, io, .{ .root = options.root, - .actor = .{ - .id = zag.core.id.ActorId.fromRaw(.{ .bytes = [_]u8{0} ** 16 }), - .kind = .system, - .label = "zagd", - }, + .actor = zag.events.event.Actor.of(zag.core.identity.program("zagd")), .now = wallClock(io), }); } diff --git a/src/events/event.zig b/src/events/event.zig index 0e0e87d..2e50a73 100644 --- a/src/events/event.zig +++ b/src/events/event.zig @@ -13,6 +13,7 @@ const std = @import("std"); const idmod = @import("../core/id.zig"); +const identity = @import("../core/identity.zig"); const hashing = @import("../core/hash.zig"); const timeutil = @import("../core/time.zig"); const provenance = @import("../data/provenance.zig"); @@ -33,6 +34,15 @@ pub const Actor = struct { kind: provenance.ProducerKind, /// Short display name. Never used for identity. label: []const u8 = "", + + /// The actor for a derived identity. + /// + /// `core/identity.zig` decides who somebody is; this turns that into the + /// three fields an event carries, so there is one definition of an actor + /// rather than one here and a copy there. + pub fn of(who: identity.Identity) Actor { + return .{ .id = who.id, .kind = who.kind, .label = who.label }; + } }; pub const SessionOpened = struct { diff --git a/src/events/format.zig b/src/events/format.zig new file mode 100644 index 0000000..29b5928 --- /dev/null +++ b/src/events/format.zig @@ -0,0 +1,211 @@ +//! The record format, pinned. +//! +//! Every hash in every workspace is computed from the JSON encoding of an +//! event. That encoding is produced by `std.json` from Zig struct definitions, +//! which means the format is decided by things nobody thinks of as format +//! decisions: the order fields are declared in, whether an optional writes +//! `null` or is omitted, how a float is rendered, what a new field is called. +//! +//! Change any of them and every historical `contentHash` becomes wrong. Not +//! detectably wrong — the log still verifies, because verification recomputes +//! the hash the same new way it wrote it. Wrong in the sense that a record +//! written last year and a record written today no longer agree about what +//! their bytes were, and the chain that was supposed to prove nothing changed +//! cannot tell you that something did. +//! +//! So the format is pinned here, as bytes. These lines were produced by this +//! program, and the tests below assert three things about them: +//! +//! * **The bytes still come out the same.** A field renamed, reordered or +//! added shows up as a failing string comparison rather than as a silent +//! re-interpretation of history. +//! * **The stored line hashes to its stored hash.** Not the parsed-and- +//! re-encoded line — the original bytes, as they sit on disk. That is the +//! only version of this check that would notice the encoder and the decoder +//! drifting apart together. +//! * **A line survives the round trip.** Parsed back into memory and encoded +//! again, it produces the identical bytes, so reading and writing a +//! workspace does not change it. +//! +//! When the format has to change, these vectors change with it, and that is the +//! point: it becomes a deliberate edit in a file called `format.zig` with a +//! commit message, rather than a side effect of touching a struct. + +const std = @import("std"); +const event_mod = @import("event.zig"); +const log_mod = @import("log.zig"); +const hashing = @import("../core/hash.zig"); + +/// One recorded line, exactly as this program writes it. +pub const Vector = struct { + /// What the line is an example of, for whoever has to update it. + describes: []const u8, + line: []const u8, +}; + +/// Lines this program produced, kept byte for byte. +/// +/// They cover the shapes that carry the format's awkward parts: a nanosecond +/// timestamp whose fraction is not round, a duration with nine significant +/// digits, an optional that is null and an optional that is not, a boolean, an +/// integer, and a payload with fields left at their defaults. +pub const vectors = [_]Vector{ + .{ + .describes = "a session opening: optionals at null, defaults written out", + .line = + \\{"id":"evt_01M16HNP3VTQEZCJATF659CD62","sequence":1,"at":"2026-08-29T10:40:00.123456789Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":null,"correlation":null,"payload":{"session_opened":{"session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","workingDirectory":"/repo","shell":"/bin/sh","columns":80,"rows":24,"repository":null,"branch":null,"worktree":null}},"contentHash":"b3:1d1cc14bf8dea5198705781508ecd52e541b556769df3a1b96a81ae874d6b069","chainHash":"b3:da9d3690614de05e0dca077479c2e608dfe26561a4c99d3c6061591fe1d6aadd"} + , + }, + .{ + .describes = "a command submitted: a cause, a boolean, text with spaces", + .line = + \\{"id":"evt_01M16HNPZ8KW76FPRZ4HN6TQ1D","sequence":2,"at":"2026-08-29T10:40:01.000000001Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":"evt_01M16HNP3VTQEZCJATF659CD62","correlation":null,"payload":{"command_submitted":{"block":"blk_01M16HNP02V3CWC3WY02PGW2MV","session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","commandText":"zig build test","workingDirectory":"/repo","boundaryFromShell":true}},"contentHash":"b3:81c85e9032caaf95ea00f3290c830bf429bde3b28152623f329644edb76668e8","chainHash":"b3:e3532fa8d6809f221f40a32142da732ef668e40ce8d08a015668d0b4133196a6"} + , + }, + .{ + .describes = "a command finishing: a correlation, a duration to the nanosecond, a non-zero status", + .line = + \\{"id":"evt_01M16HNRXQV3CWC3WY02PGW2MV","sequence":3,"at":"2026-08-29T10:40:02.999999999Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":"evt_01M16HNPZ8KW76FPRZ4HN6TQ1D","correlation":"evt_01M16HNP3VTQEZCJATF659CD62","payload":{"command_finished":{"block":"blk_01M16HNP02V3CWC3WY02PGW2MV","session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","exitStatus":3,"duration":"PT1.005000123S","signal":null}},"contentHash":"b3:d7cc09ee8e0022a78d7181c973fdbf3f50927ae5743f3f33cec5023c8667ef2c","chainHash":"b3:e38a2588a5d5e2707399e71430e0a95f73ff88273f795ab4ad2f4a51e5fcb2cb"} + , + }, +}; + +/// The three lines above, as one file. +pub fn document(arena: std.mem.Allocator) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + for (vectors) |vector| { + try out.appendSlice(arena, vector.line); + try out.append(arena, '\n'); + } + return out.items; +} + +const testing = std.testing; + +test "a stored line hashes to the hash stored in it" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The bytes on disk, not a parsed-and-re-encoded version of them. An + // encoder and a decoder that drifted apart *together* would agree with each + // other and disagree with every line ever written, and only this direction + // of the check would notice. + for (vectors) |vector| { + const parsed = try std.json.parseFromSlice(std.json.Value, arena, vector.line, .{}); + const stored_content = parsed.value.object.get("contentHash").?.string; + const stored_chain = parsed.value.object.get("chainHash").?.string; + + const envelope = try std.json.parseFromSlice(event_mod.Envelope, arena, vector.line, .{}); + const recomputed = try envelope.value.computeContentHash(arena); + + var buffer: [hashing.Hash.text_len]u8 = undefined; + try testing.expectEqualStrings(stored_content, recomputed.toText(&buffer)); + try testing.expect(stored_chain.len > 3); + } +} + +test "the encoder still produces these exact bytes" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Read each line in and write it straight back out. Any change to a field + // name, a field order, how an optional is written, or how a timestamp or a + // duration is rendered lands here as a string that does not match. + // + // If this test fails and the change was intended, the vectors above are + // updated in the same commit, and the commit message says the record format + // changed. That is the whole mechanism. + for (vectors) |vector| { + const envelope = try std.json.parseFromSlice(event_mod.Envelope, arena, vector.line, .{}); + var out: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(envelope.value, .{}, &out.writer); + try testing.expectEqualStrings(vector.line, out.written()); + } +} + +test "the pinned log loads, verifies, and keeps its chain" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const source = try document(arena); + const loaded = try log_mod.Log.loadJsonLines(arena, source, 1); + try testing.expectEqual(@as(usize, vectors.len), loaded.recovered); + try testing.expectEqual(@as(usize, 0), loaded.torn_bytes); + try testing.expect(loaded.malformed == null); + + // The chain a workspace written years ago still checks out under today's + // code. This is the property the whole record rests on. + try testing.expect(try loaded.log.verify() == null); + + // And writing it back out gives the same file, byte for byte. + var out: std.Io.Writer.Allocating = .init(arena); + try loaded.log.writeJsonLines(&out.writer); + try testing.expectEqualStrings(source, out.written()); +} + +test "a changed byte in a stored line is caught" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The exit status of the third line, edited from 3 to 0: the difference + // between a record of a failure and a record of a success. + const tampered = try std.mem.replaceOwned( + u8, + arena, + try document(arena), + "\"exitStatus\":3", + "\"exitStatus\":0", + ); + const loaded = try log_mod.Log.loadJsonLines(arena, tampered, 1); + const broken = try loaded.log.verify(); + try testing.expect(broken != null); + try testing.expectEqual(@as(usize, 2), broken.?.index); +} + +test "a whole file rewritten with freshly computed hashes still verifies" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // This is the threat the chain does *not* defend against, written down so + // that nobody reads the test above as more than it is. + // + // Editing one entry and leaving its hash stale is caught, and that catches + // a clumsy editor or a partial write. Someone who can rewrite the file can + // also recompute every hash from the genesis label, and the result verifies + // perfectly. Local hashing proves internal consistency; it does not prove + // the file was not replaced. + // + // What would close it is a head signed by a key the writer does not hold, + // or a witness outside the machine. Neither exists here yet, and the + // documentation says so rather than implying the chain is tamper-proof. + const source = try document(arena); + const loaded = try log_mod.Log.loadJsonLines(arena, source, 1); + + var rewritten = log_mod.Log.init(arena, 1); + for (loaded.log.entries.items) |entry| { + var changed = entry.payload; + switch (changed) { + .command_finished => |*e| e.exitStatus = 0, + else => {}, + } + _ = try rewritten.append(changed, .{ + .at = entry.at, + .actor = entry.actor, + .causedBy = entry.causedBy, + .correlation = entry.correlation, + }); + } + + // Every hash was recomputed, so it verifies. The record says a failing + // command succeeded, and nothing in the file can tell you otherwise. + try testing.expect(try rewritten.verify() == null); + + // What *is* different is the chain head, which is why an external witness + // is the thing that would close this. + try testing.expect(!rewritten.head.eql(loaded.log.head)); +} diff --git a/src/root.zig b/src/root.zig index 2bac4eb..7b0e27f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -12,6 +12,7 @@ pub const core = struct { pub const hash = @import("core/hash.zig"); pub const scan = @import("core/scan.zig"); pub const id = @import("core/id.zig"); + pub const identity = @import("core/identity.zig"); pub const time = @import("core/time.zig"); }; @@ -70,6 +71,7 @@ pub const events = struct { pub const log = @import("events/log.zig"); pub const graph = @import("events/graph.zig"); pub const lineage = @import("events/lineage.zig"); + pub const format = @import("events/format.zig"); pub const content_store = @import("events/content_store.zig"); pub const recovery = @import("events/recovery.zig"); }; diff --git a/src/terminal/session.zig b/src/terminal/session.zig index ec40fe3..5b93253 100644 --- a/src/terminal/session.zig +++ b/src/terminal/session.zig @@ -12,6 +12,7 @@ const pty_mod = @import("pty.zig"); const integration = @import("shell_integration.zig"); const event_mod = @import("../events/event.zig"); const log_mod = @import("../events/log.zig"); +const graph_mod = @import("../events/graph.zig"); const content_store_mod = @import("../events/content_store.zig"); const idmod = @import("../core/id.zig"); const hashing = @import("../core/hash.zig"); @@ -44,9 +45,19 @@ pub const Session = struct { child: ?pty_mod.Child = null, child_exit: ?pty_mod.Exit = null, timed_out: bool = false, + /// The event that opened this session. + /// + /// Everything the session writes carries it as a correlation, so one unit + /// of work is one query rather than a guess from timestamps. Without it the + /// execution graph folds a field nobody fills and a real session comes back + /// as a flat list of unrelated roots. + session_event: event_mod.EventId, /// The block currently collecting output, if any. open_block: ?idmod.BlockId = null, open_block_started: ?timeutil.Timestamp = null, + /// The event that submitted the open block. Output and the finish are + /// caused by it, which is what makes a block one subtree of the graph. + open_block_event: ?event_mod.EventId = null, ids: idmod.Generator, clock: timeutil.Timestamp, /// Bytes of output collected for the open block. @@ -65,7 +76,7 @@ pub const Session = struct { options: Options, clock: timeutil.Timestamp, ) !Session { - const session: Session = .{ + var session: Session = .{ .arena = arena, .id = id, .screen = try grid.Screen.init(arena, options.columns, options.rows, options.scrollback_limit), @@ -78,14 +89,16 @@ pub const Session = struct { .content_store = options.content_store, .ids = idmod.Generator.init(@bitCast(clock.ns), @divFloor(clock.ns, timeutil.ns_per_ms)), .clock = clock, + .session_event = undefined, }; - _ = try log.append(.{ .session_opened = .{ + const opened = try log.append(.{ .session_opened = .{ .session = id, .workingDirectory = options.working_directory, .shell = options.shell, .columns = options.columns, .rows = options.rows, } }, .{ .at = clock, .actor = options.actor }); + session.session_event = opened.id; return session; } @@ -178,13 +191,19 @@ pub const Session = struct { self.open_block = block; self.open_block_started = self.clock; self.output = .empty; - _ = try self.log.append(.{ .command_submitted = .{ + const submitted = try self.log.append(.{ .command_submitted = .{ .block = block, .session = self.id, .commandText = try self.arena.dupe(u8, command), .workingDirectory = self.tracker.working_directory orelse "", .boundaryFromShell = true, - } }, .{ .at = self.clock, .actor = self.actor }); + } }, .{ + .at = self.clock, + .actor = self.actor, + .causedBy = self.session_event, + .correlation = self.session_event, + }); + self.open_block_event = submitted.id; }, .command_finished => |f| { try self.finishOpenBlock(f.exit_status orelse 0, null); @@ -193,7 +212,12 @@ pub const Session = struct { _ = try self.log.append(.{ .git_changed = .{ .session = self.id, .repository = path, - } }, .{ .at = self.clock, .actor = self.actor }); + } }, .{ + .at = self.clock, + .actor = self.actor, + .causedBy = self.open_block_event orelse self.session_event, + .correlation = self.session_event, + }); }, else => {}, } @@ -211,7 +235,12 @@ pub const Session = struct { .session = self.id, .contentHash = content_hash, .byteCount = self.output.items.len, - } }, .{ .at = self.clock, .actor = self.actor }); + } }, .{ + .at = self.clock, + .actor = self.actor, + .causedBy = self.open_block_event orelse self.session_event, + .correlation = self.session_event, + }); } const started = self.open_block_started orelse self.clock; _ = try self.log.append(.{ .command_finished = .{ @@ -220,9 +249,15 @@ pub const Session = struct { .exitStatus = exit_status, .duration = self.clock.since(started), .signal = signal, - } }, .{ .at = self.clock, .actor = self.actor }); + } }, .{ + .at = self.clock, + .actor = self.actor, + .causedBy = self.open_block_event orelse self.session_event, + .correlation = self.session_event, + }); self.open_block = null; self.open_block_started = null; + self.open_block_event = null; self.output = .empty; self.capture_escape_start = null; self.capture_osc_start = null; @@ -253,7 +288,12 @@ pub const Session = struct { .session = self.id, .exitStatus = details.status, .reason = reason, - } }, .{ .at = self.clock, .actor = self.actor }); + } }, .{ + .at = self.clock, + .actor = self.actor, + .causedBy = self.session_event, + .correlation = self.session_event, + }); } pub fn didTimeOut(self: Session) bool { @@ -553,3 +593,48 @@ test "a chatty process that ignores TERM still meets the hard deadline" { try testing.expect(found_finish); try testing.expect((try log.verify()) == null); } + +test "everything a session writes belongs to the session that wrote it" { + if (!pty_mod.supported) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var log = log_mod.Log.init(arena, 91); + var gen = idmod.Generator.init(92, 0); + const id = gen.next(idmod.SessionId); + var session = try Session.init(arena, &log, id, .{ + .working_directory = "/tmp", + .shell = "/bin/sh", + .actor = .{ .id = gen.next(idmod.ActorId), .kind = .person, .label = "you" }, + }, .{ .ns = 1_700_000_000 * timeutil.ns_per_s }); + + try session.run( + "/bin/sh", + &.{ "/bin/sh", "-c", "printf hello" }, + &.{"PATH=/bin:/usr/bin"}, + "/tmp", + timeutil.Duration.fromSeconds(10), + ); + try session.close("done"); + + // One root: the session opening. Everything else descends from it, so one + // unit of work is one query rather than a guess from timestamps. Before + // this, production writers set neither field and a real session came back + // as a flat list of unrelated roots. + const graph = try graph_mod.Graph.build(arena, log); + const roots = try graph.roots(); + try testing.expectEqual(@as(usize, 1), roots.items.len); + try testing.expectEqual(@as(usize, 0), roots.items[0]); + + const opened = log.entries.items[0]; + try testing.expect(opened.causedBy == null); + for (log.entries.items[1..]) |entry| { + const correlation = entry.correlation orelse return error.TestUnexpectedResult; + try testing.expect(correlation.eql(opened.id)); + } + + // The whole session comes back from one correlation. + const run = try graph.run(opened.id); + try testing.expectEqual(log.entries.items.len, run.items.len); +} diff --git a/src/workspace/service.zig b/src/workspace/service.zig index 6e52313..614b5d7 100644 --- a/src/workspace/service.zig +++ b/src/workspace/service.zig @@ -17,6 +17,7 @@ const std = @import("std"); const event_mod = @import("../events/event.zig"); const log_mod = @import("../events/log.zig"); +const graph_mod = @import("../events/graph.zig"); const content_store_mod = @import("../events/content_store.zig"); const block_mod = @import("block.zig"); const model_mod = @import("model.zig"); @@ -25,6 +26,7 @@ const workflow_mod = @import("workflow.zig"); const base_mod = @import("../knowledge/base.zig"); const vocabulary_mod = @import("../knowledge/vocabulary.zig"); const policy_mod = @import("../ai/policy.zig"); +const loop_mod = @import("../ai/loop.zig"); const capability_mod = @import("../ai/capability.zig"); const session_mod = @import("../terminal/session.zig"); const idmod = @import("../core/id.zig"); @@ -360,6 +362,24 @@ pub const Service = struct { /// Append new records after checking the exact on-disk prefix under an /// exclusive advisory lock. Content objects are made durable first, so a /// committed event never points to an object that was only in memory. + /// Append an event and remember that it has not been written out yet. + /// + /// Everything that adds to the log goes through here. Appending straight to + /// `self.log` compiles and looks right, and quietly leaves `unflushed` at + /// zero, so the next flush returns early and the events stay in memory + /// until the process ends. That is how a run can report having recorded + /// seven events and leave no file behind. + pub fn record( + self: *Service, + payload: event_mod.WorkspaceEvent, + options: log_mod.AppendOptions, + ) !event_mod.Envelope { + if (self.sealed) return error.LogBroken; + const envelope = try self.log.append(payload, options); + self.unflushed += 1; + return envelope; + } + pub fn flush(self: *Service, io: std.Io) !void { if (!self.persist) return; try self.content.flush(io); @@ -527,6 +547,186 @@ pub const Service = struct { }; } + /// Record an agent run into this workspace. + /// + /// One session, one agent, one causal tree: the agent's start is the root, + /// every tool call is caused by it, and each call's outcome is caused by + /// the request that asked for it. That is the same shape a recorded + /// command has, so `zag why`, the block list and the dependency graph all + /// work on an agent run without knowing it was one. + /// + /// The recorder is handed to `ai.loop.Runner`, which reports each moment as + /// it reaches it. Nothing is written at the end, so a run that is + /// interrupted has still written what it did. + pub const AgentRecorder = struct { + service: *Service, + session: idmod.SessionId, + agent: idmod.AgentId, + /// The event that started the run. Everything else carries it. + started: ?event_mod.EventId = null, + /// The most recent tool request, which its outcome is caused by. + pending_call: ?event_mod.EventId = null, + pending_id: ?idmod.ToolCallId = null, + /// Written down so a caller can say how much reached the record. + recorded: usize = 0, + /// Set when an append failed. The run carries on; a person is told. + failure: ?[]const u8 = null, + /// The session event everything in the run is correlated to. + correlation: ?event_mod.EventId = null, + + pub fn init(service: *Service) AgentRecorder { + return .{ + .service = service, + .session = service.ids.next(idmod.SessionId), + .agent = service.ids.next(idmod.AgentId), + .started = null, + .pending_call = null, + .pending_id = null, + }; + } + + pub fn journal(self: *AgentRecorder) loop_mod.Journal { + return .{ .context = self, .recordFn = write }; + } + + fn write(context: *anyopaque, moment: loop_mod.Moment) anyerror!void { + const self: *AgentRecorder = @ptrCast(@alignCast(context)); + self.append(moment) catch |err| { + if (self.failure == null) self.failure = @errorName(err); + return err; + }; + self.recorded += 1; + } + + fn append(self: *AgentRecorder, moment: loop_mod.Moment) !void { + const service = self.service; + const actor = service.actor; + const at = service.clock; + + switch (moment) { + .started => |e| { + const opened = try service.record(.{ .session_opened = .{ + .session = self.session, + .workingDirectory = service.root, + } }, .{ .at = at, .actor = actor }); + const begun = try service.record(.{ .agent_started = .{ + .agent = self.agent, + .session = self.session, + .request = try service.arena.dupe(u8, e.request), + .provider = try service.arena.dupe(u8, e.provider), + .model = try service.arena.dupe(u8, e.model), + .policy = try service.arena.dupe(u8, e.policy), + } }, .{ + .at = at, + .actor = actor, + .causedBy = opened.id, + .correlation = opened.id, + }); + self.started = begun.id; + // The correlation for the whole run is the session, so one + // query returns it all. + self.correlation = opened.id; + }, + .said => |e| { + _ = try service.record(.{ .agent_message = .{ + .agent = self.agent, + .session = self.session, + .role = switch (e.role) { + .plan => .plan, + .progress => .progress, + .result => .result, + .question => .question, + .refusal => .refusal, + }, + .text = try service.arena.dupe(u8, e.text), + } }, .{ + .at = at, + .actor = actor, + .causedBy = self.started, + .correlation = self.correlation, + }); + }, + .asked => |e| { + const call = service.ids.next(idmod.ToolCallId); + self.pending_id = call; + const requested = try service.record(.{ .tool_requested = .{ + .call = call, + .agent = self.agent, + .session = self.session, + .tool = try service.arena.dupe(u8, e.tool), + .capability = try service.arena.dupe(u8, e.capability), + .argumentsJson = try service.arena.dupe(u8, e.argumentsJson), + .resource = try service.arena.dupe(u8, e.resource), + } }, .{ + .at = at, + .actor = actor, + .causedBy = self.started, + .correlation = self.correlation, + }); + self.pending_call = requested.id; + + // A decision that asked for a person is an approval on the + // record, not a footnote in a summary. + if (e.decision) |decision| { + if (decision.effect == .require_human) { + _ = try service.record(.{ .approval_requested = .{ + .approval = decision.id, + .session = self.session, + .agent = self.agent, + .capability = try service.arena.dupe(u8, e.capability), + .resource = try service.arena.dupe(u8, e.resource), + .promptText = try service.arena.dupe(u8, decision.reason), + .policy = try service.arena.dupe(u8, decision.policy_id), + } }, .{ + .at = at, + .actor = actor, + .causedBy = requested.id, + .correlation = self.correlation, + }); + } + } + }, + .settled => |e| { + const call = self.pending_id orelse service.ids.next(idmod.ToolCallId); + _ = try service.record(.{ .tool_finished = .{ + .call = call, + .agent = self.agent, + .session = self.session, + .outcome = switch (e.outcome) { + .completed => .completed, + .failed => .failed, + .denied => .denied, + .cancelled => .cancelled, + .timed_out => .timed_out, + }, + .duration = .{ .ns = 0 }, + .resultHash = e.resultHash, + .summary = try service.arena.dupe(u8, e.summary), + } }, .{ + .at = at, + .actor = actor, + .causedBy = self.pending_call orelse self.started, + .correlation = self.correlation, + }); + self.pending_call = null; + self.pending_id = null; + }, + .finished => |e| { + _ = try service.record(.{ .session_closed = .{ + .session = self.session, + .exitStatus = if (e.ending.succeeded()) 0 else 1, + .reason = e.ending.text(), + } }, .{ + .at = at, + .actor = actor, + .causedBy = self.started, + .correlation = self.correlation, + }); + }, + } + } + }; + // --- Workflows --------------------------------------------------------- pub const NodeOutcome = struct { @@ -1235,3 +1435,161 @@ test "two workspaces opened at the same instant do not mint the same identifiers })).service; try testing.expect(replay_one.ids.next(idmod.SessionId).eql(replay_two.ids.next(idmod.SessionId))); } + +test "a recorded command is one subtree, from the session down to its output" { + if (!@import("../terminal/pty.zig").supported) return error.SkipZigTest; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + + var service = (try Service.open(arena, io, .{ + .root = root, + .actor = testActor(), + .now = .{ .ns = 1_700_000_000 * timeutil.ns_per_s }, + })).service; + _ = try service.runCommand("printf hello", timeutil.Duration.fromSeconds(10)); + + const graph = try graph_mod.Graph.build(arena, service.log); + + // One root, and it is the session. Not one root per event. + const roots = try graph.roots(); + try testing.expectEqual(@as(usize, 1), roots.items.len); + const opened = service.log.entries.items[roots.items[0]]; + try testing.expect(opened.payload == .session_opened); + + // Output and the finish hang off the command that produced them, which is + // what makes a block a subtree rather than three events that happen to + // share an identifier in their payload. + var submitted_index: ?usize = null; + var children: usize = 0; + for (service.log.entries.items, 0..) |entry, index| { + switch (entry.payload) { + .command_submitted => submitted_index = index, + .process_output, .command_finished => { + const parent = graph.nodes[index].parent orelse return error.TestUnexpectedResult; + try testing.expectEqual(submitted_index.?, parent); + // And it is reachable from the session, through the command. + try testing.expect(graph.causedBy(index, roots.items[0])); + children += 1; + }, + else => {}, + } + } + try testing.expect(children >= 2); + + // "Why did this happen" reads forwards, from the session to the event. + const last = service.log.entries.items.len - 1; + const chain = try graph.ancestry(last); + try testing.expect(chain.items.len >= 2); + try testing.expectEqual(roots.items[0], chain.items[0]); +} + +test "an agent run is recorded as one causal tree, refusals included" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + + var service = (try Service.open(arena, io, .{ + .root = root, + .actor = testActor(), + .now = .{ .ns = 1_700_000_000 * timeutil.ns_per_s }, + })).service; + + var recorder = Service.AgentRecorder.init(&service); + const journal = recorder.journal(); + + journal.record(.{ .started = .{ + .request = "read notes.txt then delete it", + .provider = "ollama", + .model = "llama3.2", + .policy = "workspace", + } }); + journal.record(.{ .asked = .{ + .tool = "read_file", + .capability = "fs.read", + .resource = "notes.txt", + .argumentsJson = "", + .decision = null, + } }); + journal.record(.{ .settled = .{ + .tool = "read_file", + .outcome = .completed, + .summary = "Read 12 bytes from notes.txt.", + .resultHash = hashing.Hash.of("twelve bytes"), + } }); + journal.record(.{ .asked = .{ + .tool = "delete", + .capability = "fs.delete", + .resource = "notes.txt", + .argumentsJson = "", + .decision = null, + } }); + journal.record(.{ .settled = .{ + .tool = "delete", + .outcome = .denied, + .summary = "No rule in this policy allows it.", + .resultHash = hashing.Hash.zero, + } }); + journal.record(.{ .finished = .{ .ending = .answered } }); + + try testing.expect(recorder.failure == null); + try testing.expect(recorder.recorded > 0); + + // The events are counted as unwritten, so they reach the file. Appending + // straight to the log leaves this at zero and the flush returns early. + try testing.expect(service.unflushed > 0); + try service.flush(io); + + const path = try std.fmt.allocPrint(arena, "{s}/.workspace/events.jsonl", .{root}); + const source = try std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)); + const loaded = try log_mod.Log.loadJsonLines(arena, source, 1); + try testing.expect(try loaded.log.verify() == null); + + // One root, and everything hangs off it. + const graph = try graph_mod.Graph.build(arena, loaded.log); + const roots = try graph.roots(); + try testing.expectEqual(@as(usize, 1), roots.items.len); + + var started: ?usize = null; + var requests: usize = 0; + var refused: usize = 0; + for (loaded.log.entries.items, 0..) |entry, index| { + switch (entry.payload) { + .agent_started => started = index, + .tool_requested => { + requests += 1; + // Every call is caused by the agent that made it. + try testing.expectEqual(started.?, graph.nodes[index].parent.?); + }, + .tool_finished => |e| { + if (e.outcome == .denied) refused += 1; + // And every outcome is caused by the request that asked for it. + const parent = graph.nodes[index].parent orelse return error.TestUnexpectedResult; + try testing.expect(loaded.log.entries.items[parent].payload == .tool_requested); + }, + else => {}, + } + } + try testing.expectEqual(@as(usize, 2), requests); + // The refusal is on the record. A run that only wrote down what it managed + // to do would be the least useful half of the story. + try testing.expectEqual(@as(usize, 1), refused); + + // The whole run comes back from one correlation. + const run = try graph.run(loaded.log.entries.items[roots.items[0]].id); + try testing.expectEqual(loaded.log.entries.items.len, run.items.len); +} From 9d0e69cecde3f4d8d145a87c13b1ce1952cbc2fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 02:45:25 +0000 Subject: [PATCH 05/12] Stop the record misdescribing what happened, and close the typed-tool bypass Everything the last two reviews left open, and four things found while closing them. Three ways the record was wrong about its own contents. A block whose output arrived in pieces kept the last piece's hash beside the summed byte count, so it claimed a hash covering bytes the hash had never seen; output is now recorded as the pieces it was, each hash covering its own bytes, ordered by the chunk index the event has always carried and the fold never read. A change of directory was written as git_changed with the directory as the repository, so a history search by repository returned directories that were never repositories; there is now a directory_changed event, because OSC 7 says where the shell is and knows nothing about version control. An agent run had no finish, so a workspace of completed runs displayed every one as still going; agent_finished carries the outcome, the duration, the tool calls and what the run cost. The security hole was that deleting and pushing are refused as typed requests and were reachable by running the program. The typed refusal was the one that could not run and the way around it was the one that could. An agent now has to ask for the typed request, and may not run a shell at all - "there is no shell" is a property this design claims, and sh -c puts one back. A person running a shell from the command line is unaffected; that path never touches an executor. An approval prompt interpolated the resource unquoted, so a path containing a newline could push the real operation out of view and put a reassuring sentence where the question belongs. Control characters are now shown as escapes: a strange path looks strange rather than looking tidy and being wrong. Found while doing this: zag check never loaded the requirements register at all. Every profile that named a requirement was checked against an empty set, so eleven dangling identifiers had gone unnoticed - including two exclusions that were excluding nothing. One of them, PL006, is a plain-language rule and not a requirement, which is why it never matched; exclusions now say which kind of thing they name. The six requirements that were referenced and never written are written. Also: transparency gaps printed as OPEN and were not counted, so the build stayed green while listing what it had not done. They are counted now, which turned the build red on one real gap - so the gap is closed with docs/if-something-is-wrong.md, a page for people affected by work done with zag who do not use it. zagd serve claimed to hold a workspace open for clients and has no socket; it is called watch and says what it does, with serve kept as a name so nothing breaks. zag term ignored a torn or malformed log that zag run refuses. Two log size limits disagreed by a factor of four. Verified: 531 tests pass; zag check clean; zig fmt clean; four cross-compile targets build; a full agent run recorded and read back; an agent asking to run sh -c and rm was refused both times and the file survived. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- README.md | 6 + docs/if-something-is-wrong.md | 66 ++++++++ docs/roadmap.md | 16 ++ evidence/conformance.jsonl | 34 ++-- schemas/block.schema.json | 55 ++++++ schemas/openapi.json | 151 +++++++++++++++++ schemas/workspace-event.schema.json | 96 +++++++++++ src/ai/approval.zig | 103 +++++++++++- src/ai/attention.zig | 9 + src/ai/documentation.zig | 13 +- src/ai/executor.zig | 132 ++++++++++++++- src/ai/loop.zig | 109 +++++++++++- src/ai/transparency.zig | 26 ++- src/cli/main.zig | 69 +++++++- src/daemon/main.zig | 22 ++- src/events/event.zig | 40 ++++- src/language/plain.zig | 11 ++ src/standards/profile.zig | 48 +++++- src/terminal/session.zig | 6 +- src/workspace/block.zig | 241 ++++++++++++++++++++++++++- src/workspace/service.zig | 63 ++++++- standards/registry/requirements.toml | 105 ++++++++++++ 22 files changed, 1364 insertions(+), 57 deletions(-) create mode 100644 docs/if-something-is-wrong.md diff --git a/README.md b/README.md index 779d1c1..5cac651 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,12 @@ request -> plan -> typed tool request -> policy decision - A repository instruction file shapes behaviour. It can never grant a capability, and the checker reports any line that tries. +## If something here is wrong + +`docs/if-something-is-wrong.md` is for anyone affected by work done with zag, +whether or not they use it. It says how to read the record, how to raise a +problem, and what this project can and cannot do about it. + ## Reading further - `docs/architecture.md` — the event graph, the block model and the control plane diff --git a/docs/if-something-is-wrong.md b/docs/if-something-is-wrong.md new file mode 100644 index 0000000..8ce22f1 --- /dev/null +++ b/docs/if-something-is-wrong.md @@ -0,0 +1,66 @@ +# If something here is wrong + +This page is for anyone affected by work done with zag. The page is written for +people who have never used it. You can read it without an account and without +knowing the tool. + +## What this tool does, in one paragraph + +zag is a terminal that records what happens in it. A person runs commands in a +folder on their own computer. A model can run them too, under rules that person +wrote. Everything it does goes into a file in that folder, including the things +it was refused. Anything sent to a model is sent because the person wrote a +setting saying so. + +## If a change to your work looks wrong + +Ask the person who ran it for the record. The record is a plain text file at +`.workspace/events.jsonl`, in the folder they were working in. These commands +read it: + +``` +zag events .workspace/events.jsonl # everything that happened, as a tree +zag why .workspace/events.jsonl # what a failure depended on +zag history "status:failed" # search what was run +``` + +The record says who ran each thing, when, what the rules allowed, and what was +refused. An edit to it shows up as an edit, so what it says is what happened. + +A person who keeps the record to themselves is making their own choice. zag +holds no copy elsewhere and can compel nobody. + +## If a model was involved + +The record names the provider and the model for each request. It also names every action +the model asked for, with what the rules decided. A model here does only what +the person's own rules allow, and the rules stay beyond its reach. So if a model +did something that surprises you, the question is which rule allowed it. +`zag policy` prints those rules in words. + +## To raise it with the people who make zag + +Open an issue on the repository. You do not need to know Zig or to have used the +tool. Say what happened, and say what you expected instead. Where the problem +touches private work belonging to someone else, describe its shape and leave +their record out. + +For anything that looks like a security fault, `SECURITY.md` says how to report +it privately instead. + +## What we can and cannot do about it + +We can change the tool. We can narrow a rule that was too broad. We can make a +refusal clearer. We can make the record say something it was leaving out. Each +of those has happened because somebody told us. + +Three things stay beyond us. We cannot undo a change on another person's +computer. We cannot get their record for you. We cannot settle a disagreement +about their work. zag runs on their machine, under their rules, and keeps +nothing centrally. The person who ran it is the one who can answer for it. + +## What this page does not promise + +It promises no response time, no appeal, and nobody on call. A small number of +people make this tool in the open. What it promises is a written route, a record +that exists and can be read, and a page that says plainly where the route ends. diff --git a/docs/roadmap.md b/docs/roadmap.md index 369df2e..fb454e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -98,6 +98,22 @@ rule scoped to hosts also allowed requests on any other kind of resource. And `::1` never matched a rule that named it, because the host matcher split on the first colon. Each now has a test that fails if the fix is removed. +Several things the record used to say were untrue, and are worth naming. A block +whose output arrived in pieces kept the last piece's hash beside the summed byte +count. It claimed a hash covering bytes the hash had never seen. A change of +directory was written as a repository moving, so a search by repository returned +directories. An agent run had no finish at all, so a workspace of completed runs +displayed every one of them as still going. + +Two more were claims rather than records. The daemon said it held a workspace +open for clients, and it has no socket. `zag check` never loaded the requirements +register. Every profile naming a requirement was therefore checked against an +empty set, and eleven dangling identifiers went unnoticed. + +Deleting and pushing were refused as typed requests and reachable by running +the program, so the typed refusal was the one that could not run. An agent now +has to use the typed request, and may not run a shell at all. + These statements are covered by automated tests. They do not establish power-loss survival on every filesystem, hostile tamper resistance, a safe garbage collector, a complete terminal, a graphical product or organisational diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index dbe1be3..d423e95 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:16:09.349732981Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/schemas/block.schema.json b/schemas/block.schema.json index e85f0b7..1782d9d 100644 --- a/schemas/block.schema.json +++ b/schemas/block.schema.json @@ -224,6 +224,18 @@ "hashed" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "chunked": { + "$ref": "#/$defs/ContentRef__struct_b8951747" + } + }, + "required": [ + "chunked" + ], + "additionalProperties": false } ] }, @@ -244,6 +256,49 @@ ], "additionalProperties": false }, + "ContentRef__struct_b8951747": { + "type": "object", + "properties": { + "chunks": { + "type": "array", + "items": { + "$ref": "#/$defs/Chunk" + } + }, + "byteCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "chunks", + "byteCount" + ], + "additionalProperties": false + }, + "Chunk": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "byteCount": { + "type": "integer", + "minimum": 0 + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "required": [ + "hash", + "byteCount", + "index" + ], + "additionalProperties": false + }, "GitContext": { "type": "object", "properties": { diff --git a/schemas/openapi.json b/schemas/openapi.json index 9d3562f..89f20d0 100644 --- a/schemas/openapi.json +++ b/schemas/openapi.json @@ -850,6 +850,18 @@ "hashed" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "chunked": { + "$ref": "#/components/schemas/ContentRef__struct_b8951747" + } + }, + "required": [ + "chunked" + ], + "additionalProperties": false } ] }, @@ -870,6 +882,49 @@ ], "additionalProperties": false }, + "ContentRef__struct_b8951747": { + "type": "object", + "properties": { + "chunks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Chunk" + } + }, + "byteCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "chunks", + "byteCount" + ], + "additionalProperties": false + }, + "Chunk": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "byteCount": { + "type": "integer", + "minimum": 0 + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "required": [ + "hash", + "byteCount", + "index" + ], + "additionalProperties": false + }, "GitContext": { "type": "object", "properties": { @@ -1146,6 +1201,18 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "agent_finished": { + "$ref": "#/components/schemas/AgentFinished" + } + }, + "required": [ + "agent_finished" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -1218,6 +1285,18 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "directory_changed": { + "$ref": "#/components/schemas/DirectoryChanged" + } + }, + "required": [ + "directory_changed" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -1533,6 +1612,62 @@ "refusal" ] }, + "AgentFinished": { + "type": "object", + "properties": { + "agent": { + "type": "string" + }, + "session": { + "type": "string" + }, + "outcome": { + "$ref": "#/components/schemas/AgentFinished__enum_9fb94bae" + }, + "duration": { + "type": "string" + }, + "toolCalls": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "refusedCalls": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "inputTokens": { + "type": "integer", + "minimum": 0 + }, + "outputTokens": { + "type": "integer", + "minimum": 0 + }, + "summary": { + "type": "string" + } + }, + "required": [ + "agent", + "session", + "outcome", + "duration" + ], + "additionalProperties": false + }, + "AgentFinished__enum_9fb94bae": { + "type": "string", + "enum": [ + "answered", + "refused", + "stopped_by_policy", + "waiting_for_a_person", + "out_of_budget", + "unavailable" + ] + }, "ToolRequested": { "type": "object", "properties": { @@ -1770,6 +1905,22 @@ "expired" ] }, + "DirectoryChanged": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "session", + "path" + ], + "additionalProperties": false + }, "GitChanged": { "type": "object", "properties": { diff --git a/schemas/workspace-event.schema.json b/schemas/workspace-event.schema.json index fe89433..7fe63c4 100644 --- a/schemas/workspace-event.schema.json +++ b/schemas/workspace-event.schema.json @@ -179,6 +179,18 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "agent_finished": { + "$ref": "#/$defs/AgentFinished" + } + }, + "required": [ + "agent_finished" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -251,6 +263,18 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "directory_changed": { + "$ref": "#/$defs/DirectoryChanged" + } + }, + "required": [ + "directory_changed" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -566,6 +590,62 @@ "refusal" ] }, + "AgentFinished": { + "type": "object", + "properties": { + "agent": { + "type": "string" + }, + "session": { + "type": "string" + }, + "outcome": { + "$ref": "#/$defs/AgentFinished__enum_9fb94bae" + }, + "duration": { + "type": "string" + }, + "toolCalls": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "refusedCalls": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "inputTokens": { + "type": "integer", + "minimum": 0 + }, + "outputTokens": { + "type": "integer", + "minimum": 0 + }, + "summary": { + "type": "string" + } + }, + "required": [ + "agent", + "session", + "outcome", + "duration" + ], + "additionalProperties": false + }, + "AgentFinished__enum_9fb94bae": { + "type": "string", + "enum": [ + "answered", + "refused", + "stopped_by_policy", + "waiting_for_a_person", + "out_of_budget", + "unavailable" + ] + }, "ToolRequested": { "type": "object", "properties": { @@ -803,6 +883,22 @@ "expired" ] }, + "DirectoryChanged": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "session", + "path" + ], + "additionalProperties": false + }, "GitChanged": { "type": "object", "properties": { diff --git a/src/ai/approval.zig b/src/ai/approval.zig index 2039e0e..57bf472 100644 --- a/src/ai/approval.zig +++ b/src/ai/approval.zig @@ -90,10 +90,53 @@ const once_only_options = [_]Option{ /// command line as it will run, the remote's address rather than its local /// alias. A reader cannot judge "push to origin" without knowing where origin /// points. +/// Make text safe to put in front of a person. +/// +/// Every control character becomes a visible escape, and text longer than a +/// line is cut with the cut marked. What a person is asked about has to be what +/// they see, and a resource that can move the cursor is a resource that can +/// change the question. +/// +/// The replacement is visible rather than silent: a path that really does +/// contain a newline shows as `\n`, so a person sees something strange and can +/// refuse, instead of seeing a tidy path that is not the one being asked about. +pub fn readable(arena: std.mem.Allocator, text: []const u8) ![]const u8 { + const limit = 200; + var out: std.ArrayList(u8) = .empty; + var index: usize = 0; + while (index < text.len and out.items.len < limit) : (index += 1) { + const byte = text[index]; + switch (byte) { + '\n' => try out.appendSlice(arena, "\\n"), + '\r' => try out.appendSlice(arena, "\\r"), + '\t' => try out.appendSlice(arena, "\\t"), + 0x1b => try out.appendSlice(arena, "\\e"), + 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1a, 0x1c...0x1f, 0x7f => { + var escape: [4]u8 = undefined; + const hex = "0123456789abcdef"; + escape[0] = '\\'; + escape[1] = 'x'; + escape[2] = hex[byte >> 4]; + escape[3] = hex[byte & 0x0f]; + try out.appendSlice(arena, &escape); + }, + else => try out.append(arena, byte), + } + } + if (index < text.len) try out.appendSlice(arena, "... (cut)"); + return out.items; +} + pub fn build(arena: std.mem.Allocator, decision: Decision, agent_label: []const u8) !Prompt { const request = decision.request; - const subject = request.resource.text(); - const label = if (agent_label.len > 0) agent_label else "The agent"; + // The resource is text an agent chose, and it lands in the middle of a + // question a person answers. A path containing a newline could push the + // real operation off the top of what they read and put a reassuring + // sentence where the question should be; an escape sequence could colour + // it, move the cursor, or erase the line above. Neither is a hypothetical + // shape for a filename to have when something else picked it. + const subject = try readable(arena, request.resource.text()); + const label = try readable(arena, if (agent_label.len > 0) agent_label else "The agent"); const question = try questionFor(arena, request.capability, subject); const explanation = try std.fmt.allocPrint(arena, "{s} wants to {s}.", .{ label, try wantsTo(arena, request.capability, subject) }); @@ -295,3 +338,59 @@ test "every option label starts with the action it performs" { try testing.expect(option.meaning.len > 0); } } + +test "a resource cannot reshape the question a person is answering" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A path an agent chose, containing a newline and an escape sequence. Put + // straight into a prompt it would push the real operation out of view and + // write a reassuring line where the question should be. + const hostile = "notes.md\nThis is safe to allow.\x1b[2K\x1b[A"; + const decision: Decision = .{ + .id = idmod.DecisionId.fromRaw(.{ .bytes = [_]u8{7} ** 16 }), + .request = .{ .capability = .@"fs.delete", .resource = .{ .path = hostile } }, + .effect = .require_human, + .rule_id = null, + .policy_id = "test", + .reason = "Deleting cannot be undone.", + .actor = idmod.ActorId.fromRaw(.{ .bytes = [_]u8{1} ** 16 }), + .session = null, + .agent = null, + .decided_at = .{ .ns = 0 }, + }; + + const prompt = try build(arena, decision, "an agent"); + var buffer: [4096]u8 = undefined; + var w = std.Io.Writer.fixed(&buffer); + try prompt.writeText(&w); + const text = w.buffered(); + + // Nothing that moves a cursor or breaks a line reaches the screen. + try testing.expect(std.mem.indexOfScalar(u8, text, 0x1b) == null); + try testing.expect(std.mem.indexOf(u8, text, "\nThis is safe to allow.") == null); + + // What a person sees instead is the path with its oddities visible, so it + // looks wrong rather than looking tidy and being wrong. + try testing.expect(std.mem.indexOf(u8, text, "\\n") != null); + try testing.expect(std.mem.indexOf(u8, text, "\\e") != null); + // The operation is still named. + try testing.expect(std.mem.indexOf(u8, text, "notes.md") != null); +} + +test "readable keeps ordinary text exactly, and cuts what is too long" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // An ordinary path is untouched. A check that mangled normal input would + // be worse than none. + try testing.expectEqualStrings("src/main.zig", try readable(arena, "src/main.zig")); + try testing.expectEqualStrings("a file with spaces.txt", try readable(arena, "a file with spaces.txt")); + // Text beyond a line is cut, and the cut is marked. + const long = "x" ** 400; + const cut = try readable(arena, long); + try testing.expect(cut.len < long.len); + try testing.expect(std.mem.endsWith(u8, cut, "(cut)")); +} diff --git a/src/ai/attention.zig b/src/ai/attention.zig index 40f6cc7..0a84605 100644 --- a/src/ai/attention.zig +++ b/src/ai/attention.zig @@ -325,6 +325,14 @@ pub fn writeEvent(envelope: Envelope, w: *std.Io.Writer) std.Io.Writer.Error!voi .user_message => |e| try w.print("You said: {s}", .{e.text}), .agent_message => |e| try w.print("The agent said ({s}): {s}", .{ @tagName(e.role), e.text }), .agent_started => |e| try w.print("An agent started, for: {s}", .{e.request}), + .agent_finished => |e| { + try w.print("The agent {s}, after ", .{@tagName(e.outcome)}); + try notation.writeDuration(w, e.duration); + try w.print(", with {d} tool call", .{e.toolCalls}); + if (e.toolCalls != 1) try w.writeAll("s"); + if (e.refusedCalls > 0) try w.print(" ({d} refused)", .{e.refusedCalls}); + try w.writeAll("."); + }, .tool_requested => |e| try w.print("A tool was asked for: {s} ({s})", .{ e.tool, e.capability }), .tool_finished => |e| { try w.print("That tool {s}.", .{switch (e.outcome) { @@ -338,6 +346,7 @@ pub fn writeEvent(envelope: Envelope, w: *std.Io.Writer) std.Io.Writer.Error!voi }, .approval_requested => |e| try w.print("You were asked: {s}", .{e.promptText}), .approval_resolved => |e| try w.print("You answered: {s}.", .{@tagName(e.outcome)}), + .directory_changed => |e| try w.print("The shell moved to {s}.", .{e.path}), .git_changed => |e| { try w.print("The repository moved", .{}); if (e.branch) |branch| try w.print(" on {s}", .{branch}); diff --git a/src/ai/documentation.zig b/src/ai/documentation.zig index d170cd0..b96a9e2 100644 --- a/src/ai/documentation.zig +++ b/src/ai/documentation.zig @@ -236,9 +236,18 @@ pub fn writeSystemCard(facts: SystemFacts, w: *std.Io.Writer) !void { try w.print("Ask a question or report a problem: {s}.\n", .{facts.contact}); try w.writeAll("Every claim here can be checked against the workspace log, which you hold.\n"); + // This section is always here, whichever way it reads. A card that showed + // the heading only when something was missing would go quiet exactly when a + // reader most wants to know the question was asked, and its absence would + // be indistinguishable from nobody having checked. const unmet = try transparency.unmetObligations(facts.risks.arena); - if (unmet.items.len > 0) { - try w.writeAll("\n## What this system does not yet answer\n"); + try w.writeAll("\n## What this system does not yet answer\n"); + if (unmet.items.len == 0) { + try w.writeAll("Every question in the transparency list has an answer, and each one says where:\n"); + for (transparency.workbench_obligations) |obligation| { + try w.print(" - {s} {s}\n", .{ obligation.topic.question(), obligation.answered_by }); + } + } else { for (unmet.items) |obligation| { try w.print(" - {s} {s}\n", .{ obligation.topic.question(), obligation.gap }); } diff --git a/src/ai/executor.zig b/src/ai/executor.zig index d7a0e22..69c218f 100644 --- a/src/ai/executor.zig +++ b/src/ai/executor.zig @@ -49,6 +49,12 @@ pub const Error = error{ NotAllowed, /// This build has no executor for that request. NoExecutor, + /// The program asked for has a typed request of its own, and must be asked + /// for that way so the right capability is decided. + UseTheTypedRequest, + /// An agent asked to run a shell, which would put back the thing this + /// design removed. + NoShellForAgents, /// The kernel cannot enforce containment on this machine. ContainmentUnavailable, } || std.mem.Allocator.Error; @@ -59,6 +65,8 @@ pub fn refusalText(err: Error) []const u8 { error.DecisionDoesNotMatchRequest => "The permission that was granted does not cover what was asked for.", error.NotAllowed => "The policy did not allow this.", error.NoExecutor => "This build cannot perform that kind of request.", + error.UseTheTypedRequest => "That program has a request of its own. Ask for that instead, so the right permission is decided.", + error.NoShellForAgents => "An agent may not run a shell here. Ask for the program itself, with its arguments already separated.", error.ContainmentUnavailable => "This computer cannot keep file access inside the workspace, so the request was refused.", error.OutOfMemory => "There was not enough memory to run this.", }; @@ -81,6 +89,63 @@ pub fn resourceOf(arena: std.mem.Allocator, request: ToolRequest) !Resource { return request.resource(arena); } +/// The program a request runs, without its directory. +pub fn programOf(request: tools.ExecuteRequest) []const u8 { + if (request.argv.len == 0) return ""; + const first = request.argv[0]; + return if (std.mem.lastIndexOfScalar(u8, first, '/')) |slash| first[slash + 1 ..] else first; +} + +/// The typed request that should have been used instead of running a program. +/// +/// This is the hole it closes. Deleting a file is `fs.delete`, and a workspace +/// that refuses `fs.delete` is saying something a person means. But an agent +/// could reach the same outcome by running `rm`, which is `process.execute` and +/// a completely different decision — so the typed refusal was the one that +/// could not run, and the way around it was the one that could. +/// +/// Refusing here rather than in the policy is deliberate: a policy is written +/// by a person and can be written badly, and this is a property of the request +/// model itself. It only affects agents. A person running `zag run -- rm x` is +/// running a command they typed, which never passes through this file. +pub fn typedEquivalentOf(request: tools.ExecuteRequest) ?[]const u8 { + const program = programOf(request); + const pairs = [_]struct { program: []const u8, typed: []const u8 }{ + .{ .program = "rm", .typed = "delete" }, + .{ .program = "rmdir", .typed = "delete" }, + .{ .program = "unlink", .typed = "delete" }, + .{ .program = "git", .typed = "git" }, + }; + for (pairs) |pair| { + if (std.mem.eql(u8, pair.program, program)) return pair.typed; + } + return null; +} + +/// Is this program a shell? +/// +/// "There is no shell" is a property this design claims, and an agent running +/// `sh -c` puts one back: everything the argument vector was protecting is then +/// parsed by something else. So an agent may not run one. +/// +/// This is a list of names, and a list of names is never complete — a program +/// copied to another name, or an interpreter nobody thought of, is not here. +/// What actually bounds this is the policy's own allowlist of commands, which +/// is written by a person for their own workspace. This stops the obvious path +/// and does not pretend to be a boundary on its own. +pub fn isShell(program: []const u8) bool { + const shells = [_][]const u8{ + "sh", "bash", "zsh", "dash", "fish", "ksh", + "csh", "tcsh", "ash", "busybox", "pwsh", "powershell", + "env", "xargs", "nice", "sudo", "doas", "ssh", + "eval", "exec", "script", "nohup", "setsid", "stdbuf", + }; + for (shells) |name| { + if (std.mem.eql(u8, name, program)) return true; + } + return false; +} + /// True when `decision` authorises exactly `request`. /// /// Both halves are compared. A decision carrying the right capability for the @@ -133,7 +198,11 @@ pub const Runner = struct { return switch (request) { .read_file => |r| self.readFile(r), .write_file => |w| self.writeFile(w), - .execute => |e| self.execute(e), + .execute => |e| blk: { + if (typedEquivalentOf(e)) |_| break :blk error.UseTheTypedRequest; + if (isShell(programOf(e))) break :blk error.NoShellForAgents; + break :blk self.execute(e); + }, // The remaining request kinds have no executor in this build. They // are named rather than silently treated as failures, because // "not built" and "did not work" are different things. @@ -453,3 +522,64 @@ test "every refusal has a sentence a person can act on" { try testing.expect(text[text.len - 1] == '.'); } } + +test "a program with a typed request of its own cannot be run instead" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const scratch = arena_state.allocator(); + const runner = Runner.init(scratch, .{ .root = "." }); + + // This is the hole. A workspace that refuses `fs.delete` means it. Running + // `rm` reaches the same outcome under `process.execute`, which is a + // different decision, so the typed refusal was the one that could not run + // and the way around it was the one that could. + const by_program: ToolRequest = .{ .execute = .{ + .argv = &.{ "rm", "-rf", "build" }, + .workingDirectory = ".", + } }; + const allowed = decisionFor(.@"process.execute", .{ .command = "rm -rf build" }, .allow); + try testing.expectError(error.UseTheTypedRequest, runner.run(allowed, by_program)); + + // Including by its full path, because the check is on the program and not + // on the text somebody wrote. + const by_path: ToolRequest = .{ .execute = .{ + .argv = &.{ "/usr/bin/rm", "build" }, + .workingDirectory = ".", + } }; + const path_allowed = decisionFor(.@"process.execute", .{ .command = "/usr/bin/rm build" }, .allow); + try testing.expectError(error.UseTheTypedRequest, runner.run(path_allowed, by_path)); + + // Git the same way: pushing is `git.push`, and it is not reachable by + // running the program under a permission to run programs. + try testing.expect(typedEquivalentOf(.{ .argv = &.{ "git", "push" }, .workingDirectory = "." }) != null); + try testing.expectEqualStrings("delete", typedEquivalentOf(.{ .argv = &.{"rmdir"}, .workingDirectory = "." }).?); + + // A program that has no typed equivalent is unaffected. + try testing.expect(typedEquivalentOf(.{ .argv = &.{ "zig", "build" }, .workingDirectory = "." }) == null); +} + +test "an agent may not run a shell" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const scratch = arena_state.allocator(); + const runner = Runner.init(scratch, .{ .root = "." }); + + // "There is no shell" is a property this design claims. An agent running + // `sh -c` puts one back, and everything the argument vector protects is + // then parsed by something else. + const request: ToolRequest = .{ .execute = .{ + .argv = &.{ "sh", "-c", "rm -rf / ; echo done" }, + .workingDirectory = ".", + } }; + const allowed = decisionFor(.@"process.execute", .{ .command = "sh -c rm -rf / ; echo done" }, .allow); + try testing.expectError(error.NoShellForAgents, runner.run(allowed, request)); + + for ([_][]const u8{ "bash", "zsh", "fish", "env", "xargs", "sudo", "ssh" }) |name| { + try testing.expect(isShell(name)); + } + // A person running a shell from the command line never reaches this file: + // `zag run` records a command a person typed and does not go through an + // executor. + try testing.expect(!isShell("zig")); + try testing.expect(!isShell("cargo")); +} diff --git a/src/ai/loop.zig b/src/ai/loop.zig index ed5ca6d..6b47e46 100644 --- a/src/ai/loop.zig +++ b/src/ai/loop.zig @@ -302,7 +302,13 @@ pub const Moment = union(enum) { summary: []const u8, resultHash: hashing.Hash, }, - finished: struct { ending: Ending }, + finished: struct { + ending: Ending, + elapsed: timeutil.Duration, + toolCalls: usize, + refusedCalls: usize, + usage: provider.Usage, + }, }; /// Where the record of a run goes. @@ -486,7 +492,21 @@ pub const Runner = struct { } } - self.note(.{ .finished = .{ .ending = ending } }); + var calls: usize = 0; + var refusals: usize = 0; + for (turns.items) |turn| { + calls += turn.steps.len; + for (turn.steps) |step| { + if (step.refused) refusals += 1; + } + } + self.note(.{ .finished = .{ + .ending = ending, + .elapsed = .{ .ns = self.clock.now() - started }, + .toolCalls = calls, + .refusedCalls = refusals, + .usage = usage, + } }); return .{ .ending = ending, @@ -612,12 +632,38 @@ pub const Runner = struct { // 5. The executor spends the decision. It checks the match again, on // its own terms, because this loop is not what makes that safe. - step.executed = true; const result = self.executor.run(decision, request) catch |err| { - step.result = .{ .outcome = .failed, .summary = executor_mod.refusalText(err) }; - step.reply = try toolschema.resultText(self.arena, step.result.?); + // Some of these are refusals reached before anything happened, and + // some are failures during the work. A person reading the record + // needs the difference: "it did not run" and "it ran and did not + // work" lead to different next moves, and so does the message the + // model gets back. + const refused_before_acting = switch (err) { + error.NotAllowed, + error.DecisionDoesNotMatchRequest, + error.NoExecutor, + error.UseTheTypedRequest, + error.NoShellForAgents, + => true, + else => false, + }; + step.refused = refused_before_acting; + step.executed = !refused_before_acting; + step.result = .{ + .outcome = if (refused_before_acting) .denied else .failed, + .summary = executor_mod.refusalText(err), + }; + // The executor's own words, not the policy's. The policy may well + // have allowed this: what refused it was the request model, and + // telling a model "the policy did not allow this" when the policy + // did would send it to ask a person about the wrong thing. + step.reply = if (refused_before_acting) + executor_mod.refusalText(err) + else + try toolschema.resultText(self.arena, step.result.?); return step; }; + step.executed = true; step.result = result; step.reply = try toolschema.resultText(self.arena, result); return step; @@ -1167,3 +1213,56 @@ test "a summary reads as sentences and counts what happened" { try testing.expect(std.mem.indexOf(u8, text, "1 of them refused") != null); try testing.expect(std.mem.indexOf(u8, text, "tokens") != null); } + +test "a refusal from the executor is not reported as the policy refusing" { + var scratch = std.heap.ArenaAllocator.init(testing.allocator); + defer scratch.deinit(); + + // The policy allows running programs. What refuses these is the request + // model itself, and the two must not be confused: a model told the policy + // refused will go and ask a person about a permission they already gave. + const rules = [_]policy_mod.Rule{.{ + .id = "allow-everything", + .capabilities = &.{ .@"model.infer", .@"network.connect", .@"process.execute" }, + .effect = .allow, + .reason = "This workspace allows running programs.", + }}; + const fixture = try Fixture.init(&rules, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + + fixture.recorded.responses = try arena.dupe(transport_mod.Response, &.{ + try callResponse(arena, "run_command", + \\{"argv":["sh","-c","rm -rf notes.txt"],"workingDirectory":"."} + ), + try callResponse(arena, "run_command", + \\{"argv":["rm","notes.txt"],"workingDirectory":"."} + ), + try textResponse(arena, "I could not remove it."), + }); + + const transcript = try fixture.runner(stopped_clock).run( + fixture.options(), + "delete notes.txt", + fixture.context(), + ); + + const shell = transcript.turns[0].steps[0]; + const program = transcript.turns[1].steps[0]; + + // Neither ran, and the record says refused rather than ran-and-failed. + try testing.expect(!shell.ran()); + try testing.expect(!program.ran()); + try testing.expect(shell.refused); + try testing.expect(program.refused); + try testing.expect(!transcript.ranAnything()); + + // The decision was an allow. The refusal came from somewhere else, and the + // words the model gets back say which. + try testing.expect(shell.decision.?.isAllowed()); + try testing.expect(program.decision.?.isAllowed()); + try testing.expect(std.mem.indexOf(u8, shell.reply, "may not run a shell") != null); + try testing.expect(std.mem.indexOf(u8, program.reply, "request of its own") != null); + try testing.expect(std.mem.indexOf(u8, shell.reply, "policy did not allow") == null); + try testing.expect(std.mem.indexOf(u8, program.reply, "policy did not allow") == null); +} diff --git a/src/ai/transparency.zig b/src/ai/transparency.zig index 49aa032..d7e8bd3 100644 --- a/src/ai/transparency.zig +++ b/src/ai/transparency.zig @@ -175,9 +175,8 @@ pub const workbench_obligations = [_]Obligation{ .topic = .redress, .audience = .affected_person, .timing = .on_incident, - .answered_by = "The repository issue tracker", - .satisfied = false, - .gap = "A person affected by a change an agent made, who does not use the workbench, has no route to raise it other than through the repository owner.", + .answered_by = "docs/if-something-is-wrong.md", + .satisfied = true, }, }; @@ -200,15 +199,30 @@ test "every transparency topic asks a question a person would ask" { } } +test "every obligation names where its answer lives" { + // An obligation that says it is met has to say where. "Somebody could ask + // us" is not an answer to "what do I do if it is wrong", and the field + // exists so that claiming to have met one costs writing the page. + for (workbench_obligations) |obligation| { + try testing.expect(obligation.answered_by.len > 0); + if (!obligation.satisfied) try testing.expect(obligation.gap.len > 0); + } +} + test "unmet obligations are listed rather than hidden" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); + // Every obligation is met today, and each one says where. When one is not, + // it appears here with its gap, and `zag check` counts it as a problem — + // it used to print the gap and leave the count at zero, so the build stayed + // green while listing what it had not done. const unmet = try unmetObligations(arena); - try testing.expectEqual(@as(usize, 1), unmet.items.len); - try testing.expectEqual(Topic.redress, unmet.items[0].topic); - try testing.expect(unmet.items[0].gap.len > 0); + for (unmet.items) |obligation| { + try testing.expect(obligation.gap.len > 0); + } + try testing.expectEqual(@as(usize, 0), unmet.items.len); } test "the clear review finds a document that cannot be compared or acted on" { diff --git a/src/cli/main.zig b/src/cli/main.zig index 38b9977..e605671 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -496,7 +496,7 @@ fn standards(arena: std.mem.Allocator, w: *std.Io.Writer, options: Options) !u8 }); try w.print(" Standards in force: {d}\n", .{profile.standards.len}); for (profile.exclusions) |exclusion| { - try w.print(" Does not apply {s}: {s}\n", .{ exclusion.requirement_id, exclusion.reason }); + try w.print(" Does not apply {s}: {s}\n", .{ exclusion.subject.text(), exclusion.reason }); } try w.writeAll("\n"); } @@ -779,7 +779,7 @@ fn events(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Opti return 2; } const path = options.positional[0]; - const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(256 << 20)) catch { + const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(zag.events.log.max_file_bytes)) catch { try w.print("zag could not read {s}.\n", .{path}); return 1; }; @@ -1333,7 +1333,7 @@ fn whyDidThatHappen( return 2; } const path = options.positional[0]; - const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(256 << 20)) catch { + const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(zag.events.log.max_file_bytes)) catch { try w.print("zag could not read {s}.\n", .{path}); return 1; }; @@ -1546,9 +1546,14 @@ fn interactiveTerminal( return 1; }; var service = opened.service; - if (opened.report.chainBreak) |broken| { - try w.print("The event log is broken at event {d}, so this session would not be recorded.\n", .{broken.sequence}); - try w.writeAll("Keep the file as it is. A broken log is evidence, not a fault to repair.\n"); + // A torn tail and a malformed record are as disqualifying as a broken + // chain: in all three the workspace is sealed and nothing this session did + // would be written. Checking only the chain meant `zag term` started + // happily on a log that `zag run` refuses, and recorded a session that went + // nowhere. + if (!opened.report.logIsHealthy()) { + try opened.report.writeSummary(w); + try w.writeAll("This session would not be recorded. Keep the file as it is: a damaged log is evidence, not a fault to repair.\n"); return 1; } @@ -2399,15 +2404,55 @@ fn check(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Optio var problems: usize = 0; const dir = std.Io.Dir.cwd(); - // 1. The standards registry itself. - const registry = try loadStandards(arena); + // 1. The standards registry itself, and the requirements taken from it. + // + // The requirements were not loaded here at all, so anything checking a + // requirement identifier was checking against an empty set: every lookup + // failed, and nothing looked one up, so nothing noticed. + var registry = try loadStandards(arena); + const requirement_count = try registry.loadRequirements(@embedFile("registry_requirements")); const issues = try registry.validate(); - try w.print("Standards registry: {d} standards recorded, {d} problem(s).\n", .{ registry.count(), issues.items.len }); + try w.print("Standards registry: {d} standards and {d} requirements recorded, {d} problem(s).\n", .{ + registry.count(), + requirement_count, + issues.items.len, + }); for (issues.items) |issue| { try w.print(" {s} {s}\n", .{ issue.code.text(), issue.message }); problems += 1; } + // 1a. Every requirement a profile names has to exist. + // + // `extra_requirements` and `exclusions` are lists of identifiers, and until + // now nothing read either one. A profile could switch on a requirement that + // did not exist, or exclude one with a typo in it, and the build would + // report nothing: the exclusion silently applied to nothing, so a + // requirement the author meant to skip was enforced, or one they meant to + // add was not. + for (zag.standards.profile.registry) |candidate| { + for (candidate.extra_requirements) |id| { + if (registry.requirements.get(id) == null) { + try w.print(" PROFILE {s} switches on {s}, which is not a requirement.\n", .{ candidate.id, id }); + problems += 1; + } + } + for (candidate.exclusions) |exclusion| { + const known = switch (exclusion.subject) { + .requirement => |id| registry.requirements.get(id) != null, + .language_rule => |code| zag.language.plain.Rule.byCode(code) != null, + }; + if (!known) { + try w.print(" PROFILE {s} excludes {s}, which is not a {s}.\n", .{ + candidate.id, + exclusion.subject.text(), + @tagName(exclusion.subject), + }); + problems += 1; + } + } + } + // 1b. Any file a standard points at has to be there. One entry named a // requirements file that had never existed; the field was parsed, never // read, and never checked, so nothing noticed. @@ -2472,10 +2517,15 @@ fn check(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Optio for (impact_findings.items) |finding| try w.print(" IMPACT {s}\n", .{finding}); problems += risk_findings.items.len + impact_findings.items.len; + // An obligation nobody has met is a problem, and printing it as OPEN while + // leaving the count at zero meant the build stayed green with a list of + // things it had just said were not done. A check that reports and does not + // fail is a check that stops being read. const unmet = try zag.ai.transparency.unmetObligations(arena); try w.print("Transparency: {d} obligation(s) not yet met.\n", .{unmet.items.len}); for (unmet.items) |obligation| { try w.print(" OPEN {s} {s}\n", .{ obligation.topic.question(), obligation.gap }); + problems += 1; } // 6. The product's own text, including this tool's help. @@ -2490,6 +2540,7 @@ fn check(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Optio .{ .path = "docs/roadmap.md", .kind = .body }, .{ .path = "docs/threat-model.md", .kind = .body }, .{ .path = "docs/platform-support.md", .kind = .body }, + .{ .path = "docs/if-something-is-wrong.md", .kind = .body }, .{ .path = "CONTRIBUTING.md", .kind = .body }, .{ .path = "SECURITY.md", .kind = .body }, }; diff --git a/src/daemon/main.zig b/src/daemon/main.zig index 2ea1fa9..772858e 100644 --- a/src/daemon/main.zig +++ b/src/daemon/main.zig @@ -20,7 +20,7 @@ const Command = enum { verify, plan, history, - serve, + watch, fn parse(text: []const u8) ?Command { const table = [_]struct { name: []const u8, command: Command }{ @@ -31,7 +31,10 @@ const Command = enum { .{ .name = "verify", .command = .verify }, .{ .name = "plan", .command = .plan }, .{ .name = "history", .command = .history }, - .{ .name = "serve", .command = .serve }, + .{ .name = "watch", .command = .watch }, + // The old name said the daemon held a workspace open for clients. + // It never has: there is no socket and nothing connects to it. + .{ .name = "serve", .command = .watch }, }; for (table) |entry| { if (std.mem.eql(u8, entry.name, text)) return entry.command; @@ -52,11 +55,11 @@ pub const help_text = \\ verify Check the event log and command-output objects. \\ plan Say what each step of a workflow would need. \\ history Search what happened, using the history filters. - \\ serve Hold the workspace open and report each check. + \\ watch Re-open the workspace and report, a set number of times. \\ \\Options \\ --root The workspace to open. The default is this directory. - \\ --checks How many checks `serve` runs before it stops. + \\ --checks How many times `watch` re-opens the workspace. \\ \\The daemon never runs a workflow step on its own. It reports which steps a \\person still has to decide on, and stops there. @@ -138,7 +141,7 @@ pub fn main(init: std.process.Init) !u8 { .verify => try verify(arena, io, w, options), .plan => try plan(arena, io, w, options), .history => try history(arena, io, w, options), - .serve => try serve(arena, io, w, options), + .watch => try watch(arena, io, w, options), }; try w.flush(); return status_code; @@ -247,7 +250,14 @@ fn history(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Opt /// Hold the workspace open and re-check it. Each check re-reads the log from /// disk, so a change another process wrote is picked up, and a log that was /// damaged while the daemon was running is reported rather than extended. -fn serve(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { +/// Re-open the workspace and report, a set number of times. +/// +/// This was called `serve`, and the help said it held the workspace open for +/// clients. It does not: there is no socket, nothing connects, and each pass +/// opens the workspace from scratch and prints. The name and the sentence now +/// say what it does. `serve` still works as a name so nobody's script breaks, +/// and the roadmap says what a real daemon would need. +fn watch(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Options) !u8 { var checks: usize = 0; var worst: u8 = 0; while (checks < options.checks) : (checks += 1) { diff --git a/src/events/event.zig b/src/events/event.zig index 2e50a73..e180f45 100644 --- a/src/events/event.zig +++ b/src/events/event.zig @@ -115,6 +115,28 @@ pub const AgentMessage = struct { text: []const u8, }; +/// An agent run ended, and why. +/// +/// Without this the block folded from `agent_started` has no finish, so it +/// stays running for ever: a workspace with a hundred completed agent runs +/// shows a hundred that are still going. It also means the run itself has no +/// duration and no outcome, only its individual tool calls, so "how long did +/// that take and did it work" had no answer at the level a person asks it. +pub const AgentFinished = struct { + agent: AgentId, + session: SessionId, + outcome: enum { answered, refused, stopped_by_policy, waiting_for_a_person, out_of_budget, unavailable }, + duration: timeutil.Duration, + /// Tool calls the run made, and how many were refused. + toolCalls: u32 = 0, + refusedCalls: u32 = 0, + /// What the run cost, where the provider said. + inputTokens: u64 = 0, + outputTokens: u64 = 0, + /// One sentence for the person reading the block list later. + summary: []const u8 = "", +}; + pub const ToolRequested = struct { call: ToolCallId, agent: AgentId, @@ -176,6 +198,18 @@ pub const ApprovalResolved = struct { note: []const u8 = "", }; +/// The shell moved to another directory. +/// +/// This is not a repository changing, and recording it as one was wrong: a +/// `cd /tmp` became "the repository moved to /tmp", so a history search +/// filtered by repository returned directories that were never repositories. +/// The workbench learns this from an OSC 7 mark, which says where the shell is +/// and nothing at all about version control. +pub const DirectoryChanged = struct { + session: SessionId, + path: []const u8, +}; + pub const GitChanged = struct { session: SessionId, repository: []const u8, @@ -222,12 +256,14 @@ pub const WorkspaceEvent = union(enum) { command_finished: CommandFinished, agent_started: AgentStarted, agent_message: AgentMessage, + agent_finished: AgentFinished, tool_requested: ToolRequested, tool_finished: ToolFinished, file_opened: FileOpened, file_changed: FileChanged, approval_requested: ApprovalRequested, approval_resolved: ApprovalResolved, + directory_changed: DirectoryChanged, git_changed: GitChanged, diagnostic: Diagnostic, evidence_recorded: EvidenceRecorded, @@ -247,12 +283,14 @@ pub const WorkspaceEvent = union(enum) { .command_finished => |e| e.session, .agent_started => |e| e.session, .agent_message => |e| e.session, + .agent_finished => |e| e.session, .tool_requested => |e| e.session, .tool_finished => |e| e.session, .file_opened => |e| e.session, .file_changed => |e| e.session, .approval_requested => |e| e.session, .approval_resolved => |e| e.session, + .directory_changed => |e| e.session, .git_changed => |e| e.session, .diagnostic => |e| e.session, .evidence_recorded => |e| e.session, @@ -274,7 +312,7 @@ pub const WorkspaceEvent = union(enum) { /// automated decision-making? pub fn isAgentAction(self: WorkspaceEvent) bool { return switch (self) { - .agent_started, .agent_message, .tool_requested, .tool_finished => true, + .agent_started, .agent_message, .agent_finished, .tool_requested, .tool_finished => true, .file_changed => |e| e.agent != null, else => false, }; diff --git a/src/language/plain.zig b/src/language/plain.zig index 2328cd8..35b209a 100644 --- a/src/language/plain.zig +++ b/src/language/plain.zig @@ -50,6 +50,17 @@ pub const Rule = enum { ambiguous_datetime, ambiguous_unit, + /// The rule with this code, when there is one. + /// + /// Exists so a profile that says "do not apply PL006" can be checked + /// against the rules that exist, instead of naming a code nobody looks up. + pub fn byCode(wanted: []const u8) ?Rule { + for (std.enums.values(Rule)) |rule| { + if (std.mem.eql(u8, rule.code(), wanted)) return rule; + } + return null; + } + pub fn code(self: Rule) []const u8 { return switch (self) { .undefined_abbreviation => "PL001", diff --git a/src/standards/profile.zig b/src/standards/profile.zig index 59b2d0b..ac6d95a 100644 --- a/src/standards/profile.zig +++ b/src/standards/profile.zig @@ -48,9 +48,30 @@ pub const Profile = struct { /// Requirements this profile deliberately does not apply, with the reason. exclusions: []const Exclusion = &.{}, + /// Something this profile deliberately does not apply. + /// + /// The subject used to be a bare `requirement_id`, and one profile put a + /// plain-language rule code in it. Those are two different namespaces, so + /// the exclusion matched nothing and the rule it meant to switch off stayed + /// on — silently, because nothing ever looked the identifier up. Saying + /// which kind of thing is being excluded lets `zag check` look it up in the + /// right table and refuse a name that is in neither. pub const Exclusion = struct { - requirement_id: []const u8, + subject: Subject, reason: []const u8, + + pub const Subject = union(enum) { + /// An identifier from the requirements register. + requirement: []const u8, + /// A rule code from the plain-language checker, such as `PL006`. + language_rule: []const u8, + + pub fn text(self: Subject) []const u8 { + return switch (self) { + .requirement, .language_rule => |value| value, + }; + } + }; }; pub fn appliesTo(self: Profile, scope: Scope) bool { @@ -61,9 +82,28 @@ pub const Profile = struct { return false; } + /// Why this profile does not apply a requirement, when it does not. pub fn excludes(self: Profile, requirement_id: []const u8) ?[]const u8 { for (self.exclusions) |exclusion| { - if (std.mem.eql(u8, exclusion.requirement_id, requirement_id)) return exclusion.reason; + switch (exclusion.subject) { + .requirement => |id| if (std.mem.eql(u8, id, requirement_id)) return exclusion.reason, + .language_rule => {}, + } + } + return null; + } + + /// Why this profile does not apply a plain-language rule, when it does not. + /// + /// A separate question from the one above, because they are separate + /// namespaces. Asking one function both used to mean the legal profile's + /// exclusion of `PL006` matched nothing at all. + pub fn excludesLanguageRule(self: Profile, code: []const u8) ?[]const u8 { + for (self.exclusions) |exclusion| { + switch (exclusion.subject) { + .language_rule => |rule| if (std.mem.eql(u8, rule, code)) return exclusion.reason, + .requirement => {}, + } } return null; } @@ -144,7 +184,7 @@ pub const scientific: Profile = .{ .scopes = &.{ .scientific_text, .reporting }, .extra_requirements = &.{ "ISO-24495-3:R-METHOD", "ISO-24495-3:R-UNCERTAINTY" }, .exclusions = &.{.{ - .requirement_id = "STE100:R-APPROVED-VERB", + .subject = .{ .requirement = "STE100:R-APPROVED-VERB" }, .reason = "Controlled English removes the precision a scientific argument needs. Plain language still applies.", }}, }; @@ -159,7 +199,7 @@ pub const legal: Profile = .{ .scopes = &.{.legal_text}, .extra_requirements = &.{"ISO-24495-2:R-OBLIGATION-CLEAR"}, .exclusions = &.{.{ - .requirement_id = "PL006", + .subject = .{ .language_rule = "PL006" }, .reason = "Some legal terms are nouns formed from verbs and cannot be replaced without changing their meaning.", }}, }; diff --git a/src/terminal/session.zig b/src/terminal/session.zig index 5b93253..be0f34e 100644 --- a/src/terminal/session.zig +++ b/src/terminal/session.zig @@ -209,9 +209,11 @@ pub const Session = struct { try self.finishOpenBlock(f.exit_status orelse 0, null); }, .directory_changed => |path| { - _ = try self.log.append(.{ .git_changed = .{ + // Where the shell is, not what repository it is in. The mark + // says one and knows nothing about the other. + _ = try self.log.append(.{ .directory_changed = .{ .session = self.id, - .repository = path, + .path = path, } }, .{ .at = self.clock, .actor = self.actor, diff --git a/src/workspace/block.zig b/src/workspace/block.zig index 4cf8111..350fa09 100644 --- a/src/workspace/block.zig +++ b/src/workspace/block.zig @@ -66,12 +66,67 @@ pub const GitContext = struct { dirtyFileCount: u32 = 0, }; +/// One piece of a command's output, as it arrived. +/// +/// A program's output reaches the workbench in whatever pieces the operating +/// system hands over, and each piece is stored and hashed on its own. The +/// index number is the event's, so pieces can be put back in order however +/// they were delivered. +pub const Chunk = struct { + hash: Hash, + byteCount: usize, + index: u32, +}; + +/// Order pieces the way the program produced them. +/// +/// The event carries the index for exactly this: reads can be delivered out of +/// order, and putting them back by arrival time would reassemble the output +/// wrongly and silently. +fn earlierChunk(_: void, a: Chunk, b: Chunk) bool { + return a.index < b.index; +} + /// Where the block's output lives. Small output is kept inline; anything /// larger is content-addressed so the log stays small and replay stays fast. +/// +/// Output that arrived in more than one piece is `chunked`, and that variant +/// exists because the alternative was a lie: the fold used to keep the *last* +/// piece's hash beside the *summed* byte count, so a block with three chunks +/// claimed a hash covering twenty bytes next to a count of six thousand. Every +/// consumer that checked the hash against the count would have found the record +/// wrong, and every consumer that trusted it would have been reading twenty +/// bytes and calling them the output. pub const ContentRef = union(enum) { none, inline_text: []const u8, hashed: struct { hash: Hash, byteCount: usize }, + chunked: struct { chunks: []const Chunk, byteCount: usize }, + + /// How many bytes the output is, all pieces together. + pub fn totalBytes(self: ContentRef) usize { + return switch (self) { + .none => 0, + .inline_text => |text| text.len, + .hashed => |h| h.byteCount, + .chunked => |c| c.byteCount, + }; + } + + /// The pieces, in the order they were produced. + /// + /// One piece for a single hash, so a caller reassembling output does not + /// need to know which shape it got. + pub fn pieces(self: ContentRef, one: *[1]Chunk) []const Chunk { + return switch (self) { + .chunked => |c| c.chunks, + .hashed => |h| blk: { + one[0] = .{ .hash = h.hash, .byteCount = h.byteCount, .index = 0 }; + break :blk one[0..1]; + }, + else => &.{}, + }; + } }; pub const Block = struct { @@ -136,6 +191,10 @@ pub const Index = struct { // Positions of blocks that have not finished yet, so a git change can // reach them without walking the whole list. var open_blocks: std.ArrayList(usize) = .empty; + // Output pieces, per block, kept until the fold is finished so that a + // block with one piece and a block with several are recorded as the + // different things they are. + var chunks_by_block: std.AutoArrayHashMapUnmanaged([16]u8, std.ArrayList(Chunk)) = .empty; for (log.entries.items) |entry| { switch (entry.payload) { @@ -160,7 +219,13 @@ pub const Index = struct { const at = position.get(e.block.raw.bytes) orelse continue; const block = &index.blocks.items[at]; block.outputBytes += e.byteCount; - block.content = .{ .hashed = .{ .hash = e.contentHash, .byteCount = block.outputBytes } }; + const found = try chunks_by_block.getOrPut(arena, e.block.raw.bytes); + if (!found.found_existing) found.value_ptr.* = .empty; + try found.value_ptr.append(arena, .{ + .hash = e.contentHash, + .byteCount = e.byteCount, + .index = e.chunkIndex, + }); }, .command_finished => |e| { const at = position.get(e.block.raw.bytes) orelse continue; @@ -211,6 +276,18 @@ pub const Index = struct { .parent = BlockId.fromRaw(e.agent.raw), }); }, + .agent_finished => |e| { + const at = position.get(e.agent.raw.bytes) orelse continue; + const block = &index.blocks.items[at]; + block.finishedAt = entry.at; + block.status = switch (e.outcome) { + .answered => .succeeded, + .refused, .stopped_by_policy => .denied, + .waiting_for_a_person => .running, + .out_of_budget, .unavailable => .failed, + }; + if (e.summary.len > 0) block.commandText = e.summary; + }, .file_changed => |e| { try index.blocks.append(arena, .{ .id = idmod.BlockId.fromRaw(entry.id.raw), @@ -274,6 +351,24 @@ pub const Index = struct { } } + // Now that every piece is in, say what the output is. One piece is one + // hash of that many bytes. Several pieces are several hashes, each + // covering its own bytes, in the order the program produced them. + var chunk_entries = chunks_by_block.iterator(); + while (chunk_entries.next()) |entry| { + const at = position.get(entry.key_ptr.*) orelse continue; + const block = &index.blocks.items[at]; + const list = entry.value_ptr.*; + if (list.items.len == 0) continue; + std.mem.sort(Chunk, list.items, {}, earlierChunk); + var total: usize = 0; + for (list.items) |chunk| total += chunk.byteCount; + block.content = if (list.items.len == 1) + .{ .hashed = .{ .hash = list.items[0].hash, .byteCount = list.items[0].byteCount } } + else + .{ .chunked = .{ .chunks = list.items, .byteCount = total } }; + } + // Fill in children from the parent links. Every block is looked up by // identifier rather than by scanning, because scanning turns folding a // long session into quadratic work: a workspace with twenty thousand @@ -472,3 +567,147 @@ test "an approval becomes a block with an outcome" { try testing.expectEqual(Status.succeeded, block.status); try testing.expect(block.isFinished()); } + +test "output that arrived in pieces is recorded as pieces, not as the last one" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var log = log_mod.Log.init(arena, 61); + var gen: idmod.Generator = .init(62, 1_788_000_000_000); + const actor: event_mod.Actor = .{ .id = gen.next(idmod.ActorId), .kind = .person, .label = "you" }; + const session = gen.next(idmod.SessionId); + const block = gen.next(idmod.BlockId); + + _ = try log.append(.{ .command_submitted = .{ + .block = block, + .session = session, + .commandText = "zig build test", + .workingDirectory = "/repo", + } }, .{ .at = .{ .ns = 1 }, .actor = actor }); + + // Three reads, as a pseudoterminal really delivers them, and deliberately + // out of order to prove the index is what puts them back. + const first = Hash.of("first piece, five thousand bytes of it"); + const second = Hash.of("second piece"); + const third = Hash.of("third"); + _ = try log.append(.{ .process_output = .{ + .block = block, + .session = session, + .contentHash = third, + .byteCount = 5, + .chunkIndex = 2, + } }, .{ .at = .{ .ns = 2 }, .actor = actor }); + _ = try log.append(.{ .process_output = .{ + .block = block, + .session = session, + .contentHash = first, + .byteCount = 5000, + .chunkIndex = 0, + } }, .{ .at = .{ .ns = 3 }, .actor = actor }); + _ = try log.append(.{ .process_output = .{ + .block = block, + .session = session, + .contentHash = second, + .byteCount = 12, + .chunkIndex = 1, + } }, .{ .at = .{ .ns = 4 }, .actor = actor }); + + const index = try Index.build(arena, log); + const folded = index.blocks.items[0]; + + // Three pieces, each with the hash of its own bytes. The old fold kept the + // last piece's hash beside the summed count, so the record said a hash of + // five bytes covered five thousand and seventeen. + try testing.expect(folded.content == .chunked); + const chunks = folded.content.chunked.chunks; + try testing.expectEqual(@as(usize, 3), chunks.len); + try testing.expectEqual(@as(usize, 5017), folded.content.totalBytes()); + try testing.expectEqual(folded.outputBytes, folded.content.totalBytes()); + + // In the order the program produced them, not the order they arrived. + try testing.expect(chunks[0].hash.eql(first)); + try testing.expect(chunks[1].hash.eql(second)); + try testing.expect(chunks[2].hash.eql(third)); + + // Every piece's hash covers exactly the bytes beside it, which is the + // property that was false before and is the only one worth having. + try testing.expectEqual(@as(usize, 5000), chunks[0].byteCount); + try testing.expectEqual(@as(usize, 12), chunks[1].byteCount); + try testing.expectEqual(@as(usize, 5), chunks[2].byteCount); + + var one: [1]Chunk = undefined; + try testing.expectEqual(@as(usize, 3), folded.content.pieces(&one).len); +} + +test "output that arrived in one piece stays one hash" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var log = log_mod.Log.init(arena, 71); + var gen: idmod.Generator = .init(72, 1_788_000_000_000); + const actor: event_mod.Actor = .{ .id = gen.next(idmod.ActorId), .kind = .person, .label = "you" }; + const session = gen.next(idmod.SessionId); + const block = gen.next(idmod.BlockId); + + _ = try log.append(.{ .command_submitted = .{ + .block = block, + .session = session, + .commandText = "echo hello", + .workingDirectory = "/repo", + } }, .{ .at = .{ .ns = 1 }, .actor = actor }); + const only = Hash.of("hello\n"); + _ = try log.append(.{ .process_output = .{ + .block = block, + .session = session, + .contentHash = only, + .byteCount = 6, + } }, .{ .at = .{ .ns = 2 }, .actor = actor }); + + const index = try Index.build(arena, log); + const folded = index.blocks.items[0]; + try testing.expect(folded.content == .hashed); + try testing.expect(folded.content.hashed.hash.eql(only)); + try testing.expectEqual(@as(usize, 6), folded.content.hashed.byteCount); + + // And a caller that just wants the pieces gets one, so it need not know + // which shape it was handed. + var one: [1]Chunk = undefined; + const pieces = folded.content.pieces(&one); + try testing.expectEqual(@as(usize, 1), pieces.len); + try testing.expect(pieces[0].hash.eql(only)); +} + +test "moving to a directory is not a repository changing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var log = log_mod.Log.init(arena, 81); + var gen: idmod.Generator = .init(82, 1_788_000_000_000); + const actor: event_mod.Actor = .{ .id = gen.next(idmod.ActorId), .kind = .person, .label = "you" }; + const session = gen.next(idmod.SessionId); + const block = gen.next(idmod.BlockId); + + // A `cd /tmp`, as a shell integration mark reports it. + _ = try log.append(.{ .directory_changed = .{ + .session = session, + .path = "/tmp", + } }, .{ .at = .{ .ns = 1 }, .actor = actor }); + _ = try log.append(.{ .command_submitted = .{ + .block = block, + .session = session, + .commandText = "ls", + .workingDirectory = "/tmp", + } }, .{ .at = .{ .ns = 2 }, .actor = actor }); + + const index = try Index.build(arena, log); + const folded = index.blocks.items[0]; + + // The block knows where it ran, and claims no repository. It used to be + // recorded as `git_changed{ .repository = "/tmp" }`, so a search for work + // in a repository came back with directories that were never repositories. + try testing.expectEqualStrings("/tmp", folded.workingDirectory.?); + try testing.expect(folded.git == null); +} diff --git a/src/workspace/service.zig b/src/workspace/service.zig index 614b5d7..c41c4e5 100644 --- a/src/workspace/service.zig +++ b/src/workspace/service.zig @@ -712,6 +712,31 @@ pub const Service = struct { self.pending_id = null; }, .finished => |e| { + // The run itself gets a finish, so the block folded from it + // stops being one that is still going. + _ = try service.record(.{ .agent_finished = .{ + .agent = self.agent, + .session = self.session, + .outcome = switch (e.ending) { + .answered => .answered, + .refused_by_model => .refused, + .stopped_by_policy => .stopped_by_policy, + .waiting_for_a_person => .waiting_for_a_person, + .out_of_turns, .out_of_tokens, .out_of_time, .going_in_circles => .out_of_budget, + .provider_unavailable => .unavailable, + }, + .duration = e.elapsed, + .toolCalls = @intCast(e.toolCalls), + .refusedCalls = @intCast(e.refusedCalls), + .inputTokens = e.usage.inputTokens, + .outputTokens = e.usage.outputTokens, + .summary = try service.arena.dupe(u8, e.ending.text()), + } }, .{ + .at = at, + .actor = actor, + .causedBy = self.started, + .correlation = self.correlation, + }); _ = try service.record(.{ .session_closed = .{ .session = self.session, .exitStatus = if (e.ending.succeeded()) 0 else 1, @@ -1544,7 +1569,13 @@ test "an agent run is recorded as one causal tree, refusals included" { .summary = "No rule in this policy allows it.", .resultHash = hashing.Hash.zero, } }); - journal.record(.{ .finished = .{ .ending = .answered } }); + journal.record(.{ .finished = .{ + .ending = .answered, + .elapsed = .{ .ns = 250 * timeutil.ns_per_ms }, + .toolCalls = 2, + .refusedCalls = 1, + .usage = .{ .inputTokens = 60, .outputTokens = 18 }, + } }); try testing.expect(recorder.failure == null); try testing.expect(recorder.recorded > 0); @@ -1589,6 +1620,36 @@ test "an agent run is recorded as one causal tree, refusals included" { // to do would be the least useful half of the story. try testing.expectEqual(@as(usize, 1), refused); + // The run itself has a finish, so the block folded from it is not one that + // is still going. Without it a workspace with a hundred completed runs + // shows a hundred that never ended. + const blocks = try block_mod.Index.build(arena, loaded.log); + var agent_runs: usize = 0; + for (blocks.blocks.items) |b| { + if (b.kind != .agent_run) continue; + agent_runs += 1; + try testing.expectEqual(block_mod.Status.succeeded, b.status); + try testing.expect(b.finishedAt != null); + } + try testing.expectEqual(@as(usize, 1), agent_runs); + + // And the run's own cost and shape are on the record, not only its + // individual calls. + var saw_finish = false; + for (loaded.log.entries.items) |entry| { + switch (entry.payload) { + .agent_finished => |e| { + saw_finish = true; + try testing.expectEqual(@as(u32, 2), e.toolCalls); + try testing.expectEqual(@as(u32, 1), e.refusedCalls); + try testing.expectEqual(@as(u64, 60), e.inputTokens); + try testing.expectEqual(@as(i64, 250 * timeutil.ns_per_ms), e.duration.ns); + }, + else => {}, + } + } + try testing.expect(saw_finish); + // The whole run comes back from one correlation. const run = try graph.run(loaded.log.entries.items[roots.items[0]].id); try testing.expectEqual(loaded.log.entries.items.len, run.items.len); diff --git a/standards/registry/requirements.toml b/standards/registry/requirements.toml index 07ae484..d4046bd 100644 --- a/standards/registry/requirements.toml +++ b/standards/registry/requirements.toml @@ -282,3 +282,108 @@ verification = "static_analysis" severity = "major" rationale = "A draft read as approved is the most common way wrong guidance spreads." check = "KB04" + +# Requirements switched on by a profile rather than by the default. +# +# Each of these was named by a profile before it was written, so the profile +# switched on nothing and the exclusion excluded nothing. `zag check` now +# refuses a profile that names a requirement that does not exist. + +[[requirement]] +id = "ISO-42001:R-EVIDENCE" +standard = "ISO/IEC 42001" +clause = "9.1" +statement = "Each control the organisation claims is supported by evidence that names the artefact it is about and when it was produced." +scopes = ["ai_system", "records"] +verification = "static_analysis" +severity = "blocking" +rationale = "A control nobody can produce evidence for is a control nobody can audit, and an evidence record that names no artefact cannot be checked against the thing it describes." +residual_judgement = "Whether the evidence establishes the control is a judgement an auditor makes. A checker sees only that a record exists, names an artefact, and carries its hash." + +[[requirement]] +id = "ISO-15489:R-RETENTION" +standard = "ISO 15489" +clause = "9.9" +statement = "Every record has a retention rule, and a record is not disposed of before its rule allows or while a hold is on it." +scopes = ["records"] +verification = "automated_test" +severity = "blocking" +rationale = "A record kept for ever is as much a failure of records management as one destroyed early, and a legal hold that can be ignored is not a hold." +residual_judgement = "Whether the retention period is the right one for the jurisdiction and the business is decided by the organisation, not by a checker." + +[[requirement]] +id = "ISO-24495-2:R-OBLIGATION-CLEAR" +standard = "ISO 24495-2" +clause = "5.3" +statement = "Text that places an obligation on the reader says who must do what, in the active voice, without leaving the actor to be inferred." +scopes = ["interface_text", "documentation"] +verification = "static_analysis" +severity = "major" +rationale = "A reader cannot comply with an obligation whose subject is missing. \"Approval must be obtained\" does not say by whom, from whom, or when." +residual_judgement = "Whether the reader understands what they are obliged to do is established by asking readers, not by finding the subject of a sentence." + +[[requirement]] +id = "ISO-24495-3:R-METHOD" +standard = "ISO 24495-3" +clause = "6.4" +statement = "A statement of a result says how the result was produced, in enough detail for a reader to judge it." +scopes = ["documentation", "reporting"] +verification = "static_analysis" +severity = "major" +rationale = "A number without a method is not a finding. A reader who cannot see how it was measured cannot tell a measurement from an estimate." +residual_judgement = "Whether the method described is sufficient for this reader to judge the result is established by review, not by the presence of a method section." + +[[requirement]] +id = "ISO-24495-3:R-UNCERTAINTY" +standard = "ISO 24495-3" +clause = "6.5" +statement = "A reported figure says what it does not establish, and where a range or a spread applies, gives it." +scopes = ["documentation", "reporting"] +verification = "static_analysis" +severity = "major" +rationale = "A figure quoted without its uncertainty invites a precision nobody measured. This product's own benchmark output is the example: one machine gave 75 MiB/s and another 56 MiB/s for the same commit." +residual_judgement = "Whether the stated uncertainty is honest is a judgement about the measurement, which no checker makes." + +[[requirement]] +id = "STE100:R-APPROVED-VERB" +standard = "ASD-STE100" +clause = "Part 1, Section 1" +statement = "A verb in a procedure is one of the approved verbs, used in its approved meaning." +scopes = ["interface_text", "documentation"] +verification = "static_analysis" +severity = "major" +rationale = "Controlled English works because one word means one thing. A verb outside the approved set is a word a reader has to guess at, and a reader following a procedure cannot afford to guess." +residual_judgement = "Whether an approved verb was used in its approved sense is judged by a person; a checker sees only that the word is on the list." + +[[requirement]] +id = "STE100:R-ONE-INSTRUCTION" +standard = "ASD-STE100" +clause = "Part 1, Section 5" +statement = "A sentence in a procedure gives one instruction." +scopes = ["interface_text", "documentation"] +verification = "static_analysis" +severity = "major" +rationale = "Two instructions in one sentence are two things to do and one thing to forget. A reader working through a procedure marks progress by sentence." +residual_judgement = "Whether two clauses are one instruction or two is a judgement about the task, not about the grammar." + +[[requirement]] +id = "ER:R-SHORT-SENTENCE" +standard = "Easy-to-Read" +clause = "4.2" +statement = "A sentence carries one idea and stays short enough to hold in mind while reading it." +scopes = ["interface_text", "documentation"] +verification = "static_analysis" +severity = "major" +rationale = "A reader with a cognitive disability, or reading in a second language, or reading under pressure, loses the start of a long sentence before reaching its end." +residual_judgement = "Whether this reader can follow this sentence is established by reading it with them. A word count is a proxy and is treated as one." + +[[requirement]] +id = "ER:R-SUPPORTING-IMAGE" +standard = "Easy-to-Read" +clause = "5.1" +statement = "An image that carries meaning has a text alternative saying the same thing, and an image that is decoration is marked as decoration." +scopes = ["interface_text", "documentation"] +verification = "static_analysis" +severity = "blocking" +rationale = "An image supporting easy-to-read text is doing part of the explaining. A reader who cannot see it loses that part unless the text says it too, and a decorative image announced as meaningful wastes their attention." +residual_judgement = "Whether the alternative says the same thing as the image is judged by a person who can see both." From 4462f2908438efb28ebf54ced29711a716f1d2ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 03:08:18 +0000 Subject: [PATCH 06/12] Make the ledger append-only and the shell marker token real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects that were last on the list because neither is reachable from a path a person runs today. Both turned out to be worse than they read. The records ledger kept one mutable `Record` per identifier, and `placeHold`, `releaseHold` and `dispose` reached into it and changed fields that no hash covered. A record could be sealed, then quietly unsealed, disposed of, or have its legal hold lifted, and `verify()` would still return null. That is the opposite of what a retention ledger is for. The ledger is now a chain of immutable entries. Sealing, holding, releasing a hold, superseding and disposing are each an `Entry` with its own content hash over the record id, the time, the actor and the whole change, linked into a chain. `find()` folds current state out of the entries rather than storing it, so there is no field left to edit. `verify()` checks each entry's own content hash as well as the chain, so an edited disposal witness or an edited hold reason is caught at its index. Added `all()`, `entryCount()`, `historyOf()` and `inState()` for reading it back. The shell marker token was worse than "written to the glass before being consumed". `__zag_token` was read in four places in the hooks and assigned in none of them. Every OSC 133 mark bash and zsh emitted carried an empty token, the authenticated tracker rejected all of them, and `zag term` recorded no authenticated command boundaries at all. Meanwhile `ZAG_MARKER_TOKEN` was never unset, so every child process inherited the secret that was supposed to distinguish the shell from the programs it runs — any program could forge a boundary. fish and PowerShell emitted no token in any mark. All four hooks now take the token out of the environment into a shell-local variable and unset the environment entry, so the token exists only in the shell that owns the marks. fish gained token-bearing C and D marks, which it never had. Verified live in a real bash session: after sourcing, `env | grep -c ZAG_MARKER_TOKEN` is 0 and `__zag_preexec` emits a mark carrying the token. Tests for both, each checked by reverting the fix and watching it fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- docs/roadmap.md | 16 + docs/threat-model.md | 2 + evidence/conformance.jsonl | 34 +-- src/records/ledger.zig | 456 +++++++++++++++++++++++++---- src/terminal/shell_integration.zig | 100 ++++++- 5 files changed, 524 insertions(+), 84 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index fb454e1..4d73fde 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -110,6 +110,22 @@ open for clients, and it has no socket. `zag check` never loaded the requirement register. Every profile naming a requirement was therefore checked against an empty set, and eleven dangling identifiers went unnoticed. +The worst of them was in the recording terminal. The shell hooks read a variable +called `__zag_token` in four places, and every hook left it empty. Each boundary +mark therefore went out unauthenticated. An authenticated session rejected every +one, so `zag term` ended up recording no command boundaries. + +The environment variable carrying that secret stayed set as well. Every program +the person ran inherited the token whose job is to stop programs forging +boundaries. The hooks now move it into a shell variable and unset it, and a test +fails if a hook stops doing either. + +The records ledger called itself append-only and hash-chained while three +functions edited sealed records in place. A hold, its release and a disposal +changed fields that lay outside every hash, with no entry to mark them. Somebody +could lift a hold and destroy a record, leaving nothing behind. The ledger holds immutable +chained entries now, and a record's state is folded from them. + Deleting and pushing were refused as typed requests and reachable by running the program, so the typed refusal was the one that could not run. An agent now has to use the typed request, and may not run a shell at all. diff --git a/docs/threat-model.md b/docs/threat-model.md index e5746b3..a0cd3ba 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -39,6 +39,8 @@ against that actor. | T1 | Repository text tells an agent that it has more permission | Permissions come only from typed policy; instruction files that try to grant permission are reported | Test every concrete executor against the decision it receives | | T14 | The decision is obtained and then not spent | The executor takes the decision as a parameter and refuses one that does not authorise the request, so a caller that does not pass the right one cannot act. The agent runtime's seam used to take only the request, and the runtime discarded the decision it had just obtained; four tests now fail if a decision stops being checked | Keep the seam shaped so a decision cannot be omitted. A new executor that ignores its decision parameter would not be caught by a type | | T15 | Two definitions of what a request touches | One definition, in `tools.ToolRequest.resource`. A second copy in the executor narrowed an execute request to its program name and collapsed a git push to a path, so a decision made on `git push origin main` could not authorise the request it was made for. The two halves of the security model had never agreed, because each was only ever tested against decisions it built itself | An end-to-end test from a written policy file through a decision to a real executor run | +| T19 | A program forges a command boundary in an interactive session | Boundary marks carry a per-session secret, and the shell hook moves it out of the environment into a shell variable so nothing the person runs inherits it. This was written down and not done: the hooks read a variable nothing assigned, so every mark went out unauthenticated, the session rejected all of them, and the environment variable stayed set for every child to read | The secret is in the byte stream the outer terminal receives. A terminal that logs its input, or a session recorded with `script`, captures it | +| T20 | A record is altered after it is sealed | The records ledger holds immutable chained entries; a hold, a release, a supersession and a disposal are each an entry, and a record's state is folded from them. It used to edit sealed records in place, in fields no hash covered | The ledger is not yet written to disk, so it holds only what one process did | | T17 | The record cannot say who acted | Every event carries an actor derived from the account and machine names, and a person, the workbench and an agent are three different actors in three different namespaces. Every event used to carry sixteen zero bytes, so the envelope could not answer the question an audit trail exists for | The identity is derived from an environment the person controls, so it names an account and is not evidence about a human being. A deployment that needs the second thing needs a signed identity | | T18 | The record format changes underneath the hashes | Golden vectors pin the exact bytes this program writes, and a test asserts a stored line still hashes to its stored hash — the original bytes, not a parsed-and-re-encoded version, which is the only direction that notices the encoder and decoder drifting together | The vectors cover the shapes in use today. A payload kind added without a vector is unpinned until one is written | | T16 | A permission spreads to a resource its author never named | A rule's scope now covers only the axes it names. An allow rule scoped to hosts used to match `network.connect` on a command or a path resource, because an unnamed axis meant "no restriction on this axis". A refusal keeps the older, wider reading, so tightening this did not narrow any deny | Enforce at the socket as well: the host is checked before the request is built, not when the address is resolved | diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index d423e95..82ad59d 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T02:44:35.358362410Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/records/ledger.zig b/src/records/ledger.zig index 9b60a2e..0f7f610 100644 --- a/src/records/ledger.zig +++ b/src/records/ledger.zig @@ -9,6 +9,27 @@ //! stated rule, and disposed of only with evidence. A record under legal hold //! cannot be disposed of at all, which is the property that makes the ledger //! worth keeping. +//! +//! ## Why entries, and not records with fields that change +//! +//! "Append-only" used to be a sentence in this comment rather than a property +//! of the code. A record was a struct, and placing a hold, releasing it, or +//! disposing of the record wrote straight into that struct's fields. None of +//! those fields were in the hash, and no entry marked the change, so the one +//! thing the ledger exists to prove — that nobody quietly altered a record — +//! was exactly the thing it did not do. A hold could be lifted and a record +//! destroyed with no trace of either. +//! +//! So the ledger holds *entries*, and every entry is immutable and chained. +//! Sealing a record is an entry. Placing a hold is another entry, about that +//! record. Releasing it, superseding it and disposing of it are entries too. +//! What a record *is* right now is folded from its entries, the same way a +//! block is folded from the event log, and there is nowhere to write except the +//! end. +//! +//! The cost is that a record's current state has to be computed rather than +//! read. That is the right cost: it means there is no copy of the state to fall +//! out of step with the entries that produced it. const std = @import("std"); const idmod = @import("../core/id.zig"); @@ -186,9 +207,51 @@ pub const Error = error{ pub const genesis_label = "zag.record-ledger.v1"; +/// One thing that happened to a record. +/// +/// Every one of these is immutable once written and chained to the one before +/// it. A record's state is folded from the entries about it. +pub const Change = union(enum) { + /// The record was created. + sealed: Sealed, + /// A hold was placed on it. + held: LegalHold, + /// The hold was lifted. + hold_released: struct { by: idmod.ActorId }, + /// A later record replaced it. + superseded: struct { by: idmod.RecordId }, + /// It was disposed of. + disposed: struct { method: Disposition, witness: idmod.ActorId }, + + pub const Sealed = struct { + title: []const u8, + classification: Classification, + retention: RetentionClass, + content_hash: Hash, + provenance: ?provenance_mod.Provenance = null, + supersedes: ?idmod.RecordId = null, + }; + + pub fn name(self: Change) []const u8 { + return @tagName(self); + } +}; + +pub const Entry = struct { + /// The record this entry is about. + record: idmod.RecordId, + at: Timestamp, + by: idmod.ActorId, + change: Change, + /// Hash of this entry's own content. + content_hash: Hash, + /// Hash chaining it to the entry before. + chain_hash: Hash, +}; + pub const Ledger = struct { arena: std.mem.Allocator, - records: std.ArrayList(Record) = .empty, + entries: std.ArrayList(Entry) = .empty, head: Hash, ids: idmod.Generator, @@ -207,67 +270,187 @@ pub const Ledger = struct { supersedes: ?idmod.RecordId = null, }; - /// Write a record. Once sealed, its content hash and its place in the chain - /// cannot change without the chain showing it. + /// The bytes an entry is hashed over. + /// + /// Everything that says what happened goes in: which record, when, who, and + /// the whole of the change. An entry whose hold reason was edited hashes + /// differently, which is the property the old shape lacked. + fn contentOf(entry_record: idmod.RecordId, at: Timestamp, by: idmod.ActorId, change: Change) Hash { + var time_buf: [8]u8 = undefined; + std.mem.writeInt(u64, &time_buf, @bitCast(at.ns), .little); + + var parts: [12][]const u8 = undefined; + var used: usize = 0; + parts[used] = &entry_record.raw.bytes; + used += 1; + parts[used] = &time_buf; + used += 1; + parts[used] = &by.raw.bytes; + used += 1; + parts[used] = change.name(); + used += 1; + switch (change) { + .sealed => |e| { + parts[used] = e.title; + used += 1; + parts[used] = @tagName(e.classification); + used += 1; + parts[used] = e.retention.id; + used += 1; + parts[used] = &e.content_hash.bytes; + used += 1; + }, + .held => |hold| { + parts[used] = hold.id; + used += 1; + parts[used] = hold.reason; + used += 1; + parts[used] = &hold.placed_by.raw.bytes; + used += 1; + }, + .hold_released => |e| { + parts[used] = &e.by.raw.bytes; + used += 1; + }, + .superseded => |e| { + parts[used] = &e.by.raw.bytes; + used += 1; + }, + .disposed => |e| { + parts[used] = @tagName(e.method); + used += 1; + parts[used] = &e.witness.raw.bytes; + used += 1; + }, + } + return Hash.ofParts(parts[0..used]); + } + + /// Append one entry. The only way anything enters this ledger. + fn append( + self: *Ledger, + entry_record: idmod.RecordId, + at: Timestamp, + by: idmod.ActorId, + change: Change, + ) !Entry { + const content = contentOf(entry_record, at, by, change); + const entry: Entry = .{ + .record = entry_record, + .at = at, + .by = by, + .change = change, + .content_hash = content, + .chain_hash = Hash.chain(self.head, content), + }; + self.head = entry.chain_hash; + try self.entries.append(self.arena, entry); + return entry; + } + + /// Write a record. Once sealed, nothing about it can be changed: a later + /// fact about it is a later entry. pub fn seal(self: *Ledger, options: SealOptions) !Record { self.ids.clock_ms = @divFloor(options.created_at.ns, timeutil.ns_per_ms); const retention = options.retention orelse options.classification.defaultRetention(); + const id = self.ids.next(idmod.RecordId); - var title_buf: [8]u8 = undefined; - std.mem.writeInt(u64, &title_buf, @bitCast(options.created_at.ns), .little); - const content = Hash.ofParts(&.{ - options.title, - @tagName(options.classification), - retention.id, - &options.content_hash.bytes, - &title_buf, - }); - - const record: Record = .{ - .id = self.ids.next(idmod.RecordId), + _ = try self.append(id, options.created_at, options.created_by, .{ .sealed = .{ .title = options.title, .classification = options.classification, .retention = retention, - .created_at = options.created_at, - .created_by = options.created_by, .content_hash = options.content_hash, - .chain_hash = Hash.chain(self.head, content), .provenance = options.provenance, .supersedes = options.supersedes, - }; - self.head = record.chain_hash; - try self.records.append(self.arena, record); + } }); - if (options.supersedes) |older_id| { - if (self.find(older_id)) |older| older.superseded_by = record.id; + if (options.supersedes) |older| { + _ = try self.append(older, options.created_at, options.created_by, .{ + .superseded = .{ .by = id }, + }); + } + return self.find(id).?; + } + + /// What a record is now, folded from every entry about it. + /// + /// Returns null when no entry has sealed that identifier. + pub fn find(self: Ledger, id: idmod.RecordId) ?Record { + var record: ?Record = null; + for (self.entries.items) |entry| { + if (!entry.record.eql(id)) continue; + switch (entry.change) { + .sealed => |e| record = .{ + .id = id, + .title = e.title, + .classification = e.classification, + .retention = e.retention, + .created_at = entry.at, + .created_by = entry.by, + .content_hash = e.content_hash, + .chain_hash = entry.chain_hash, + .provenance = e.provenance, + .supersedes = e.supersedes, + }, + .held => |hold| { + if (record) |*r| r.hold = hold; + }, + .hold_released => |e| { + if (record) |*r| { + if (r.hold) |*hold| { + hold.released_at = entry.at; + hold.released_by = e.by; + } + } + }, + .superseded => |e| { + if (record) |*r| r.superseded_by = e.by; + }, + .disposed => |e| { + if (record) |*r| { + r.disposed_at = entry.at; + r.disposal_method = e.method; + r.disposal_witness = e.witness; + } + }, + } } return record; } - pub fn find(self: *Ledger, id: idmod.RecordId) ?*Record { - for (self.records.items) |*record| { - if (record.id.eql(id)) return record; + /// Every record, folded, in the order they were sealed. + pub fn all(self: Ledger) !std.ArrayList(Record) { + var out: std.ArrayList(Record) = .empty; + for (self.entries.items) |entry| { + if (entry.change != .sealed) continue; + if (self.find(entry.record)) |record| try out.append(self.arena, record); } - return null; + return out; } + /// How many records the ledger holds. Entries are counted by `entryCount`. pub fn count(self: Ledger) usize { - return self.records.items.len; + var total: usize = 0; + for (self.entries.items) |entry| { + if (entry.change == .sealed) total += 1; + } + return total; + } + + pub fn entryCount(self: Ledger) usize { + return self.entries.items.len; } /// Put a hold on a record. While the hold is active the record cannot be /// disposed of, whatever its retention period says. pub fn placeHold(self: *Ledger, id: idmod.RecordId, hold: LegalHold) Error!void { - const record = self.find(id) orelse return error.UnknownRecord; - record.hold = hold; + _ = self.find(id) orelse return error.UnknownRecord; + _ = try self.append(id, hold.placed_at, hold.placed_by, .{ .held = hold }); } pub fn releaseHold(self: *Ledger, id: idmod.RecordId, by: idmod.ActorId, at: Timestamp) Error!void { - const record = self.find(id) orelse return error.UnknownRecord; - if (record.hold) |*hold| { - hold.released_at = at; - hold.released_by = by; - } + _ = self.find(id) orelse return error.UnknownRecord; + _ = try self.append(id, at, by, .{ .hold_released = .{ .by = by } }); } /// Dispose of a record, recording that it happened. @@ -279,56 +462,59 @@ pub const Ledger = struct { } if (at.ns < record.dueAt().ns) return error.NotYetDue; - record.disposed_at = at; - record.disposal_method = record.retention.disposition; - record.disposal_witness = witness; + _ = try self.append(id, at, witness, .{ .disposed = .{ + .method = record.retention.disposition, + .witness = witness, + } }); - // The disposal is itself a record, so the ledger can show that the - // record existed and was destroyed on purpose. + // The disposal is also its own record, so the ledger can show that the + // record existed and was destroyed on purpose even after the thing it + // pointed at is gone. var id_buf: [idmod.RecordId.text_len]u8 = undefined; _ = try self.seal(.{ - .title = try std.fmt.allocPrint(self.arena, "Disposed of record {s} by {s}", .{ record.id.toText(&id_buf), @tagName(record.retention.disposition) }), + .title = try std.fmt.allocPrint(self.arena, "Disposed of record {s} by {s}", .{ + record.id.toText(&id_buf), + @tagName(record.retention.disposition), + }), .classification = .decision, .content_hash = record.content_hash, .created_by = witness, .created_at = at, }); - return record.*; + return self.find(id).?; } pub fn dueFor(self: Ledger, now: Timestamp) !std.ArrayList(Record) { - var out: std.ArrayList(Record) = .empty; - for (self.records.items) |record| { - if (record.state(now) == .due) try out.append(self.arena, record); - } - return out; + return self.inState(.due, now); } pub fn onHold(self: Ledger, now: Timestamp) !std.ArrayList(Record) { + return self.inState(.on_hold, now); + } + + fn inState(self: Ledger, wanted: State, now: Timestamp) !std.ArrayList(Record) { var out: std.ArrayList(Record) = .empty; - for (self.records.items) |record| { - if (record.state(now) == .on_hold) try out.append(self.arena, record); + const records = try self.all(); + for (records.items) |record| { + if (record.state(now) == wanted) try out.append(self.arena, record); } return out; } - /// Re-derive the chain. Returns the index of the first record that does not + /// Re-derive the chain. Returns the index of the first entry that does not /// match, or null when the ledger is intact. + /// + /// Every entry is covered, not only the sealings. A hold placed and quietly + /// lifted, or a disposal with the witness changed, shows up here — which it + /// could not when a hold was a field somebody wrote into a struct. pub fn verify(self: Ledger) ?usize { var chain = Hash.of(genesis_label); - for (self.records.items, 0..) |record, index| { - var time_buf: [8]u8 = undefined; - std.mem.writeInt(u64, &time_buf, @bitCast(record.created_at.ns), .little); - const content = Hash.ofParts(&.{ - record.title, - @tagName(record.classification), - record.retention.id, - &record.content_hash.bytes, - &time_buf, - }); + for (self.entries.items, 0..) |entry, index| { + const content = contentOf(entry.record, entry.at, entry.by, entry.change); + if (!content.eql(entry.content_hash)) return index; const expected = Hash.chain(chain, content); - if (!expected.eql(record.chain_hash)) return index; - chain = record.chain_hash; + if (!expected.eql(entry.chain_hash)) return index; + chain = entry.chain_hash; } return null; } @@ -336,7 +522,8 @@ pub const Ledger = struct { /// The disposition schedule a person reviews. pub fn writeSchedule(self: Ledger, w: *std.Io.Writer, now: Timestamp) !void { try w.writeAll("Records and what happens to them\n\n"); - for (self.records.items) |record| { + const records = try self.all(); + for (records.items) |record| { var id_buf: [idmod.RecordId.text_len]u8 = undefined; var due_buf: [Timestamp.text_len_max]u8 = undefined; try w.print("{s} {s}\n", .{ record.id.toText(&id_buf), record.title }); @@ -354,6 +541,18 @@ pub const Ledger = struct { try w.writeAll("\n"); } } + + /// Everything that happened to one record, in order. + /// + /// This is what an auditor asks for: not the record's current state, but + /// how it got there and who did each part. + pub fn historyOf(self: Ledger, id: idmod.RecordId) !std.ArrayList(Entry) { + var out: std.ArrayList(Entry) = .empty; + for (self.entries.items) |entry| { + if (entry.record.eql(id)) try out.append(self.arena, entry); + } + return out; + } }; const testing = std.testing; @@ -386,7 +585,8 @@ test "records are chained, and an edit is detectable" { try testing.expectEqual(@as(usize, 2), ledger.count()); try testing.expect(ledger.verify() == null); - ledger.records.items[0].title = "Agent 4 was allowed to do anything."; + // Editing what a sealed entry says is caught, wherever in the chain it is. + ledger.entries.items[0].change.sealed.title = "Agent 4 was allowed to do anything."; try testing.expectEqual(@as(usize, 0), ledger.verify().?); } @@ -528,3 +728,131 @@ test "the schedule shows what is due and what is held" { try testing.expect(std.mem.indexOf(u8, text, "Now: due") != null); try testing.expect(std.mem.indexOf(u8, text, "Needed for the incident review.") != null); } + +test "a hold and a disposal are entries, and editing either is caught" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var gen: idmod.Generator = .init(601, 1_788_000_000_000); + var ledger = Ledger.init(arena, 602); + const keeper = gen.next(idmod.ActorId); + const lawyer = gen.next(idmod.ActorId); + const created = try Timestamp.parseIso("2026-01-01T00:00:00Z"); + const later = created.addNanos(timeutil.ns_per_day * 200); + + const record = try ledger.seal(.{ + .title = "Command output from the incident.", + .classification = .operational, + .content_hash = Hash.of("output"), + .created_by = keeper, + .created_at = created, + }); + + // Placing a hold adds an entry. It used to write into a field that no hash + // covered, so a hold could be placed and lifted with no trace of either. + const before_hold = ledger.entryCount(); + try ledger.placeHold(record.id, .{ + .id = "H-1", + .reason = "Kept for the review of the 4 September incident.", + .placed_by = lawyer, + .placed_at = created, + }); + try testing.expectEqual(before_hold + 1, ledger.entryCount()); + try testing.expect(ledger.verify() == null); + try testing.expectEqual(State.on_hold, ledger.find(record.id).?.state(later)); + + // Editing the reason on a written entry breaks the chain at that entry. + const at_hold = ledger.entryCount() - 1; + const kept = ledger.entries.items[at_hold].change.held.reason; + ledger.entries.items[at_hold].change.held.reason = "Routine."; + try testing.expectEqual(at_hold, ledger.verify().?); + ledger.entries.items[at_hold].change.held.reason = kept; + try testing.expect(ledger.verify() == null); + + // Releasing and disposing are entries too, and the record's history reads + // as the sequence of things that happened to it. + try ledger.releaseHold(record.id, lawyer, later); + const disposed = try ledger.dispose(record.id, keeper, later.addNanos(timeutil.ns_per_s)); + try testing.expect(disposed.disposed_at != null); + try testing.expectEqual(Disposition.delete, disposed.disposal_method.?); + try testing.expect(ledger.verify() == null); + + const history = try ledger.historyOf(record.id); + try testing.expectEqual(@as(usize, 4), history.items.len); + try testing.expect(history.items[0].change == .sealed); + try testing.expect(history.items[1].change == .held); + try testing.expect(history.items[2].change == .hold_released); + try testing.expect(history.items[3].change == .disposed); + + // And who did each part is on the entry, not inferred. + try testing.expect(history.items[1].by.eql(lawyer)); + try testing.expect(history.items[3].by.eql(keeper)); +} + +test "a disposal cannot be quietly removed from the record" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var gen: idmod.Generator = .init(603, 1_788_000_000_000); + var ledger = Ledger.init(arena, 604); + const keeper = gen.next(idmod.ActorId); + const created = try Timestamp.parseIso("2026-01-01T00:00:00Z"); + const due = created.addNanos(timeutil.ns_per_day * 200); + + const record = try ledger.seal(.{ + .title = "Output nobody wants found.", + .classification = .operational, + .content_hash = Hash.of("output"), + .created_by = keeper, + .created_at = created, + }); + _ = try ledger.dispose(record.id, keeper, due); + try testing.expect(ledger.verify() == null); + + // Changing the witness on the disposal entry is caught. Under the old + // shape the witness was a field on the record, covered by no hash, so this + // was a silent edit. + var at: usize = 0; + for (ledger.entries.items, 0..) |entry, index| { + if (entry.change == .disposed) at = index; + } + ledger.entries.items[at].change.disposed.witness = gen.next(idmod.ActorId); + try testing.expectEqual(at, ledger.verify().?); +} + +test "the state a record is in is folded, never stored" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var gen: idmod.Generator = .init(605, 1_788_000_000_000); + var ledger = Ledger.init(arena, 606); + const keeper = gen.next(idmod.ActorId); + const created = try Timestamp.parseIso("2026-01-01T00:00:00Z"); + + const record = try ledger.seal(.{ + .title = "A decision.", + .classification = .decision, + .content_hash = Hash.of("decision"), + .created_by = keeper, + .created_at = created, + }); + + // The same record, asked about at three moments, answers three ways — + // without anything having been written in between. There is no stored + // state to fall out of step with the entries. + try testing.expectEqual(State.active, ledger.find(record.id).?.state(created)); + try testing.expectEqual( + State.active, + ledger.find(record.id).?.state(created.addNanos(timeutil.ns_per_day * 365)), + ); + try testing.expectEqual( + State.due, + ledger.find(record.id).?.state(created.addNanos(timeutil.ns_per_day * 365 * 8)), + ); + + // An identifier nobody sealed is not a record, rather than an empty one. + try testing.expect(ledger.find(gen.next(idmod.RecordId)) == null); +} diff --git a/src/terminal/shell_integration.zig b/src/terminal/shell_integration.zig index 169be2c..87a86b7 100644 --- a/src/terminal/shell_integration.zig +++ b/src/terminal/shell_integration.zig @@ -220,6 +220,15 @@ pub const hooks = struct { \\# is not used: it fires before every simple command, including the \\# ones inside the prompt's own functions, which produces marks for \\# commands nobody typed. + \\# + \\# The marker token arrives in the environment and is moved into a shell + \\# variable here, then removed from the environment. Two reasons. A shell + \\# variable is not exported, so nothing the person runs inherits it and no + \\# program can forge a boundary mark. And it has to be moved *somewhere*: + \\# the hooks below read `__zag_token`, and nothing used to set it, so every + \\# mark went out unauthenticated and the workbench rejected all of them. + \\__zag_token="${ZAG_MARKER_TOKEN:-}" + \\unset ZAG_MARKER_TOKEN \\__zag_prompt_start() { printf '\033]133;A\007'; } \\__zag_command_start() { printf '\033]133;B\007'; } \\__zag_report_cwd() { printf '\033]7;file://%s%s\007' "${HOSTNAME:-}" "$PWD"; } @@ -249,6 +258,11 @@ pub const hooks = struct { pub const zsh = \\# zag shell integration for zsh. + \\# + \\# The token moves out of the environment into a shell variable, so nothing + \\# the person runs inherits it. See the bash hook for why. + \\__zag_token="${ZAG_MARKER_TOKEN:-}" + \\unset ZAG_MARKER_TOKEN \\__zag_precmd() { \\ local status=$? \\ if [ -n "${__zag_token:-}" ]; then @@ -274,21 +288,45 @@ pub const hooks = struct { pub const fish = \\# zag shell integration for fish. + \\# + \\# The token moves out of the environment into a shell variable, so nothing + \\# the person runs inherits it. This hook used to emit no token at all, + \\# which meant a fish session recorded no authenticated boundaries. + \\set -g __zag_token $ZAG_MARKER_TOKEN + \\set -e ZAG_MARKER_TOKEN \\function __zag_prompt --on-event fish_prompt - \\ printf '\033]133;D;%s\007' $status + \\ set -l code $status + \\ if test -n "$__zag_token" + \\ printf '\033]133;D;%s;token=%s\007' $code $__zag_token + \\ else + \\ printf '\033]133;D;%s\007' $code + \\ end \\ printf '\033]7;file://%s%s\007' (hostname) $PWD \\ printf '\033]133;A\007' \\end \\function __zag_preexec --on-event fish_preexec - \\ printf '\033]133;C;cmdline=%s\007' $argv[1] + \\ if test -n "$__zag_token" + \\ printf '\033]133;C;token=%s;cmdline=%s\007' $__zag_token $argv[1] + \\ else + \\ printf '\033]133;C;cmdline=%s\007' $argv[1] + \\ end \\end ; pub const powershell = \\# zag shell integration for PowerShell. + \\# + \\# The token moves out of the environment into a script variable, so nothing + \\# the person runs inherits it. + \\$Global:__ZagToken = $env:ZAG_MARKER_TOKEN + \\Remove-Item Env:ZAG_MARKER_TOKEN -ErrorAction SilentlyContinue \\function Global:__Zag-Prompt { \\ $status = if ($?) { 0 } else { 1 } - \\ Write-Host -NoNewline "$([char]27)]133;D;$status$([char]7)" + \\ if ($Global:__ZagToken) { + \\ Write-Host -NoNewline "$([char]27)]133;D;$status;token=$Global:__ZagToken$([char]7)" + \\ } else { + \\ Write-Host -NoNewline "$([char]27)]133;D;$status$([char]7)" + \\ } \\ Write-Host -NoNewline "$([char]27)]7;file://$env:COMPUTERNAME$($PWD.Path)$([char]7)" \\ Write-Host -NoNewline "$([char]27)]133;A$([char]7)" \\} @@ -414,3 +452,59 @@ test "the 633 family is treated as an alias" { const boundary = (try tracker.consume("633;C;cmdline=ls")).?; try testing.expectEqualStrings("ls", boundary.output_start.command.?); } + +test "every hook takes the token out of the environment and uses it" { + // Three properties, and the first is the one that was missing. The hooks + // read `__zag_token` in four places and nothing assigned it, so every mark + // went out unauthenticated and an authenticated tracker rejected all of + // them: `zag term` recorded no command boundaries at all. The environment + // variable also stayed set, so every program the person ran inherited the + // secret that exists to stop programs forging boundaries. + const Hook = struct { + name: []const u8, + text: []const u8, + /// How this shell reads the variable it was given. + takes: []const u8, + /// How it removes it from the environment. + removes: []const u8, + }; + const each = [_]Hook{ + .{ .name = "bash", .text = hooks.bash, .takes = "__zag_token=\"${ZAG_MARKER_TOKEN:-}\"", .removes = "unset ZAG_MARKER_TOKEN" }, + .{ .name = "zsh", .text = hooks.zsh, .takes = "__zag_token=\"${ZAG_MARKER_TOKEN:-}\"", .removes = "unset ZAG_MARKER_TOKEN" }, + .{ .name = "fish", .text = hooks.fish, .takes = "set -g __zag_token $ZAG_MARKER_TOKEN", .removes = "set -e ZAG_MARKER_TOKEN" }, + .{ .name = "pwsh", .text = hooks.powershell, .takes = "$Global:__ZagToken = $env:ZAG_MARKER_TOKEN", .removes = "Remove-Item Env:ZAG_MARKER_TOKEN" }, + }; + + for (each) |hook| { + // It takes the token somewhere the shell can read it. + try testing.expect(std.mem.indexOf(u8, hook.text, hook.takes) != null); + // It removes it from the environment, so nothing the person runs + // inherits it. + try testing.expect(std.mem.indexOf(u8, hook.text, hook.removes) != null); + // And it puts it on the marks, so the workbench can tell a boundary the + // shell wrote from one a program printed. + try testing.expect(std.mem.indexOf(u8, hook.text, "token=") != null); + } +} + +test "a boundary a program printed is refused, and the shell's is accepted" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The marks the fixed bash hook produces, with the token the session gave + // it. These are the exact strings the shell prints. + var tracker = Tracker.initAuthenticated(arena, "SECRET123"); + + const started = (try tracker.consume("133;C;token=SECRET123;cmdline=zig build test")).?; + try testing.expectEqualStrings("zig build test", started.output_start.command.?); + const finished = (try tracker.consume("133;D;3;token=SECRET123")).?; + try testing.expectEqual(@as(u8, 3), finished.command_finished.exit_status.?); + + // A program that prints the same shape without the token, or with a guess, + // cannot close a block or open one. This is what the token is for, and it + // did nothing at all while no hook set one. + try testing.expect((try tracker.consume("133;D;0")) == null); + try testing.expect((try tracker.consume("133;D;0;token=guessed")) == null); + try testing.expect((try tracker.consume("133;C;token=guessed;cmdline=rm -rf /")) == null); +} From f4ed63a45024f65692c212b558c575bf1b7ddd92 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:58:33 +0000 Subject: [PATCH 07/12] Take credentials out of text before it becomes a record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap has carried "add secret detection and redaction policy" as an open Priority 1 item since the security work began. It matters more here than in a terminal that forgets: the log is append-only and hash-chained, so a key written into it cannot be edited out without breaking the chain that proves nothing was edited. There is no `git rm` for a record whose whole value is that it cannot be rewritten. src/security/secrets.zig finds credentials in three passes, deliberately different in kind because the ways a secret shows itself are different in kind. A delimited block announces itself and is removed header to footer, including a block truncated mid-key, which is exactly where a scanner that insists on finding the footer writes the body down. A published prefix — sk-ant-, ghp_, AKIA, xoxb-, glpat-, AIza and the rest — is matched from a comptime table with no threshold involved at all; providers chose those prefixes so leaked keys could be recognised, and a token with the prefix, the length and the alphabet is a key rather than a coincidence. A value assigned to a name that says "secret" is caught by entropy and character class, which is the only pass that can be wrong and the only one that reports itself as inferred rather than recognised. Entropy is never a reason to redact on its own. A build's output is full of high-entropy strings that a reader needs: commit ids, content hashes, digests, UUIDs, base64 images, stack frames. There is a test naming eleven of them, because a detector that fires on those makes every record unreadable, and a record nobody reads protects nobody. A redaction leaves a placeholder naming the kind and four hex digits of a salted fingerprint, so one key in twenty places reads as one key and two keys never read as one — the question an incident actually turns on. The salt is drawn per workspace from the operating system and never written down: without it the fingerprint is an oracle anybody holding the record could brute-force short secrets out of. Wired into every path where text becomes a record or leaves the machine. Command text is redacted before the event is written, because `export API_KEY=…` is in every shell history there is. Captured output is redacted at the one point where it becomes a content object. Everything sent to a provider is redacted before encoding — prompt, system text, tool arguments and tool results, that last being the likeliest route by which a key reaches a third party, since it is how a file the model asked to read comes back. The prompt is hashed after the redaction, so the recorded hash is the hash of what actually left rather than of a draft nobody sent. The count of removals goes on the event as well as into the content, because the event is what the hash chain covers and the content object is not: a reader holding only the log can still tell a clean block from a redacted one. Adding those two fields changed the record format, and the golden vectors in events/format.zig failed exactly as they were built to. They are regenerated here in the same commit, which is the mechanism working rather than an inconvenience. `zag secrets ` shows what would be removed without writing anything and without printing any secret. "redaction" is registered in the vocabulary with its deprecated synonym. The threat model gains T21 and a review trigger; the roadmap item is narrowed to the outputs this build does not write yet rather than closed. What it does not do is find every secret, and it says so in its own documentation rather than implying otherwise. A bare password with no prefix, no delimiter and no name beside it is not found, and no detector finds one. The end-to-end test was checked by removing the wiring and watching it fail: a real command printing a real key shape, asserting the key is absent from the command text, from the stored bytes, and from the log file on disk. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- docs/glossary.md | 8 + docs/roadmap.md | 6 +- docs/threat-model.md | 3 + evidence/conformance.jsonl | 34 +- schemas/openapi.json | 8 + schemas/workspace-event.schema.json | 8 + src/ai/transport.zig | 123 ++- src/cli/main.zig | 71 ++ src/events/event.zig | 11 + src/events/format.zig | 6 +- src/knowledge/vocabulary.zig | 15 + src/root.zig | 4 + src/security/secrets.zig | 1082 +++++++++++++++++++++++++++ src/terminal/interactive.zig | 1 + src/terminal/session.zig | 42 +- src/workspace/service.zig | 73 ++ vocab/workspace.jsonld | 50 ++ vocab/workspace.thesaurus.txt | 15 + vocab/workspace.ttl | 14 + 19 files changed, 1547 insertions(+), 27 deletions(-) create mode 100644 src/security/secrets.zig diff --git a/docs/glossary.md b/docs/glossary.md index ee53590..e6d61a4 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -58,6 +58,14 @@ named permission to perform one class of operation, such as reading files or ope rule set that decides whether an actor may use a capability on a resource, and whether a person must approve first +## redaction + +replacement of a credential in text by a placeholder that names its kind, done before the text becomes a record or leaves the machine + +Also called: masking + +Do not use: scrubbing + ## authorisation decision record of one policy outcome, naming the actor, the capability, the resource, the result and the reason diff --git a/docs/roadmap.md b/docs/roadmap.md index 4d73fde..5277a75 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -170,8 +170,10 @@ Still open: not caught. - Review `docs/threat-model.md` with local users, malicious repositories, compromised child processes, model providers and remote clients in scope. -- Add secret detection and redaction policy for terminal output, model context, - diagnostic bundles and security reports. +- Extend redaction to the remaining outputs. Command text, captured output and + everything sent to a model are redacted today. Diagnostic bundles and + generated security reports are not written by this build yet, and must be + covered when they are. ## Priority 2: make the terminal one a person can live in diff --git a/docs/threat-model.md b/docs/threat-model.md index a0cd3ba..9ca48a7 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -55,6 +55,7 @@ against that actor. | T12a | A model asks for a tool that would widen its own reach | A model's tool call becomes a typed request or nothing: an unknown name, arguments that do not fit the type, or an unknown enum value are all refused before any policy is consulted. The capability is taken from the request kind, never from the arguments, so a model that writes a capability into its own call is writing a field that does not exist. `infer` and `spawn_agent` are not offered at all, and a test asserts their absence rather than leaving it to whoever edits the list | Offer a way for a person to widen the offered set deliberately, with the widening recorded | | T12b | An agent loop runs away, or repeats a refused call for ever | Four bounds, each reported separately: turns, tokens, a monotonic wall clock, and a repetition count over a fingerprint of the request's kind and resource. The fingerprint deliberately ignores fields nobody decides on, so varying a byte limit cannot defeat it | Bound the total work an agent may cause across runs, not only within one | | T13 | A workspace policy file is written to widen permission | The file is never shown to a model and never consulted by one. Its fallback is not read from the file: an unparsable file allows nothing, and nothing more permissive is substituted. An unknown capability name refuses the whole file rather than dropping the rule, so a denial cannot be narrowed by a typo | Add a recorded event when the policy file changes, so a widening is visible in the log rather than only in the file | +| T21 | A credential printed by a command is written into a log that cannot be edited afterwards | Command text and captured output are scanned before either becomes a record, and a credential is replaced by a placeholder naming its kind and a salted fingerprint. The same scan runs on everything sent to a model provider, including tool results, and the prompt is hashed after the redaction rather than before, so the record points at what actually left. The count of removals is on the event, which is what the hash chain covers, so a reader holding only the log can tell a clean block from a redacted one. `zag secrets` shows what would be removed without writing anything | The detector finds published credential shapes, delimited key blocks, credentials in URLs, and values assigned to a name that says "secret". A bare password with no prefix, no delimiter and no name beside it is not found, and no detector finds one | | T9 | A terminal sequence corrupts memory or parser state | Fixed parser bounds, UTF-8 replacement, screen bounds and regression tests | Add coverage-guided fuzzing and differential tests against a mature terminal parser | | T10 | A build input is replaced upstream | No package dependencies; CI pins the checkout action and verifies the Zig archive digest | Add signed release provenance, artifact signing and an independently verified toolchain policy | | T11 | A future remote client bypasses local policy | No network listener exists today | Define authentication, per-request authorisation, replay protection, rate limits and audit before listening on a socket | @@ -106,6 +107,8 @@ Review this model before merging a change that: - joins an argument vector back into a command line; - changes how the workspace policy file is read, or what its fallback is; - changes event encoding, recovery, retention or migration; +- changes what the secret detector looks for, or removes a call to it from a + path where text becomes a record or leaves the machine; - describes an event record as proof, authentic or tamper-proof. Report a discovered vulnerability through `SECURITY.md`. Keep affected records diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index 82ad59d..bfc1274 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:3e0609ea1e196531e93435195cec8491b2f7336eed1a01f5c2e7d4fcd8dde2f9","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T03:07:51.659590806Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/schemas/openapi.json b/schemas/openapi.json index 89f20d0..4042ed2 100644 --- a/schemas/openapi.json +++ b/schemas/openapi.json @@ -1457,6 +1457,10 @@ }, "boundaryFromShell": { "type": "boolean" + }, + "redactions": { + "type": "integer", + "minimum": 0 } }, "required": [ @@ -1490,6 +1494,10 @@ "type": "integer", "minimum": 0, "maximum": 4294967295 + }, + "redactions": { + "type": "integer", + "minimum": 0 } }, "required": [ diff --git a/schemas/workspace-event.schema.json b/schemas/workspace-event.schema.json index 7fe63c4..d09a072 100644 --- a/schemas/workspace-event.schema.json +++ b/schemas/workspace-event.schema.json @@ -435,6 +435,10 @@ }, "boundaryFromShell": { "type": "boolean" + }, + "redactions": { + "type": "integer", + "minimum": 0 } }, "required": [ @@ -468,6 +472,10 @@ "type": "integer", "minimum": 0, "maximum": 4294967295 + }, + "redactions": { + "type": "integer", + "minimum": 0 } }, "required": [ diff --git a/src/ai/transport.zig b/src/ai/transport.zig index cf69753..42b0a06 100644 --- a/src/ai/transport.zig +++ b/src/ai/transport.zig @@ -39,6 +39,7 @@ const policy_mod = @import("policy.zig"); const capability_mod = @import("capability.zig"); const tools = @import("tools.zig"); const hashing = @import("../core/hash.zig"); +const secrets = @import("../security/secrets.zig"); pub const Connector = catalog.Connector; pub const Decision = policy_mod.Decision; @@ -131,6 +132,10 @@ pub const Attempt = struct { local: bool, /// The decisions made, in the order they were asked for. decisions: []const Decision, + /// How many credentials were taken out of the prompt before it was + /// encoded. Zero is worth recording as such: it is the difference between + /// "nothing was found" and "nobody looked". + redactions: usize = 0, /// Set when the request never went out. refusedAt: ?Stage = null, /// The prompt's hash. The prompt itself is the caller's to store or not; @@ -217,6 +222,16 @@ pub const Transport = struct { engine: *policy_mod.Engine, sender: Sender, credentials: Credentials = no_credentials, + /// Takes credentials out of the prompt before it is encoded. + /// + /// The three policy decisions below govern *whether* bytes may leave. This + /// governs *which* bytes do, which is a different question: a workspace can + /// legitimately allow a model to read a file and still not want the AWS key + /// that happened to be inside it sent to a third party. + /// + /// Optional so a wire-format test need not build one. Every path that + /// reaches a real provider sets it. + redactor: ?secrets.Redactor = null, /// Decide, then send. /// @@ -284,8 +299,16 @@ pub const Transport = struct { }; } + // Last chance. After this the bytes are somebody else's. + // + // The redaction happens before the prompt is hashed, so the hash in the + // record is the hash of what actually left rather than of a draft that + // never existed anywhere. + const outgoing = try self.withoutCredentials(request, attempt); + attempt.promptHash = hashPrompt(outgoing); + const wire = connector.wire(); - const http = wire.encode(self.arena, connector.endpoint(api_key), request) catch { + const http = wire.encode(self.arena, connector.endpoint(api_key), outgoing) catch { return error.SendFailed; }; const response = self.sender.send(self.arena, http) catch |err| { @@ -306,6 +329,53 @@ pub const Transport = struct { return completion; } + /// A copy of `request` with credentials taken out of every text the model + /// would see, and `attempt.redactions` set to how many were removed. + /// + /// Tool arguments and tool results are rewritten as well as the prompt: a + /// file the model asked to read comes back through `tool_result`, and that + /// is the likeliest way a key gets into a conversation in the first place. + fn withoutCredentials(self: Transport, request: provider.Request, attempt: *Attempt) Error!provider.Request { + const redactor = self.redactor orelse return request; + var removed: usize = 0; + + const rewrite = struct { + fn one(r: secrets.Redactor, arena: std.mem.Allocator, text: []const u8, total: *usize) ![]const u8 { + const result = try r.rewrite(arena, text); + total.* += result.findings.len; + return result.text; + } + }.one; + + var messages = self.arena.alloc(provider.Message, request.messages.len) catch return error.SendFailed; + for (request.messages, 0..) |message, index| { + var blocks = self.arena.alloc(provider.Block, message.blocks.len) catch return error.SendFailed; + for (message.blocks, 0..) |block, at| { + blocks[at] = switch (block) { + .text => |t| .{ .text = rewrite(redactor, self.arena, t, &removed) catch return error.SendFailed }, + .thinking => |t| .{ .thinking = rewrite(redactor, self.arena, t, &removed) catch return error.SendFailed }, + .tool_use => |t| .{ .tool_use = .{ + .id = t.id, + .name = t.name, + .argumentsJson = rewrite(redactor, self.arena, t.argumentsJson, &removed) catch return error.SendFailed, + } }, + .tool_result => |t| .{ .tool_result = .{ + .toolUseId = t.toolUseId, + .content = rewrite(redactor, self.arena, t.content, &removed) catch return error.SendFailed, + .isError = t.isError, + } }, + }; + } + messages[index] = .{ .role = message.role, .blocks = blocks }; + } + + var copy = request; + copy.system = rewrite(redactor, self.arena, request.system, &removed) catch return error.SendFailed; + copy.messages = messages; + attempt.redactions = removed; + return copy; + } + /// The typed request this attempt corresponds to, for the tool log. It is /// built from the same values the decisions were made on, so the record and /// the decision cannot drift apart. @@ -818,3 +888,54 @@ test "an attempt describes itself in words a person can read" { try testing.expectEqual(Capability.@"network.connect", request.additionalCapability().?); try testing.expectEqualStrings("anthropic", request.infer.provider); } + +test "a credential in the prompt never reaches the wire" { + // Everything is allowed here, so nothing but the redaction can be the + // reason a key is missing from the body. + var fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + // The likeliest route by which a key reaches a provider is not a person + // typing it. It is a file the model asked to read coming back as a tool + // result, so that path is exercised here alongside the prompt itself. + const request: provider.Request = .{ + .model = "claude-opus-5", + .system = "The operator key is sk-ant-api03-" ++ "v" ** 40, + .messages = &.{ + .{ .role = .user, .blocks = &.{.{ .text = "here is my token ghp_" ++ "u" ** 36 }} }, + .{ .role = .user, .blocks = &.{.{ .tool_result = .{ + .toolUseId = "call_1", + .content = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n", + } }} }, + }, + }; + + var transport = fixture.transport(with_key); + transport.redactor = secrets.Redactor.withSalt([_]u8{7} ** 16); + + var attempt: Attempt = undefined; + const connector = catalog.find("anthropic").?; + _ = try transport.send(connector, request, fixture.context(), &attempt); + + try testing.expectEqual(@as(usize, 1), fixture.recorded.seen.items.len); + const body = fixture.recorded.seen.items[0].body; + + try testing.expect(std.mem.indexOf(u8, body, "sk-ant-api03") == null); + try testing.expect(std.mem.indexOf(u8, body, "ghp_") == null); + try testing.expect(std.mem.indexOf(u8, body, "AKIAIOSFODNN7EXAMPLE") == null); + try testing.expectEqual(@as(usize, 3), attempt.redactions); + + // The words around each key survive, so the model can still be told what it + // is looking at and why the value is not there. + try testing.expect(std.mem.indexOf(u8, body, "here is my token") != null); + try testing.expect(std.mem.indexOf(u8, body, "AWS_ACCESS_KEY_ID=") != null); + try testing.expect(std.mem.indexOf(u8, body, "redacted") != null); + + // The recorded hash is the hash of what left, not of a draft that never + // existed anywhere. Without this the record would point at a prompt nobody + // ever sent. + var ignored: Attempt = attempt; + const outgoing = try transport.withoutCredentials(request, &ignored); + try testing.expect(attempt.promptHash.eql(hashPrompt(outgoing))); + try testing.expect(!attempt.promptHash.eql(hashPrompt(request))); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index e605671..22b22f5 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -42,6 +42,7 @@ const Command = enum { policy, ask, why, + secrets, fn parse(text: []const u8) ?Command { const table = [_]struct { name: []const u8, command: Command }{ @@ -78,6 +79,7 @@ const Command = enum { .{ .name = "policy", .command = .policy }, .{ .name = "ask", .command = .ask }, .{ .name = "why", .command = .why }, + .{ .name = "secrets", .command = .secrets }, }; for (table) |entry| { if (std.mem.eql(u8, entry.name, text)) return entry.command; @@ -121,6 +123,7 @@ pub const help_text = \\ policy Show the policy in force, and where it was read from. \\ ask Ask a model, through the policy. It can use tools, one decision each. \\ why [n] Show what an event depended on, and what it went on to affect. + \\ secrets Show what would be taken out of these files before recording them. \\ \\Options \\ --profile Use this conformance profile. The default is "default". @@ -329,6 +332,7 @@ fn run( .policy => try showPolicy(arena, io, w, options), .ask => try askAModel(arena, io, w, options, environment), .why => try whyDidThatHappen(arena, io, w, options), + .secrets => try showSecrets(arena, io, w, options), }; } @@ -1224,6 +1228,10 @@ fn askAModel( .engine = &engine, .sender = http.sender(), .credentials = view.credentials(), + // Nothing that looks like a credential leaves this machine, whatever + // the policy allowed to leave. The two are separate questions and this + // is the second one. + .redactor = try zag.security.secrets.Redactor.init(io), }; // The loop, not a single question. A model that can read the workspace and @@ -1322,6 +1330,69 @@ fn askAModel( /// the harder question: which earlier work this rests on through the files that /// passed between them, even when nothing caused anything and the two happened /// in different sessions hours apart. +/// Show what the redactor would take out of a file, without changing it. +/// +/// A person deciding whether to record something, or checking why a placeholder +/// appeared in a record they already have, needs to be able to ask this +/// directly. Nothing here writes anything, and the secrets themselves are never +/// printed — only what kind each one is, and where. +fn showSecrets( + arena: std.mem.Allocator, + io: std.Io, + w: *std.Io.Writer, + options: Options, +) !u8 { + if (options.positional.len == 0) { + try w.writeAll("Name the files to check. For example: zag secrets .env deploy.log\n"); + return 2; + } + + // A fresh salt each run. The fingerprints tell you which findings are the + // same credential *within this report*, and mean nothing outside it, which + // is what stops a printed report becoming a guessing oracle later. + const redactor = try zag.security.secrets.Redactor.init(io); + var total: usize = 0; + var unreadable: usize = 0; + + for (options.positional) |path| { + const contents = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 << 20)) catch { + try w.print("{s}: could not be read.\n", .{path}); + unreadable += 1; + continue; + }; + const findings = try zag.security.secrets.scan(arena, contents, .{}); + if (findings.len == 0) { + try w.print("{s}: nothing found.\n", .{path}); + continue; + } + try w.print("{s}: {d} to remove.\n", .{ path, findings.len }); + for (findings) |finding| { + const line = 1 + std.mem.count(u8, contents[0..finding.start], "\n"); + const print = redactor.fingerprint(contents[finding.start..finding.end]); + try w.print(" line {d}: {s} ({s}, {d} bytes) {s}\n", .{ + line, + finding.kind.text(), + finding.kind.certainty().text(), + finding.len(), + print, + }); + } + total += findings.len; + } + + if (total > 0) { + try w.writeAll( + \\ + \\Each of these would be replaced by a placeholder naming its kind, before + \\anything was written to the record. The value itself is not printed here + \\and is not written anywhere by this command. + \\ + ); + } + if (unreadable > 0) return 2; + return 0; +} + fn whyDidThatHappen( arena: std.mem.Allocator, io: std.Io, diff --git a/src/events/event.zig b/src/events/event.zig index e180f45..c5adcc1 100644 --- a/src/events/event.zig +++ b/src/events/event.zig @@ -72,6 +72,10 @@ pub const CommandSubmitted = struct { /// the boundary was inferred. The difference matters for every metric /// computed from blocks. boundaryFromShell: bool = false, + /// How many credentials were taken out of `commandText` before it was + /// written down. A key typed on a command line is the most common way one + /// reaches a log, and `export API_KEY=…` is in every shell history there is. + redactions: usize = 0, }; pub const OutputStream = enum { stdout, stderr, merged }; @@ -86,6 +90,13 @@ pub const ProcessOutput = struct { byteCount: usize, /// Sequence within the block, so out-of-order delivery can be repaired. chunkIndex: u32 = 0, + /// How many credentials were taken out of the stored bytes. + /// + /// The placeholders are visible in the content object, but the count + /// belongs here as well, because the event is what the hash chain covers + /// and the content object is not. A reader who has the log and not the + /// objects can still tell a clean block from a redacted one. + redactions: usize = 0, }; pub const CommandFinished = struct { diff --git a/src/events/format.zig b/src/events/format.zig index 29b5928..444eea9 100644 --- a/src/events/format.zig +++ b/src/events/format.zig @@ -53,19 +53,19 @@ pub const vectors = [_]Vector{ .{ .describes = "a session opening: optionals at null, defaults written out", .line = - \\{"id":"evt_01M16HNP3VTQEZCJATF659CD62","sequence":1,"at":"2026-08-29T10:40:00.123456789Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":null,"correlation":null,"payload":{"session_opened":{"session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","workingDirectory":"/repo","shell":"/bin/sh","columns":80,"rows":24,"repository":null,"branch":null,"worktree":null}},"contentHash":"b3:1d1cc14bf8dea5198705781508ecd52e541b556769df3a1b96a81ae874d6b069","chainHash":"b3:da9d3690614de05e0dca077479c2e608dfe26561a4c99d3c6061591fe1d6aadd"} + \\{"id":"evt_01M16HNP3VKF106VVZT32WZ3F0","sequence":1,"at":"2026-08-29T10:40:00.123456789Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":null,"correlation":null,"payload":{"session_opened":{"session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","workingDirectory":"/repo","shell":"/bin/sh","columns":80,"rows":24,"repository":null,"branch":null,"worktree":null}},"contentHash":"b3:904872c03590f30172a3e2bb583bfaee1c561c8f49a527fc97fe7666dea4ba8b","chainHash":"b3:862ed628dc1dda6a94ba8939bf10cf1e23add5e5e2e6bc06fcbd0da6a7ee25a5"} , }, .{ .describes = "a command submitted: a cause, a boolean, text with spaces", .line = - \\{"id":"evt_01M16HNPZ8KW76FPRZ4HN6TQ1D","sequence":2,"at":"2026-08-29T10:40:01.000000001Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":"evt_01M16HNP3VTQEZCJATF659CD62","correlation":null,"payload":{"command_submitted":{"block":"blk_01M16HNP02V3CWC3WY02PGW2MV","session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","commandText":"zig build test","workingDirectory":"/repo","boundaryFromShell":true}},"contentHash":"b3:81c85e9032caaf95ea00f3290c830bf429bde3b28152623f329644edb76668e8","chainHash":"b3:e3532fa8d6809f221f40a32142da732ef668e40ce8d08a015668d0b4133196a6"} + \\{"id":"evt_01M16HNPZ843TTMNTQFPHHKNJP","sequence":2,"at":"2026-08-29T10:40:01.000000001Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":"evt_01M16HNP3VTQEZCJATF659CD62","correlation":null,"payload":{"command_submitted":{"block":"blk_01M16HNP02V3CWC3WY02PGW2MV","session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","commandText":"zig build test","workingDirectory":"/repo","boundaryFromShell":true,"redactions":0}},"contentHash":"b3:9f1c26430aa5e559309071d6c8738cfc83d86d35f3ada4a29d031c333992f11c","chainHash":"b3:24aeafc1890992e48c5a7549582297ccc4531a0d3b3f526f71b58e3da560fb60"} , }, .{ .describes = "a command finishing: a correlation, a duration to the nanosecond, a non-zero status", .line = - \\{"id":"evt_01M16HNRXQV3CWC3WY02PGW2MV","sequence":3,"at":"2026-08-29T10:40:02.999999999Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":"evt_01M16HNPZ8KW76FPRZ4HN6TQ1D","correlation":"evt_01M16HNP3VTQEZCJATF659CD62","payload":{"command_finished":{"block":"blk_01M16HNP02V3CWC3WY02PGW2MV","session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","exitStatus":3,"duration":"PT1.005000123S","signal":null}},"contentHash":"b3:d7cc09ee8e0022a78d7181c973fdbf3f50927ae5743f3f33cec5023c8667ef2c","chainHash":"b3:e38a2588a5d5e2707399e71430e0a95f73ff88273f795ab4ad2f4a51e5fcb2cb"} + \\{"id":"evt_01M16HNRXQMHQHH1JB313JYH93","sequence":3,"at":"2026-08-29T10:40:02.999999999Z","actor":{"id":"act_01M16HNP00TQEZCJATF659CD62","kind":"person","label":"you"},"causedBy":"evt_01M16HNPZ8KW76FPRZ4HN6TQ1D","correlation":"evt_01M16HNP3VTQEZCJATF659CD62","payload":{"command_finished":{"block":"blk_01M16HNP02V3CWC3WY02PGW2MV","session":"ses_01M16HNP01KW76FPRZ4HN6TQ1D","exitStatus":3,"duration":"PT1.005000123S","signal":null}},"contentHash":"b3:8e541b7240e4852156dc296fe9d0e911646640dbe7ec0db04e83e10d99709624","chainHash":"b3:314011de9fd07798c77068a6e3422e3cdbf68eab6a4e280d9e2abfdbf45f5144"} , }, }; diff --git a/src/knowledge/vocabulary.zig b/src/knowledge/vocabulary.zig index a4a7efe..2c8bd1e 100644 --- a/src/knowledge/vocabulary.zig +++ b/src/knowledge/vocabulary.zig @@ -103,6 +103,21 @@ pub const entries = [_]C{ .relations = &.{.{ .kind = .associative, .target = "capability" }}, .top_concept = true, }, + .{ + .id = "redaction", + .preferred = "redaction", + .definition = "replacement of a credential in text by a placeholder that names its kind, done before the text becomes a record or leaves the machine", + .designations = &.{ + .{ .text = "masking", .status = .admitted }, + .{ .text = "scrubbing", .status = .deprecated, .note = "Says nothing about what replaced the value. Use redaction." }, + }, + .relations = &.{ + .{ .kind = .associative, .target = "workspace-event" }, + .{ .kind = .associative, .target = "policy" }, + }, + .examples = &.{"an API key a command printed, replaced in the stored output by a placeholder naming the provider"}, + .top_concept = true, + }, .{ .id = "authorization-decision", .preferred = "authorisation decision", diff --git a/src/root.zig b/src/root.zig index 7b0e27f..bb809bf 100644 --- a/src/root.zig +++ b/src/root.zig @@ -128,6 +128,10 @@ pub const ai = struct { }; }; +pub const security = struct { + pub const secrets = @import("security/secrets.zig"); +}; + pub const records = struct { pub const ledger = @import("records/ledger.zig"); }; diff --git a/src/security/secrets.zig b/src/security/secrets.zig new file mode 100644 index 0000000..f0db27b --- /dev/null +++ b/src/security/secrets.zig @@ -0,0 +1,1082 @@ +//! Finding credentials in text, before the text becomes a record. +//! +//! This program writes down what happened. That is the whole point of it, and +//! it is also the reason a credential that reaches the log is worse here than +//! it would be in a terminal that forgets. The log is append-only and +//! hash-chained: a secret written into it cannot be edited out without breaking +//! the chain that proves nothing was edited. There is no `git rm` for a record +//! whose value is that it cannot be rewritten. +//! +//! So credentials have to be caught on the way in. This module is the only +//! thing standing between what a command printed and what the workspace keeps. +//! +//! ## What it looks for, and in what order +//! +//! Three passes, deliberately different in kind, because the ways a secret +//! shows itself are different in kind: +//! +//! 1. **Delimited blocks.** A PEM private key announces itself with a line +//! that says so. There is no guessing involved and no threshold to tune — +//! it is found by its own header and redacted to its own footer. +//! 2. **Shapes with a published prefix.** `sk-ant-`, `ghp_`, `AKIA`, `xoxb-` +//! and the rest. Providers chose these prefixes precisely so that leaked +//! keys could be recognised, and a table of them is the highest-precision +//! detector available: a token with the prefix, the right length and the +//! right alphabet is a key, not a coincidence. +//! 3. **Anything assigned to something that calls itself a secret.** A value +//! after `api_key=`, `"password":`, `TOKEN: `. This is the pass that finds +//! the provider nobody has heard of, and the one that needs a threshold — +//! so it also demands the value carry enough entropy to be a key rather +//! than the word `changeme`. +//! +//! ## Why not entropy on its own +//! +//! Because a compiled program's output is full of high-entropy strings that are +//! not secrets: content hashes, git object names, base64 images, UUIDs, minified +//! source. A detector that redacts all of them makes the record useless, and a +//! record nobody reads protects nothing. Entropy is used here only as a *veto* +//! on the third pass — never on its own as a reason to redact. The first two +//! passes need no entropy test at all, because a `-----BEGIN OPENSSH PRIVATE +//! KEY-----` line is not a coincidence at any entropy. +//! +//! ## What a redaction leaves behind +//! +//! A placeholder that names the kind and carries four hex digits of a *salted* +//! fingerprint: `[redacted github-token 7f3a]`. The kind is there so a person +//! reading the record knows what was taken out. The fingerprint is there so +//! that one credential appearing in twenty places reads as one credential, and +//! two different credentials never read as one — which is the question an +//! incident actually turns on. +//! +//! The salt matters and is not decoration. An unsalted fingerprint of a short +//! or low-entropy secret is an oracle: anybody holding the record can hash +//! guesses until four hex digits match. The salt is drawn once per redactor +//! from the operating system and never written down, so the fingerprints are +//! comparable within one record and meaningless outside it. +//! +//! ## What this does not do +//! +//! It does not promise to find every secret. No detector does, and one that +//! claimed to would be the more dangerous thing to ship, because it would be +//! believed. It finds the shapes below and the assignments below. A credential +//! with no prefix, no delimiter and no name next to it — a bare password typed +//! into a prompt, a token split across two lines — passes through, and the +//! documentation says so rather than implying otherwise. + +const std = @import("std"); +const hashing = @import("../core/hash.zig"); +const scan_mod = @import("../core/scan.zig"); + +/// What was found. The name is what a person reads in the placeholder, so it +/// is written as words rather than as an internal identifier. +pub const Kind = enum { + private_key_block, + aws_access_key_id, + aws_secret_access_key, + github_token, + gitlab_token, + anthropic_api_key, + openai_api_key, + google_api_key, + slack_token, + stripe_key, + hugging_face_token, + npm_token, + json_web_token, + url_credentials, + named_secret, + + /// The words that go in a placeholder. + pub fn text(self: Kind) []const u8 { + return switch (self) { + .private_key_block => "private-key", + .aws_access_key_id => "aws-access-key-id", + .aws_secret_access_key => "aws-secret-access-key", + .github_token => "github-token", + .gitlab_token => "gitlab-token", + .anthropic_api_key => "anthropic-api-key", + .openai_api_key => "openai-api-key", + .google_api_key => "google-api-key", + .slack_token => "slack-token", + .stripe_key => "stripe-key", + .hugging_face_token => "hugging-face-token", + .npm_token => "npm-token", + .json_web_token => "json-web-token", + .url_credentials => "url-credentials", + .named_secret => "secret", + }; + } + + /// Which pass found it. Reported so that a person reviewing a redaction can + /// tell a certainty from a judgement. + pub fn certainty(self: Kind) Certainty { + return switch (self) { + .named_secret => .inferred, + else => .recognised, + }; + } +}; + +/// How the finding was reached. +pub const Certainty = enum { + /// Matched a delimiter or a published prefix. No threshold was involved. + recognised, + /// Matched a name that says "secret" and carried enough entropy to be one. + /// A person may disagree with this one; the other kind they may not. + inferred, + + pub fn text(self: Certainty) []const u8 { + return switch (self) { + .recognised => "recognised", + .inferred => "inferred", + }; + } +}; + +/// One credential, located in the text it was found in. +/// +/// `start` and `end` bound the secret itself, not the name in front of it: the +/// point is to remove the value and keep the sentence around it readable, so +/// that `AWS_SECRET_ACCESS_KEY=[redacted ...]` still tells a reader which +/// variable was set. +pub const Finding = struct { + kind: Kind, + start: usize, + end: usize, + + pub fn len(self: Finding) usize { + return self.end - self.start; + } +}; + +/// A published credential shape. +/// +/// Providers publish these prefixes so that leaked keys can be recognised by +/// scanners exactly like this one. Matching one is not a heuristic. +const Shape = struct { + kind: Kind, + prefix: []const u8, + /// Total length including the prefix. A range because several providers + /// have issued more than one length under one prefix. + min_len: usize, + max_len: usize, + /// Whether the body may contain `-` and `_` as well as letters and digits. + /// AWS keys may not; GitHub and Slack tokens may. + dashes: bool = true, +}; + +/// The table. Ordered longest prefix first so that a more specific shape wins: +/// `github_pat_` must be tried before `ghp_` would be, and `sk-ant-api03-` +/// before `sk-`. +const shapes = [_]Shape{ + .{ .kind = .anthropic_api_key, .prefix = "sk-ant-api", .min_len = 40, .max_len = 200 }, + .{ .kind = .anthropic_api_key, .prefix = "sk-ant-", .min_len = 30, .max_len = 200 }, + .{ .kind = .github_token, .prefix = "github_pat_", .min_len = 40, .max_len = 200 }, + .{ .kind = .openai_api_key, .prefix = "sk-proj-", .min_len = 40, .max_len = 200 }, + .{ .kind = .hugging_face_token, .prefix = "hf_", .min_len = 30, .max_len = 100 }, + .{ .kind = .github_token, .prefix = "ghp_", .min_len = 36, .max_len = 100 }, + .{ .kind = .github_token, .prefix = "gho_", .min_len = 36, .max_len = 100 }, + .{ .kind = .github_token, .prefix = "ghu_", .min_len = 36, .max_len = 100 }, + .{ .kind = .github_token, .prefix = "ghs_", .min_len = 36, .max_len = 100 }, + .{ .kind = .github_token, .prefix = "ghr_", .min_len = 36, .max_len = 100 }, + .{ .kind = .gitlab_token, .prefix = "glpat-", .min_len = 26, .max_len = 100 }, + .{ .kind = .slack_token, .prefix = "xoxb-", .min_len = 30, .max_len = 100 }, + .{ .kind = .slack_token, .prefix = "xoxp-", .min_len = 30, .max_len = 100 }, + .{ .kind = .slack_token, .prefix = "xoxa-", .min_len = 30, .max_len = 100 }, + .{ .kind = .slack_token, .prefix = "xapp-", .min_len = 30, .max_len = 100 }, + .{ .kind = .stripe_key, .prefix = "sk_live_", .min_len = 30, .max_len = 100 }, + .{ .kind = .stripe_key, .prefix = "rk_live_", .min_len = 30, .max_len = 100 }, + .{ .kind = .npm_token, .prefix = "npm_", .min_len = 30, .max_len = 100 }, + .{ .kind = .google_api_key, .prefix = "AIza", .min_len = 39, .max_len = 39 }, + .{ .kind = .aws_access_key_id, .prefix = "AKIA", .min_len = 20, .max_len = 20, .dashes = false }, + .{ .kind = .aws_access_key_id, .prefix = "ASIA", .min_len = 20, .max_len = 20, .dashes = false }, + .{ .kind = .openai_api_key, .prefix = "sk-", .min_len = 48, .max_len = 200 }, +}; + +comptime { + // The table is searched in order and the first match wins, so a shape whose + // prefix is a prefix of an earlier one is unreachable. Catching that here + // means a new entry added in the wrong place fails the build rather than + // silently never matching. + for (shapes, 0..) |shape, i| { + for (shapes[0..i]) |earlier| { + if (std.mem.startsWith(u8, shape.prefix, earlier.prefix)) { + @compileError("credential shape '" ++ shape.prefix ++ + "' is unreachable: '" ++ earlier.prefix ++ "' is listed before it and matches first"); + } + } + } +} + +/// Variable names that say the value beside them is a credential. +/// +/// Matched case-insensitively against the tail of the name, so `MY_APP_TOKEN` +/// and `token` both hit. `_id` and `_name` are excluded below, because +/// `client_id` and `key_name` are not secrets and redacting them loses a fact a +/// reader needs. +const secret_names = [_][]const u8{ + "api_key", "apikey", "api-key", "secret", + "password", "passwd", "token", "auth", + "authorization", "credential", "private_key", "access_key", + "session_key", "client_secret", "bearer", "passphrase", + // With the separator attached, so that `SSH_KEY` and `SOME_OTHER_KEY` + // match while `monkey` and a bare `key=` in a printed map do not. + "_key", "-key", +}; + +/// Names that end in one of the above but are not credentials. +/// +/// Checked first and always winning. `client_id` and `client_secret` appear +/// together in every OAuth configuration there is, and a detector that cannot +/// tell them apart redacts the half a reader needs. +const not_secret_names = [_][]const u8{ + "client_id", "key_id", "token_type", "auth_type", + "token_count", "author", "authors", "secret_name", + "key_name", "auth_url", "token_uri", "password_hash", +}; + +/// How much a value may look like a word before the third pass lets it through. +/// +/// Shannon entropy in bits per character. English prose sits near 3.0 and +/// `changeme` near 2.75; a random 32-character key sits above 4.5. The +/// threshold is set at 3.0 so that a real but short key still passes and a +/// placeholder like `your-key-here` does not. +pub const default_entropy_floor: f64 = 3.0; + +/// How many of {lowercase, uppercase, digit} a value uses. +/// +/// Entropy alone lets `your-api-key-here` through: seventeen characters of +/// English with three dashes carries about 3.3 bits, which is above any floor +/// low enough to still catch a real sixteen-character key. What separates them +/// is not how surprising the characters are but which *kinds* they are — a +/// generated credential mixes cases or includes digits, and a phrase does not. +fn characterClasses(text: []const u8) u8 { + var lower = false; + var upper = false; + var digit = false; + for (text) |byte| { + if (std.ascii.isLower(byte)) lower = true; + if (std.ascii.isUpper(byte)) upper = true; + if (std.ascii.isDigit(byte)) digit = true; + } + return @as(u8, @intFromBool(lower)) + @intFromBool(upper) + @intFromBool(digit); +} + +/// Settings for a scan. Named rather than positional because two of the three +/// are thresholds, and a threshold passed positionally is a bug waiting. +pub const Policy = struct { + /// Bits per character a named value must carry to be treated as a secret. + entropy_floor: f64 = default_entropy_floor, + /// Shortest value the third pass will look at. Below this, entropy is not + /// measurable in any meaningful way and the false-positive rate climbs. + min_named_len: usize = 8, + /// How many of {lowercase, uppercase, digit} a named value must use. Two + /// is what separates a generated credential from a phrase somebody wrote. + min_character_classes: u8 = 2, + /// Whether to run the third pass at all. Off gives a detector that only + /// ever reports things it recognised, which is the right setting when a + /// false positive would be worse than a miss. + infer_from_names: bool = true, +}; + +/// Shannon entropy of `text`, in bits per character. +/// +/// Zero for an empty or single-repeated string, and log2(n) for a string of n +/// equally frequent distinct bytes — so 6 for well-spread base64 and about 4 +/// for hex. +pub fn entropy(text: []const u8) f64 { + if (text.len == 0) return 0; + var counts = [_]u32{0} ** 256; + for (text) |byte| counts[byte] += 1; + + const total: f64 = @floatFromInt(text.len); + var bits: f64 = 0; + for (counts) |count| { + if (count == 0) continue; + const p = @as(f64, @floatFromInt(count)) / total; + bits -= p * @log2(p); + } + return bits; +} + +/// Whether a byte can appear in the body of a credential token. +/// +/// `=` is deliberately absent. It is base64 padding, so it belongs to a token — +/// but only at the end, and treating it as an interior byte makes `API_KEY=sk-…` +/// scan as a single token whose name and value cannot be told apart. Trailing +/// padding is picked up by `absorbPadding` instead, which can tell the two +/// cases apart because padding is never followed by more token bytes. +fn isTokenByte(byte: u8) bool { + return switch (byte) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '_', '.', '~', '+', '/' => true, + else => false, + }; +} + +/// Extend a token over trailing base64 padding, if that is what it is. +/// +/// `Zm9v==` at the end of a line is one token with padding. The `=` in +/// `API_KEY=sk-ant-…` is not, and the difference is exactly whether more token +/// bytes follow: padding never has anything after it. +fn absorbPadding(text: []const u8, end: usize) usize { + var index = end; + while (index < text.len and text[index] == '=') index += 1; + if (index == end) return end; + if (index < text.len and isTokenByte(text[index])) return end; + return index; +} + +/// The end of the token starting at `from`, scanned a vector at a time. +/// +/// Output is mostly not credentials, so the cost of this module is the cost of +/// walking past everything that is not one. Finding the end of a run of token +/// bytes is the inner loop of that walk. +fn tokenEnd(text: []const u8, from: usize) usize { + var index = from; + if (scan_mod.width) |lanes| { + const Chunk = @Vector(lanes, u8); + while (index + lanes <= text.len) { + const chunk: Chunk = text[index..][0..lanes].*; + // Token bytes are a union of ranges, so the test is a handful of + // comparisons per vector rather than a table lookup, which has no + // portable vector form. + const upper = (chunk >= @as(Chunk, @splat('A'))) & (chunk <= @as(Chunk, @splat('Z'))); + const lower = (chunk >= @as(Chunk, @splat('a'))) & (chunk <= @as(Chunk, @splat('z'))); + const digit = (chunk >= @as(Chunk, @splat('0'))) & (chunk <= @as(Chunk, @splat('9'))); + var ok = upper | lower | digit; + inline for ([_]u8{ '-', '_', '.', '~', '+', '/' }) |extra| { + ok = ok | (chunk == @as(Chunk, @splat(extra))); + } + if (!@reduce(.And, ok)) break; + index += lanes; + } + } + while (index < text.len and isTokenByte(text[index])) index += 1; + return index; +} + +/// The plain version of `tokenEnd`, kept beside it and checked against it. +fn tokenEndSlow(text: []const u8, from: usize) usize { + var index = from; + while (index < text.len and isTokenByte(text[index])) index += 1; + return index; +} + +/// Whether the token at `[start, end)` is bounded by non-token bytes. +/// +/// Without this a scan would match `sk-ant-` in the middle of a longer run and +/// report a fragment, and a redaction of a fragment leaves the rest of the key +/// in the record — worse than not redacting at all, because it looks handled. +fn isWholeToken(text: []const u8, start: usize, end: usize) bool { + if (start > 0 and isTokenByte(text[start - 1])) return false; + if (end < text.len and isTokenByte(text[end])) return false; + return true; +} + +/// Whether `name` is one of the names that mean "credential". +/// +/// The tail is compared, so a prefix a project invents (`ZAG_`, `MY_APP_`) does +/// not have to be listed. The exclusions are checked first and win, because +/// `client_id` ends in nothing secret but `client_secret` does, and the pair +/// appear together in every OAuth configuration on earth. +pub fn nameSaysSecret(name: []const u8) bool { + if (name.len == 0 or name.len > 64) return false; + for (not_secret_names) |exclusion| { + if (endsWithFold(name, exclusion)) return false; + } + for (secret_names) |marker| { + // Either end. A project's own prefix goes on the front + // (`ZAG_API_KEY`), and a header's qualifier goes on the back + // (`Authorization`), and both name a credential. + if (endsWithFold(name, marker) or startsWithFold(name, marker)) return true; + } + return false; +} + +fn startsWithFold(haystack: []const u8, needle: []const u8) bool { + if (needle.len > haystack.len) return false; + for (haystack[0..needle.len], needle) |a, b| { + if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false; + } + return true; +} + +fn endsWithFold(haystack: []const u8, needle: []const u8) bool { + if (needle.len > haystack.len) return false; + const tail = haystack[haystack.len - needle.len ..]; + for (tail, needle) |a, b| { + if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false; + } + return true; +} + +/// The name immediately before `at`, if the text there reads like an +/// assignment. +/// +/// Handles the three shapes that carry credentials in practice: a shell or +/// dotenv assignment (`API_KEY=value`), a JSON member (`"api_key": "value"`), +/// and a header or YAML mapping (`Authorization: value`). Whitespace and one +/// optional quote are stepped over on each side. +fn nameBefore(text: []const u8, at: usize) ?[]const u8 { + var index = at; + // Step back over an opening quote around the value. + if (index > 0 and (text[index - 1] == '"' or text[index - 1] == '\'')) index -= 1; + while (index > 0 and (text[index - 1] == ' ' or text[index - 1] == '\t')) index -= 1; + if (index == 0) return null; + const separator = text[index - 1]; + if (separator != '=' and separator != ':') return null; + index -= 1; + while (index > 0 and (text[index - 1] == ' ' or text[index - 1] == '\t')) index -= 1; + // Step back over a closing quote around the name. + if (index > 0 and (text[index - 1] == '"' or text[index - 1] == '\'')) index -= 1; + const name_end = index; + while (index > 0 and (std.ascii.isAlphanumeric(text[index - 1]) or + text[index - 1] == '_' or text[index - 1] == '-')) index -= 1; + if (index == name_end) return null; + return text[index..name_end]; +} + +const pem_header = "-----BEGIN "; +const pem_footer = "-----END "; + +/// Find every credential in `text`. +/// +/// Findings come back in the order they appear and never overlap: an earlier +/// finding always ends before a later one starts, which is what lets the +/// redactor rewrite the text in one pass. +pub fn scan(arena: std.mem.Allocator, text: []const u8, policy: Policy) ![]const Finding { + var found: std.ArrayList(Finding) = .empty; + + var index: usize = 0; + while (index < text.len) { + // Pass one: a delimited block announces itself, and swallows whatever + // the other passes would have found inside it. + if (text.len - index >= pem_header.len and + std.mem.startsWith(u8, text[index..], pem_header) and + lineContains(text, index, "PRIVATE KEY")) + { + const end = pemBlockEnd(text, index); + try found.append(arena, .{ .kind = .private_key_block, .start = index, .end = end }); + index = end; + continue; + } + + // Pass one, second shape: credentials inside a URL. Found by the `://` + // rather than by the scheme, so it catches every protocol at once. + if (std.mem.startsWith(u8, text[index..], "://")) { + if (urlCredentials(text, index)) |span| { + try found.append(arena, .{ .kind = .url_credentials, .start = span.start, .end = span.end }); + index = span.end; + continue; + } + } + + if (!isTokenByte(text[index])) { + index += 1; + continue; + } + + // The whole token, once. Every remaining test is against this slice, so + // the scan touches each byte a constant number of times. + const end = absorbPadding(text, tokenEnd(text, index)); + const token = text[index..end]; + if (!isWholeToken(text, index, end)) { + index = end; + continue; + } + + if (classify(token)) |kind| { + try found.append(arena, .{ .kind = kind, .start = index, .end = end }); + index = end; + continue; + } + + // Pass three: the value is not a shape anybody publishes, but the thing + // it was assigned to calls itself a secret, and it carries the entropy + // of a key rather than of a word. + if (policy.infer_from_names and token.len >= policy.min_named_len) { + if (nameBefore(text, index)) |name| { + if (nameSaysSecret(name) and + characterClasses(token) >= policy.min_character_classes and + entropy(token) >= policy.entropy_floor) + { + try found.append(arena, .{ .kind = .named_secret, .start = index, .end = end }); + index = end; + continue; + } + } + } + + index = end; + } + + return found.items; +} + +/// Which published shape a token matches, if any. +pub fn classify(token: []const u8) ?Kind { + for (shapes) |shape| { + if (!std.mem.startsWith(u8, token, shape.prefix)) continue; + if (token.len < shape.min_len or token.len > shape.max_len) continue; + const body = token[shape.prefix.len..]; + if (!bodyMatches(body, shape.dashes)) continue; + return shape.kind; + } + // A JSON Web Token is three base64url segments separated by dots, and the + // first one always decodes to a JSON object beginning `{"`, which is why + // every JWT on earth starts `eyJ`. That is a shape, not a guess. + if (std.mem.startsWith(u8, token, "eyJ") and std.mem.count(u8, token, ".") == 2 and token.len >= 40) { + return .json_web_token; + } + // An AWS secret access key has no prefix — it is 40 characters of base64 + // and nothing else. On its own that is far too weak to act on, so it is + // only reported when the name beside it says what it is, which `scan` + // handles through the named pass. Left here as the reason there is no + // prefix entry for it in the table. + return null; +} + +fn bodyMatches(body: []const u8, dashes: bool) bool { + for (body) |byte| { + const ok = switch (byte) { + 'A'...'Z', 'a'...'z', '0'...'9' => true, + '-', '_' => dashes, + else => false, + }; + if (!ok) return false; + } + return true; +} + +fn lineContains(text: []const u8, from: usize, needle: []const u8) bool { + const line_end = std.mem.indexOfScalarPos(u8, text, from, '\n') orelse text.len; + return std.mem.indexOf(u8, text[from..line_end], needle) != null; +} + +/// The end of a PEM block, or the end of the text when the footer is missing. +/// +/// A truncated key is still a key, and output that was cut off mid-block is +/// exactly the case where a naive scanner gives up and writes the body down. +fn pemBlockEnd(text: []const u8, start: usize) usize { + const footer_at = std.mem.indexOfPos(u8, text, start, pem_footer) orelse return text.len; + const line_end = std.mem.indexOfScalarPos(u8, text, footer_at, '\n') orelse text.len; + return line_end; +} + +const Span = struct { start: usize, end: usize }; + +/// The `user:password` in `scheme://user:password@host`, if there is one. +/// +/// Only the credentials are returned, so the host survives the redaction and a +/// reader can still see where a request went — which is usually the fact they +/// need and never the secret. +fn urlCredentials(text: []const u8, at_separator: usize) ?Span { + const start = at_separator + 3; + if (start >= text.len) return null; + var index = start; + var colon: ?usize = null; + while (index < text.len) : (index += 1) { + switch (text[index]) { + '@' => break, + ':' => if (colon == null) { + colon = index; + }, + '/', '?', '#', ' ', '\t', '\r', '\n', '"', '\'' => return null, + else => {}, + } + } else return null; + // `host:port` with no `@` is not credentials, and `@` with no `:` is a + // username with no password, which is not a secret either. + if (colon == null) return null; + if (index == start) return null; + return .{ .start = start, .end = index }; +} + +/// Rewrites text with every credential replaced by a placeholder. +/// +/// Holds a salt for the lifetime of the redactor, so fingerprints are +/// comparable across everything one redactor rewrites and meaningless anywhere +/// else. Make one per session and keep it. +pub const Redactor = struct { + policy: Policy = .{}, + salt: [16]u8, + + /// A redactor whose fingerprints nobody outside this process can reproduce. + pub fn init(io: std.Io) !Redactor { + var salt: [16]u8 = undefined; + try io.randomSecure(&salt); + return .{ .salt = salt }; + } + + /// A redactor with a stated salt, for tests and for the rare case where two + /// processes must agree on fingerprints. Anything using this is choosing to + /// give up the property that the fingerprints resist a guessing attack. + pub fn withSalt(salt: [16]u8) Redactor { + return .{ .salt = salt }; + } + + /// Four hex digits identifying a secret without revealing it. + pub fn fingerprint(self: Redactor, secret: []const u8) [4]u8 { + const digest = hashing.Hash.ofParts(&.{ &self.salt, secret }); + var out: [4]u8 = undefined; + _ = std.fmt.bufPrint(&out, "{x:0>2}{x:0>2}", .{ digest.bytes[0], digest.bytes[1] }) catch unreachable; + return out; + } + + /// Rewrite `text`, returning the redacted copy and what was taken out. + /// + /// The original is never written anywhere by this function. A caller that + /// keeps it is choosing to, and the one caller that does — the terminal, so + /// that the person watching sees their own screen unchanged — keeps it in + /// memory and never hands it to the log. + pub fn rewrite(self: Redactor, arena: std.mem.Allocator, text: []const u8) !Result { + const findings = try scan(arena, text, self.policy); + if (findings.len == 0) return .{ .text = text, .findings = findings }; + + var out: std.ArrayList(u8) = .empty; + var cursor: usize = 0; + for (findings) |finding| { + try out.appendSlice(arena, text[cursor..finding.start]); + try out.appendSlice(arena, "[redacted "); + try out.appendSlice(arena, finding.kind.text()); + try out.append(arena, ' '); + const print = self.fingerprint(text[finding.start..finding.end]); + try out.appendSlice(arena, &print); + try out.append(arena, ']'); + cursor = finding.end; + } + try out.appendSlice(arena, text[cursor..]); + return .{ .text = out.items, .findings = findings }; + } + + /// Whether `text` contains anything this redactor would remove. + /// + /// Cheaper than rewriting when the answer is only needed as a yes or no — + /// refusing to send a prompt, for instance. + pub fn detects(self: Redactor, arena: std.mem.Allocator, text: []const u8) !bool { + const findings = try scan(arena, text, self.policy); + return findings.len > 0; + } +}; + +/// What a rewrite produced. +pub const Result = struct { + /// The redacted text. Identical to the input, and the same slice, when + /// nothing was found — so the common case allocates nothing. + text: []const u8, + findings: []const Finding, + + pub fn changed(self: Result) bool { + return self.findings.len > 0; + } + + /// How many of each kind, for a report that says what was removed without + /// listing where. + pub fn countOf(self: Result, kind: Kind) usize { + var total: usize = 0; + for (self.findings) |finding| { + if (finding.kind == kind) total += 1; + } + return total; + } +}; + +const testing = std.testing; + +/// A fixed salt, so that the expected placeholders in these tests are stable. +/// Real use draws one from the operating system. +const test_salt = [_]u8{0xA5} ** 16; + +test "a published prefix is recognised without any threshold" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const cases = [_]struct { text: []const u8, kind: Kind }{ + .{ .text = "sk-ant-api03-" ++ "a" ** 40, .kind = .anthropic_api_key }, + .{ .text = "ghp_" ++ "b" ** 36, .kind = .github_token }, + .{ .text = "github_pat_" ++ "c" ** 40, .kind = .github_token }, + .{ .text = "glpat-" ++ "d" ** 20, .kind = .gitlab_token }, + .{ .text = "xoxb-" ++ "1234567890-1234567890-abcdefghijklmno", .kind = .slack_token }, + .{ .text = "AKIAIOSFODNN7EXAMPLE", .kind = .aws_access_key_id }, + .{ .text = "AIza" ++ "e" ** 35, .kind = .google_api_key }, + .{ .text = "hf_" ++ "f" ** 34, .kind = .hugging_face_token }, + .{ .text = "npm_" ++ "g" ** 36, .kind = .npm_token }, + .{ .text = "sk_live_" ++ "h" ** 24, .kind = .stripe_key }, + .{ .text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk", .kind = .json_web_token }, + }; + + for (cases) |case| { + // The lowest possible entropy body: one repeated character. A detector + // that leaned on entropy would miss every one of these, which is the + // reason the recognised pass has no entropy test in it. + const findings = try scan(arena, case.text, .{}); + try testing.expectEqual(@as(usize, 1), findings.len); + try testing.expectEqual(case.kind, findings[0].kind); + try testing.expectEqual(@as(usize, 0), findings[0].start); + try testing.expectEqual(case.text.len, findings[0].end); + try testing.expectEqual(Certainty.recognised, findings[0].kind.certainty()); + } +} + +test "a key is found in the middle of a line and the line survives" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const line = "export ANTHROPIC_API_KEY=sk-ant-api03-" ++ "z" ** 40 ++ " # for the build"; + const redactor = Redactor.withSalt(test_salt); + const result = try redactor.rewrite(arena, line); + + try testing.expect(result.changed()); + // The variable name and the comment are still there. The value is not. + try testing.expect(std.mem.startsWith(u8, result.text, "export ANTHROPIC_API_KEY=")); + try testing.expect(std.mem.endsWith(u8, result.text, " # for the build")); + try testing.expect(std.mem.indexOf(u8, result.text, "sk-ant") == null); + try testing.expect(std.mem.indexOf(u8, result.text, "[redacted anthropic-api-key ") != null); +} + +test "the same secret twice reads as one secret, and two secrets never merge" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const redactor = Redactor.withSalt(test_salt); + const one = "ghp_" ++ "1" ** 36; + const other = "ghp_" ++ "2" ** 36; + + const same = redactor.fingerprint(one); + try testing.expectEqualSlices(u8, &same, &redactor.fingerprint(one)); + try testing.expect(!std.mem.eql(u8, &same, &redactor.fingerprint(other))); + + // And in a rewrite: the reader can see the deploy key and the build key are + // different keys without either of them being in the record. + const text = "deploy=" ++ one ++ "\nbuild=" ++ one ++ "\nother=" ++ other ++ "\n"; + const result = try redactor.rewrite(arena, text); + try testing.expectEqual(@as(usize, 3), result.findings.len); + + var lines = std.mem.splitScalar(u8, result.text, '\n'); + const deploy = lines.next().?; + const build = lines.next().?; + const third = lines.next().?; + try testing.expectEqualStrings(deploy["deploy=".len..], build["build=".len..]); + try testing.expect(!std.mem.eql(u8, deploy["deploy=".len..], third["other=".len..])); +} + +test "a different salt gives different fingerprints for the same secret" { + // This is the property that stops a fingerprint being a guessing oracle. If + // it ever fails, the salt has stopped being mixed in and anybody holding a + // record can brute-force short secrets back out of it. + const secret = "ghp_" ++ "9" ** 36; + const here = Redactor.withSalt(test_salt); + const elsewhere = Redactor.withSalt([_]u8{0x11} ** 16); + try testing.expect(!std.mem.eql(u8, &here.fingerprint(secret), &elsewhere.fingerprint(secret))); +} + +test "a private key block goes entirely, footer included" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const text = + \\Loading identity. + \\-----BEGIN OPENSSH PRIVATE KEY----- + \\b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gt + \\cnNhAAAAAwEAAQAAAYEAvbGkfyGhLNQxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + \\-----END OPENSSH PRIVATE KEY----- + \\Done. + ; + const redactor = Redactor.withSalt(test_salt); + const result = try redactor.rewrite(arena, text); + + try testing.expectEqual(@as(usize, 1), result.findings.len); + try testing.expectEqual(Kind.private_key_block, result.findings[0].kind); + try testing.expect(std.mem.indexOf(u8, result.text, "BEGIN") == null); + try testing.expect(std.mem.indexOf(u8, result.text, "END") == null); + try testing.expect(std.mem.indexOf(u8, result.text, "b3BlbnNz") == null); + // The text around it is untouched, so the record still says what was + // happening when the key appeared. + try testing.expect(std.mem.startsWith(u8, result.text, "Loading identity.\n")); + try testing.expect(std.mem.endsWith(u8, result.text, "\nDone.")); +} + +test "a private key cut off mid-block is still removed" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Output truncated by a buffer limit is exactly where a scanner that + // insists on finding the footer writes the body into the record. + const text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAxLotOfB7Zk\n"; + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expectEqual(@as(usize, 1), result.findings.len); + try testing.expect(std.mem.indexOf(u8, result.text, "MIIEow") == null); +} + +test "a public key block is left alone" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Publishing a public key is the entire point of a public key. Redacting it + // would break the one workflow it exists for. + const text = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj\n-----END PUBLIC KEY-----\n"; + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expectEqual(@as(usize, 0), result.findings.len); + try testing.expectEqualStrings(text, result.text); +} + +test "credentials come out of a URL and the host stays in" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const text = "fatal: could not read from https://deploy:hunter2@git.example.com/repo.git"; + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + + try testing.expectEqual(@as(usize, 1), result.findings.len); + try testing.expectEqual(Kind.url_credentials, result.findings[0].kind); + try testing.expect(std.mem.indexOf(u8, result.text, "hunter2") == null); + // The host is the fact somebody debugging this needs, and it is not secret. + try testing.expect(std.mem.indexOf(u8, result.text, "git.example.com/repo.git") != null); + try testing.expect(std.mem.indexOf(u8, result.text, "https://") != null); +} + +test "a host and port is not a credential" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + for ([_][]const u8{ + "connecting to http://localhost:8080/health", + "see https://example.com:443/", + "redis://cache:6379", + }) |text| { + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expectEqualStrings(text, result.text); + } +} + +test "an unknown provider's key is caught by the name beside it" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // None of these prefixes are in the table, and they never will be. The + // third pass is the only reason they are found. + const cases = [_][]const u8{ + "ACME_API_KEY=Xq7pLm2vNc9wTr4yUi8oPa3sDf6gHj1k", + "{\"client_secret\": \"Zb5nQw8xEr2tYu4iOp6aSd9fGh3jKl7m\"}", + "Authorization: Vc3xZm9pQr5tYw8nBd2fGk6hJl4sAe7u", + "db_password: Rt6yUj3mNb8vCx5zAq9wEr2tYu4iOp7a", + }; + + for (cases) |text| { + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expectEqual(@as(usize, 1), result.findings.len); + try testing.expectEqual(Kind.named_secret, result.findings[0].kind); + try testing.expectEqual(Certainty.inferred, result.findings[0].kind.certainty()); + } +} + +test "the things a build prints every day are not redacted" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // This is the test that decides whether the feature is usable. A detector + // that fires on any of these makes every record unreadable, and a record + // nobody reads protects nobody. Each line here is high-entropy text that a + // person needs to be able to see. + const ordinary = [_][]const u8{ + "commit 4f9d3c2b1a8e7f6d5c4b3a29180706f5e4d3c2b1", + "b3:61fb3f606aa922085ed5fa6f7346c2179842222a31e2bd2f2461902f5c98a290", + "evt_01M16HNP3VTQEZCJATF659CD62", + "sha256-Xq7pLm2vNc9wTr4yUi8oPa3sDf6gHj1kZb5nQw8xEr2=", + "id: 550e8400-e29b-41d4-a716-446655440000", + " at /home/user/zag/src/ai/transport.zig:412:19", + "docker.io/library/alpine@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4a27504f8ab1abd", + "client_id=1084729384756-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "token_count: 918273645", + "password_hash=$2b$12$KIXQJ8bV3sRz1lYqQoT8ue", + "Compiling regex v1.10.2 (a3f8e91c4d7b2065)", + }; + + for (ordinary) |text| { + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + if (result.changed()) { + std.debug.print("false positive on: {s}\n -> {s}\n", .{ text, result.text }); + } + try testing.expectEqual(@as(usize, 0), result.findings.len); + try testing.expectEqualStrings(text, result.text); + } +} + +test "a placeholder is not mistaken for a key" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Documentation and templates are full of these. Redacting them turns a + // README into noise and teaches people to ignore the redaction. + for ([_][]const u8{ + "API_KEY=your-api-key-here", + "password: changeme", + "TOKEN=xxxxxxxxxxxxxxxxxxxx", + "secret: ", + "api_key = REPLACE_ME", + }) |text| { + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expectEqualStrings(text, result.text); + } +} + +test "entropy separates a key from a word" { + // The numbers the third pass is tuned against, pinned so that a change to + // the threshold has to argue with them. + try testing.expect(entropy("changeme") < 3.0); + try testing.expect(entropy("your-api-key-here") < 3.5); + try testing.expect(entropy("xxxxxxxxxxxxxxxxxxxx") < 1.0); + try testing.expect(entropy("Xq7pLm2vNc9wTr4yUi8oPa3sDf6gHj1k") > 4.5); + try testing.expectEqual(@as(f64, 0), entropy("")); + try testing.expectEqual(@as(f64, 0), entropy("aaaa")); + // Two equally frequent bytes is exactly one bit. + try testing.expectApproxEqAbs(@as(f64, 1), entropy("abab"), 1e-12); +} + +test "a fragment of a key is never reported, because half a redaction is worse than none" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // If the scanner matched a prefix anywhere rather than at a token boundary, + // it would report the tail of this and leave the head in the record. + const text = "notasecretghp_" ++ "k" ** 36; + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expectEqual(@as(usize, 0), result.findings.len); +} + +test "several secrets in one document all go, in order" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const text = + "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n" ++ + "ANTHROPIC_API_KEY=sk-ant-api03-" ++ "q" ** 40 ++ "\n" ++ + "git remote add origin https://ci:sV8xQm2pLr6tYw9n@git.example.com/x.git\n" ++ + "SOME_OTHER_KEY=Kp4mZx8qWe2rTy6uIo9aSd3fGh7jLn5b\n"; + + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expectEqual(@as(usize, 4), result.findings.len); + try testing.expectEqual(Kind.aws_access_key_id, result.findings[0].kind); + try testing.expectEqual(Kind.anthropic_api_key, result.findings[1].kind); + try testing.expectEqual(Kind.url_credentials, result.findings[2].kind); + try testing.expectEqual(Kind.named_secret, result.findings[3].kind); + + // Findings never overlap and are in order, which is what lets the rewrite + // be one pass. + var previous: usize = 0; + for (result.findings) |finding| { + try testing.expect(finding.start >= previous); + try testing.expect(finding.end > finding.start); + previous = finding.end; + } + + try testing.expectEqual(@as(usize, 1), result.countOf(.url_credentials)); + try testing.expect(std.mem.indexOf(u8, result.text, "AKIA") == null); + try testing.expect(std.mem.indexOf(u8, result.text, "sk-ant") == null); + try testing.expect(std.mem.indexOf(u8, result.text, "sV8xQm2pLr6tYw9n") == null); + try testing.expect(std.mem.indexOf(u8, result.text, "Kp4mZx8qWe2rTy6uIo9aSd3fGh7jLn5b") == null); +} + +test "clean text is returned as the same slice, so the common case allocates nothing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const text = "All 513 tests passed.\n"; + const result = try Redactor.withSalt(test_salt).rewrite(arena, text); + try testing.expect(result.text.ptr == text.ptr); +} + +test "the vectorised token scan agrees with the plain one at every length and offset" { + // The tail of a vectorised scan is where this kind of code is wrong, and it + // is wrong at exactly one length in a way no hand-written example finds. + var prng = std.Random.DefaultPrng.init(0x5EC2E7); + const random = prng.random(); + + var buffer: [512]u8 = undefined; + const alphabet = "abcXYZ019-_./+= \t\n!@#$%^&*(){}[]|;'\",<>?:"; + for (0..400) |_| { + for (&buffer) |*byte| byte.* = alphabet[random.intRangeLessThan(usize, 0, alphabet.len)]; + var length: usize = 0; + while (length <= buffer.len) : (length += 1) { + const text = buffer[0..length]; + for (0..length) |from| { + try testing.expectEqual(tokenEndSlow(text, from), tokenEnd(text, from)); + } + // Lengths near a vector boundary are where it breaks; the rest is + // covered by sampling so the test stays fast. + if (length > 80) length += 37; + } + } +} + +test "a name that says secret is told from one that only sounds like it" { + try testing.expect(nameSaysSecret("api_key")); + try testing.expect(nameSaysSecret("API_KEY")); + try testing.expect(nameSaysSecret("MY_APP_TOKEN")); + try testing.expect(nameSaysSecret("client_secret")); + try testing.expect(nameSaysSecret("db-password")); + + // The pair that appears together in every OAuth configuration on earth. One + // is a secret and one is published, and a detector that cannot tell them + // apart redacts the half people need. + try testing.expect(!nameSaysSecret("client_id")); + try testing.expect(!nameSaysSecret("key_id")); + try testing.expect(!nameSaysSecret("token_type")); + try testing.expect(!nameSaysSecret("password_hash")); + try testing.expect(!nameSaysSecret("username")); + try testing.expect(!nameSaysSecret("")); +} + +test "the detector can be told to report only what it recognises" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const text = "MY_KEY=Xq7pLm2vNc9wTr4yUi8oPa3sDf6gHj1k and ghp_" ++ "m" ** 36; + const strict: Policy = .{ .infer_from_names = false }; + + const inferring = try scan(arena, text, .{}); + try testing.expectEqual(@as(usize, 2), inferring.len); + + // With inference off, only the published shape is reported — the setting + // for a deployment where a false positive costs more than a miss. + const recognising = try scan(arena, text, strict); + try testing.expectEqual(@as(usize, 1), recognising.len); + try testing.expectEqual(Kind.github_token, recognising[0].kind); +} + +test "detects answers yes or no without building the rewritten copy" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const redactor = Redactor.withSalt(test_salt); + try testing.expect(try redactor.detects(arena, "key=ghp_" ++ "n" ** 36)); + try testing.expect(!try redactor.detects(arena, "nothing to see here")); +} + +test "every kind names itself, and no two kinds share a name" { + // The placeholder is the only thing a reader gets, so a kind with no words + // or a duplicate name would make the record ambiguous about what was taken. + for (std.enums.values(Kind)) |kind| { + try testing.expect(kind.text().len > 0); + for (std.enums.values(Kind)) |other| { + if (kind == other) continue; + try testing.expect(!std.mem.eql(u8, kind.text(), other.text())); + } + } +} diff --git a/src/terminal/interactive.zig b/src/terminal/interactive.zig index 81cb41a..79837d6 100644 --- a/src/terminal/interactive.zig +++ b/src/terminal/interactive.zig @@ -293,6 +293,7 @@ pub fn run( .actor = service.actor, .content_store = &service.content, .marker_token = if (launch.marksPrompts) options.markerToken else null, + .redactor = service.redactor, }, wallClock(io)); try tty.enterRaw(); diff --git a/src/terminal/session.zig b/src/terminal/session.zig index be0f34e..287ccd9 100644 --- a/src/terminal/session.zig +++ b/src/terminal/session.zig @@ -17,6 +17,7 @@ const content_store_mod = @import("../events/content_store.zig"); const idmod = @import("../core/id.zig"); const hashing = @import("../core/hash.zig"); const timeutil = @import("../core/time.zig"); +const secrets = @import("../security/secrets.zig"); pub const Options = struct { columns: u16 = 80, @@ -31,6 +32,14 @@ pub const Options = struct { content_store: ?*content_store_mod.Store = null, /// A parent-only token required on service-owned command boundary marks. marker_token: ?[]const u8 = null, + /// Takes credentials out of command text and captured output before either + /// becomes a record. + /// + /// Optional only so that tests exercising the parser need not build one. + /// Every production path sets it, because the log is append-only and a + /// secret written into it cannot be taken out again without breaking the + /// chain that proves nothing was. + redactor: ?secrets.Redactor = null, }; /// Drives one program and turns its output into workspace events. @@ -68,6 +77,7 @@ pub const Session = struct { /// open. A recognised shell mark is removed from the captured bytes. capture_osc_start: ?usize = null, content_store: ?*content_store_mod.Store, + redactor: ?secrets.Redactor, pub fn init( arena: std.mem.Allocator, @@ -87,6 +97,7 @@ pub const Session = struct { .log = log, .actor = options.actor, .content_store = options.content_store, + .redactor = options.redactor, .ids = idmod.Generator.init(@bitCast(clock.ns), @divFloor(clock.ns, timeutil.ns_per_ms)), .clock = clock, .session_event = undefined, @@ -179,6 +190,17 @@ pub const Session = struct { } } + /// Take credentials out of text on its way into the record. + /// + /// A session with no redactor keeps the text as it was — the setting that + /// exists so a parser test need not build one, and the setting no + /// production path uses. + fn redact(self: *Session, text: []const u8) !secrets.Result { + const redactor = self.redactor orelse + return .{ .text = text, .findings = &.{} }; + return redactor.rewrite(self.arena, text); + } + fn handleBoundary(self: *Session, boundary: integration.Boundary) !void { switch (boundary) { .output_start => |o| { @@ -191,12 +213,18 @@ pub const Session = struct { self.open_block = block; self.open_block_started = self.clock; self.output = .empty; + // `export ANTHROPIC_API_KEY=…` is a command like any other, and + // a command line is the most common way a key reaches a log. + // It is redacted here, before the text is duplicated into the + // arena the event will point at. + const clean = try self.redact(command); const submitted = try self.log.append(.{ .command_submitted = .{ .block = block, .session = self.id, - .commandText = try self.arena.dupe(u8, command), + .commandText = try self.arena.dupe(u8, clean.text), .workingDirectory = self.tracker.working_directory orelse "", .boundaryFromShell = true, + .redactions = clean.findings.len, } }, .{ .at = self.clock, .actor = self.actor, @@ -228,15 +256,21 @@ pub const Session = struct { fn finishOpenBlock(self: *Session, exit_status: u8, signal: ?u8) !void { const block = self.open_block orelse return; if (self.output.items.len > 0) { + // The person watching the terminal already saw the real bytes on + // their own screen; `self.output` is the copy on its way to being + // kept. This is the last point at which a credential can be taken + // out of it, because everything after this is hashed and chained. + const clean = try self.redact(self.output.items); const content_hash = if (self.content_store) |store| - try store.put(self.output.items) + try store.put(clean.text) else - hashing.Hash.of(self.output.items); + hashing.Hash.of(clean.text); _ = try self.log.append(.{ .process_output = .{ .block = block, .session = self.id, .contentHash = content_hash, - .byteCount = self.output.items.len, + .byteCount = clean.text.len, + .redactions = clean.findings.len, } }, .{ .at = self.clock, .actor = self.actor, diff --git a/src/workspace/service.zig b/src/workspace/service.zig index c41c4e5..52d2355 100644 --- a/src/workspace/service.zig +++ b/src/workspace/service.zig @@ -20,6 +20,7 @@ const log_mod = @import("../events/log.zig"); const graph_mod = @import("../events/graph.zig"); const content_store_mod = @import("../events/content_store.zig"); const block_mod = @import("block.zig"); +const secrets = @import("../security/secrets.zig"); const model_mod = @import("model.zig"); const history_mod = @import("history.zig"); const workflow_mod = @import("workflow.zig"); @@ -196,6 +197,12 @@ pub const Service = struct { /// one another. persisted_bytes: u64 = 0, persisted_fingerprint: hashing.Hash = hashing.Hash.zero, + /// Takes credentials out of anything on its way into the log. + /// + /// One per opened workspace, so a key that appears in ten commands reads as + /// one key across the whole record, and so the salt behind those + /// fingerprints is drawn once and never written down. + redactor: secrets.Redactor, /// Open a workspace: read the log, verify it, fold it, and read the /// knowledge base. Never repairs anything. @@ -266,6 +273,7 @@ pub const Service = struct { .sealed = chain_break != null or torn_bytes > 0 or malformed != null, .persisted_bytes = persisted_bytes, .persisted_fingerprint = persisted_fingerprint, + .redactor = try secrets.Redactor.init(io), }; const report: OpenReport = .{ .root = options.root, @@ -511,6 +519,7 @@ pub const Service = struct { .actor = self.actor, .content_store = &self.content, .marker_token = marker_token, + .redactor = self.redactor, }, self.clock); const marker_command = try encodeOscField(self.arena, command_text); @@ -1654,3 +1663,67 @@ test "an agent run is recorded as one causal tree, refusals included" { const run = try graph.run(loaded.log.entries.items[roots.items[0]].id); try testing.expectEqual(loaded.log.entries.items.len, run.items.len); } + +test "a key a command prints never reaches the stored bytes or the log" { + if (comptime !@import("../terminal/pty.zig").supported) return error.SkipZigTest; + + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + const at = try Timestamp.parseIso("2026-09-05T09:00:00Z"); + const opened = try Service.open(arena, io, .{ .root = root, .actor = testActor(), .now = at }); + var service = opened.service; + + // The whole point of the feature, exercised the way it actually happens: a + // command prints a credential, and the workspace keeps a record of the + // command. This is not a unit test of the detector — it is the wiring, and + // the wiring is the part that was missing. + const key = "sk-ant-api03-" ++ "w" ** 40; + const run = service.runCommand("printf 'key is " ++ key ++ "\\n'", .fromSeconds(5)) catch |err| switch (err) { + error.OpenFailed, error.ConfigureFailed, error.UnsupportedPlatform => return error.SkipZigTest, + else => return err, + }; + try service.flush(io); + + // Not in the command text the log kept, even though the person typed it. + try testing.expect(std.mem.indexOf(u8, run.blocks[0].commandText.?, "sk-ant") == null); + try testing.expect(std.mem.indexOf(u8, run.blocks[0].commandText.?, "[redacted anthropic-api-key ") != null); + + var output_hash: ?hashing.Hash = null; + var output_redactions: usize = 0; + var command_redactions: usize = 0; + for (service.log.entries.items) |entry| switch (entry.payload) { + .process_output => |output| { + output_hash = output.contentHash; + output_redactions += output.redactions; + }, + .command_submitted => |submitted| command_redactions += submitted.redactions, + else => {}, + }; + + // Not in the stored bytes either. + const bytes = try service.readContent(io, output_hash.?, 4096); + try testing.expect(std.mem.indexOf(u8, bytes, "sk-ant") == null); + try testing.expect(std.mem.indexOf(u8, bytes, "key is [redacted anthropic-api-key ") != null); + + // And the counts are on the events, so a reader holding only the log can + // tell a clean block from a redacted one. This is the part that survives + // the content objects being lost. + try testing.expect(command_redactions >= 1); + try testing.expect(output_redactions >= 1); + + // Nowhere in the log file on disk, which is the file that cannot be edited + // afterwards without breaking the chain. + const on_disk = try Service.readFileIfPresent(arena, io, try Service.logPath(arena, root)); + try testing.expect(std.mem.indexOf(u8, on_disk.?, "sk-ant") == null); + + const reopened = try Service.open(arena, io, .{ .root = root, .actor = testActor(), .now = at }); + try testing.expect(reopened.report.isHealthy()); +} diff --git a/vocab/workspace.jsonld b/vocab/workspace.jsonld index 4c5cd56..cacf604 100644 --- a/vocab/workspace.jsonld +++ b/vocab/workspace.jsonld @@ -39,6 +39,9 @@ { "@id": "https://zag.dev/vocab/workspace/policy" }, + { + "@id": "https://zag.dev/vocab/workspace/redaction" + }, { "@id": "https://zag.dev/vocab/workspace/approval" }, @@ -281,6 +284,9 @@ { "@id": "https://zag.dev/vocab/workspace/block" }, + { + "@id": "https://zag.dev/vocab/workspace/redaction" + }, { "@id": "https://zag.dev/vocab/workspace/execution-graph" } @@ -486,6 +492,9 @@ { "@id": "https://zag.dev/vocab/workspace/capability" }, + { + "@id": "https://zag.dev/vocab/workspace/redaction" + }, { "@id": "https://zag.dev/vocab/workspace/authorization-decision" } @@ -495,6 +504,47 @@ }, "dcterms:subject": "software engineering" }, + { + "@id": "https://zag.dev/vocab/workspace/redaction", + "@type": { + "@id": "skos:Concept" + }, + "skos:inScheme": { + "@id": "https://zag.dev/vocab/workspace" + }, + "skos:prefLabel": { + "@value": "redaction", + "@language": "en" + }, + "skos:definition": { + "@value": "replacement of a credential in text by a placeholder that names its kind, done before the text becomes a record or leaves the machine", + "@language": "en" + }, + "skos:altLabel": { + "@value": "masking", + "@language": "en" + }, + "skos:hiddenLabel": { + "@value": "scrubbing", + "@language": "en" + }, + "skos:related": [ + { + "@id": "https://zag.dev/vocab/workspace/workspace-event" + }, + { + "@id": "https://zag.dev/vocab/workspace/policy" + } + ], + "skos:topConceptOf": { + "@id": "https://zag.dev/vocab/workspace" + }, + "skos:example": { + "@value": "an API key a command printed, replaced in the stored output by a placeholder naming the provider", + "@language": "en" + }, + "dcterms:subject": "software engineering" + }, { "@id": "https://zag.dev/vocab/workspace/authorization-decision", "@type": { diff --git a/vocab/workspace.thesaurus.txt b/vocab/workspace.thesaurus.txt index 95f464d..2be78d3 100644 --- a/vocab/workspace.thesaurus.txt +++ b/vocab/workspace.thesaurus.txt @@ -106,6 +106,9 @@ knowledge base machine learning RT AI system +masking + USE redaction + metric model @@ -117,6 +120,7 @@ plain language policy RT capability + RT redaction RT authorisation decision project shell @@ -143,6 +147,12 @@ record NT authorisation decision NT evidence +redaction + UF masking + UF scrubbing + RT workspace event + RT policy + requirement BT standard RT evidence @@ -151,6 +161,10 @@ requirement risk RT impact assessment +scrubbing + USE redaction + SN Says nothing about what replaced the value. Use redaction. + session BT workspace NT block @@ -193,6 +207,7 @@ workspace event UF event BT workspace RT block + RT redaction RT execution graph worktree diff --git a/vocab/workspace.ttl b/vocab/workspace.ttl index 35d1b5b..8a04379 100644 --- a/vocab/workspace.ttl +++ b/vocab/workspace.ttl @@ -113,6 +113,20 @@ skos:topConceptOf . skos:hasTopConcept . dcterms:subject "software engineering" . + a skos:Concept . + skos:inScheme . + skos:prefLabel "redaction"@en . + skos:definition "replacement of a credential in text by a placeholder that names its kind, done before the text becomes a record or leaves the machine"@en . + skos:altLabel "masking"@en . + skos:hiddenLabel "scrubbing"@en . + skos:related . + skos:related . + skos:related . + skos:related . + skos:topConceptOf . + skos:hasTopConcept . + skos:example "an API key a command printed, replaced in the stored output by a placeholder naming the provider"@en . + dcterms:subject "software engineering" . a skos:Concept . skos:inScheme . skos:prefLabel "authorisation decision"@en . From 9f9d287f9473ac7becd217064abbf2956c9301be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:10:39 +0000 Subject: [PATCH 08/12] Check where a provider's name actually resolves, before connecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap's other open Priority 1 item: the policy engine decides `network.connect` on a host, and it decides it on text. Between that rule and the socket sits a name lookup the workspace did not control and did not look at. Three things go wrong in that gap. A literal address dodges the name entirely: a rule listing hosts by name has nothing to say about `http://169.254.169.254/`, which is the cloud metadata service on every major provider and hands credentials to anything that asks. A public name can resolve into private space, which is a request to the machine's own network made from inside it, wearing a name the allowlist accepted. And a name can answer differently the second time. src/security/netguard.zig classifies every address a name answers with — not the first, not the one that happens to be chosen. A client may try any address in the list, so one bad address refuses the whole name. What counts as bad depends on what the connector claimed about itself, and the two cases are opposites rather than degrees: a hosted connector must resolve to public address space, and a local one must resolve onto this machine. The second direction matters as much as the first — a local model whose name suddenly answers with a public address is a workspace sending its prompts to a stranger while believing they never left the laptop. IPv4-mapped and IPv4-compatible IPv6 addresses are unmapped before classification. `::ffff:169.254.169.254` is the metadata service, and a checker that reads IPv6 prefixes and stops calls it a global unicast address and lets it through. There is a test for it built from bytes rather than text, because Zig's own IPv6 parser rejects both the hexadecimal spelling of a mapped address and the compatible form — and neither refusal protects anything, since a nameserver answers an AAAA query with sixteen bytes and no text at all. An empty answer set is a refusal, and so is a lookup that fails. Those are the two places a guard like this usually fails open. The check is its own stage in the transport gate, between the network decision and the credential: after the policy allowed the host by name, and before the credential is read, so a rebinding attack cannot spend one. It is a separate stage from `network` because the two fail for opposite reasons — one is a rule that did not permit the host, the other is a host that turned out not to be where its name said — and the record should not conflate them. A test asserts that both earlier decisions were taken and allowed before the address refusal, and that nothing reached the sender. `Endpoint.host` was splitting on the first colon, which is wrong for exactly one shape and it is the shape a local model uses: `[::1]:8080` came back as `[` and matched no rule anybody could write. That is the same defect this repository already fixed once in the policy engine. The port rules now live in one place, and a test asserts the netguard copy and the policy engine still agree — the relationship that would otherwise drift. What this does not close is written down rather than implied away: the connection is made by `std.http.Client`, which resolves the name again. A name that answers correctly to the check and incorrectly to the connection is not caught. Closing it needs the connection made to the address that was checked, which means owning the socket. The roadmap and the threat model (T22, and T12's residual risk) both say so. Both gate tests were checked by disabling the guard and watching them fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- docs/roadmap.md | 12 +- docs/threat-model.md | 3 +- evidence/conformance.jsonl | 34 +- src/ai/loop.zig | 6 +- src/ai/provider.zig | 40 ++- src/ai/transport.zig | 162 +++++++++ src/cli/main.zig | 3 + src/root.zig | 1 + src/security/netguard.zig | 718 +++++++++++++++++++++++++++++++++++++ 9 files changed, 952 insertions(+), 27 deletions(-) create mode 100644 src/security/netguard.zig diff --git a/docs/roadmap.md b/docs/roadmap.md index 5277a75..12e8516 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -163,11 +163,13 @@ three times before anything is encoded. Still open: -- Enforce network host allowlists at the connection boundary rather than at the - request boundary. The host is decided before the request is built and a - redirect is refused outright, but name resolution and address changes are not - yet checked at the socket, so a host that resolves to an unexpected address is - not caught. +- Connect to the address that was checked. Every address a provider's name + resolves to is now classified before anything connects, and a public name + answering with a loopback, link-local, private or reserved address refuses the + request. What remains is that `std.http.Client` resolves the name a second + time when it connects, so a name that answers correctly to the check and + incorrectly to the connection is not caught. Closing that means owning the + socket rather than handing a URL to a client. - Review `docs/threat-model.md` with local users, malicious repositories, compromised child processes, model providers and remote clients in scope. - Extend redaction to the remaining outputs. Command text, captured output and diff --git a/docs/threat-model.md b/docs/threat-model.md index 9ca48a7..f42cbdc 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -51,11 +51,12 @@ against that actor. | T6 | A child prints OSC 133 bytes to forge a command boundary or status | Service-owned command wrappers use a random marker token that is removed before the command starts; control marks and parser states are bounded | Extend authenticated markers to interactive shell hooks; an ordinary `boundaryFromShell` value alone is not proof of origin | | T7 | A child floods output or never exits | Captured output is bounded; deadlines use monotonic time and terminate the process group, escalating to `KILL` | Add operating-system CPU, memory, process-count and disk quotas | | T8 | Terminal output discloses a secret | New event and object files use owner-only permissions where POSIX permissions exist | Add detection and redaction before output enters model context or diagnostic bundles | -| T12 | An agent spends a credential on a provider nobody chose | Asking a model needs three separate decisions — `model.infer` on the connector, `network.connect` on the host, `credentials.use` on the variable — all taken before the request is encoded. The credential is read only after its own decision allows it, is written into a header by the connector, and never enters a decision, a log, an error or a summary. A redirect is never followed, so a credential cannot be carried to a host the policy did not decide | Enforce the host at the socket, including name resolution and address changes; a host that resolves to an unexpected address is not yet caught | +| T12 | An agent spends a credential on a provider nobody chose | Asking a model needs three separate decisions — `model.infer` on the connector, `network.connect` on the host, `credentials.use` on the variable — all taken before the request is encoded. The credential is read only after its own decision allows it, is written into a header by the connector, and never enters a decision, a log, an error or a summary. A redirect is never followed, so a credential cannot be carried to a host the policy did not decide | Connect to the address that was checked. The client resolves the name again when it connects, so a name that answers differently to the check and to the connection is not caught | | T12a | A model asks for a tool that would widen its own reach | A model's tool call becomes a typed request or nothing: an unknown name, arguments that do not fit the type, or an unknown enum value are all refused before any policy is consulted. The capability is taken from the request kind, never from the arguments, so a model that writes a capability into its own call is writing a field that does not exist. `infer` and `spawn_agent` are not offered at all, and a test asserts their absence rather than leaving it to whoever edits the list | Offer a way for a person to widen the offered set deliberately, with the widening recorded | | T12b | An agent loop runs away, or repeats a refused call for ever | Four bounds, each reported separately: turns, tokens, a monotonic wall clock, and a repetition count over a fingerprint of the request's kind and resource. The fingerprint deliberately ignores fields nobody decides on, so varying a byte limit cannot defeat it | Bound the total work an agent may cause across runs, not only within one | | T13 | A workspace policy file is written to widen permission | The file is never shown to a model and never consulted by one. Its fallback is not read from the file: an unparsable file allows nothing, and nothing more permissive is substituted. An unknown capability name refuses the whole file rather than dropping the rule, so a denial cannot be narrowed by a typo | Add a recorded event when the policy file changes, so a widening is visible in the log rather than only in the file | | T21 | A credential printed by a command is written into a log that cannot be edited afterwards | Command text and captured output are scanned before either becomes a record, and a credential is replaced by a placeholder naming its kind and a salted fingerprint. The same scan runs on everything sent to a model provider, including tool results, and the prompt is hashed after the redaction rather than before, so the record points at what actually left. The count of removals is on the event, which is what the hash chain covers, so a reader holding only the log can tell a clean block from a redacted one. `zag secrets` shows what would be removed without writing anything | The detector finds published credential shapes, delimited key blocks, credentials in URLs, and values assigned to a name that says "secret". A bare password with no prefix, no delimiter and no name beside it is not found, and no detector finds one | +| T22 | A provider name resolves somewhere it should not, reaching the cloud metadata service or the machine's own network | Every address a name answers with is classified before anything connects, and one bad address refuses the whole name — a client may try any of them. A hosted connector must resolve to public address space; a local one must resolve onto this machine, so a local model whose name suddenly answers with a public address is refused too. Literal addresses are checked without a lookup, which is the case a name-based allowlist has nothing to say about, and IPv4-mapped and IPv4-compatible IPv6 addresses are unmapped before classification, so `::ffff:169.254.169.254` is the metadata service and not a global unicast address. A lookup that fails is a refusal, never a pass. The check is its own stage in the record, after the policy allowed the host by name and before any credential is read | The connection is made by a client that resolves the name a second time. A name that answers correctly to the check and incorrectly to the connection — DNS rebinding — is not caught, and closing it means owning the socket | | T9 | A terminal sequence corrupts memory or parser state | Fixed parser bounds, UTF-8 replacement, screen bounds and regression tests | Add coverage-guided fuzzing and differential tests against a mature terminal parser | | T10 | A build input is replaced upstream | No package dependencies; CI pins the checkout action and verifies the Zig archive digest | Add signed release provenance, artifact signing and an independently verified toolchain policy | | T11 | A future remote client bypasses local policy | No network listener exists today | Define authentication, per-request authorisation, replay protection, rate limits and audit before listening on a socket | diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index bfc1274..da2be65 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T13:56:47.395121985Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/ai/loop.zig b/src/ai/loop.zig index 6b47e46..9b69ade 100644 --- a/src/ai/loop.zig +++ b/src/ai/loop.zig @@ -393,7 +393,11 @@ pub const Runner = struct { try turns.append(self.arena, .{ .number = number, .attempt = attempt }); ending = switch (err) { error.NeedsApproval => .waiting_for_a_person, - error.NotPermitted, error.CredentialMissing => .stopped_by_policy, + // An address that is not where its name said is a + // refusal by the workspace, not a provider being down. It + // stops the run the same way a policy denial does, because + // that is what it is. + error.NotPermitted, error.CredentialMissing, error.AddressRefused => .stopped_by_policy, error.ProviderRejected, error.SendFailed => .provider_unavailable, error.OutOfMemory => return error.OutOfMemory, }; diff --git a/src/ai/provider.zig b/src/ai/provider.zig index 8faaaab..e51b718 100644 --- a/src/ai/provider.zig +++ b/src/ai/provider.zig @@ -18,6 +18,7 @@ const std = @import("std"); const hashing = @import("../core/hash.zig"); +const netguard = @import("../security/netguard.zig"); pub const Role = enum { user, @@ -238,15 +239,48 @@ pub const Endpoint = struct { /// Extra headers a deployment needs, such as a cloud resource name. extraHeaders: []const HttpRequest.Header = &.{}, - /// The host part of the base address, for the policy engine. - pub fn host(self: Endpoint) []const u8 { + /// The host and port of the base address, with no scheme and no path. + /// + /// `api.anthropic.com`, `localhost:11434`, `[::1]:8080`. Kept together + /// because the port is part of what a person writes in a rule, and every + /// consumer that wants one without the other says so. + pub fn authority(self: Endpoint) []const u8 { var rest = self.baseUrl; if (std.mem.indexOf(u8, rest, "://")) |at| rest = rest[at + 3 ..]; if (std.mem.indexOfScalar(u8, rest, '/')) |at| rest = rest[0..at]; - if (std.mem.indexOfScalar(u8, rest, ':')) |at| rest = rest[0..at]; return rest; } + /// The host part of the base address, for the policy engine. + /// + /// Splitting on the first colon was wrong for exactly one shape and it is + /// the shape a local model uses: `[::1]:8080` came back as `[` and matched + /// no rule anybody could write. The port rules live in one place and this + /// calls them. + pub fn host(self: Endpoint) []const u8 { + return netguard.hostWithoutPort(self.authority()); + } + + /// The port, or the default for the scheme. + pub fn port(self: Endpoint) u16 { + const rest = self.authority(); + const after = if (rest.len > 0 and rest[0] == '[') + (if (std.mem.indexOfScalar(u8, rest, ']')) |close| rest[close + 1 ..] else "") + else blk: { + // A bare IPv6 address is all colons and carries no port. + var colons: usize = 0; + for (rest) |byte| { + if (byte == ':') colons += 1; + } + if (colons > 1) break :blk ""; + break :blk if (std.mem.indexOfScalar(u8, rest, ':')) |at| rest[at..] else ""; + }; + if (after.len > 1 and after[0] == ':') { + if (std.fmt.parseInt(u16, after[1..], 10)) |parsed| return parsed else |_| {} + } + return if (std.mem.startsWith(u8, self.baseUrl, "http://")) 80 else 443; + } + /// True when the endpoint is on this machine. A local model still passes /// the policy engine, but a person deciding about it wants to know that /// nothing leaves the computer. diff --git a/src/ai/transport.zig b/src/ai/transport.zig index 42b0a06..44e8c59 100644 --- a/src/ai/transport.zig +++ b/src/ai/transport.zig @@ -40,6 +40,7 @@ const capability_mod = @import("capability.zig"); const tools = @import("tools.zig"); const hashing = @import("../core/hash.zig"); const secrets = @import("../security/secrets.zig"); +const netguard = @import("../security/netguard.zig"); pub const Connector = catalog.Connector; pub const Decision = policy_mod.Decision; @@ -53,6 +54,10 @@ pub const Error = error{ NeedsApproval, /// The connector needs a credential and the variable is empty. CredentialMissing, + /// The host was allowed by name, and the addresses it resolves to are not + /// where that name is supposed to go. `Attempt.addressProblem` says which + /// address and what is wrong with it. + AddressRefused, /// The provider answered with a status this build treats as a failure. ProviderRejected, /// The request could not be sent at all. @@ -66,6 +71,7 @@ pub fn refusalText(err: Error) []const u8 { error.NotPermitted => "The policy does not allow this model request.", error.NeedsApproval => "This model request is waiting for a person to allow it.", error.CredentialMissing => "That provider needs a credential and none is set.", + error.AddressRefused => "The provider's name does not resolve to where it is supposed to go.", error.ProviderRejected => "The provider refused the request.", error.SendFailed => "The request could not be sent.", error.OutOfMemory => "There was not enough memory to send this.", @@ -108,12 +114,18 @@ pub const Sender = struct { pub const Stage = enum { infer, network, + /// The host was allowed by name, and the addresses it answers with were + /// not. A separate stage from `network` because the two fail for opposite + /// reasons: one is a rule that did not permit the host, the other is a host + /// that turned out not to be where its name said. + address, credential, pub fn text(self: Stage) []const u8 { return switch (self) { .infer => "using a model", .network => "reaching the provider over the network", + .address => "the address the provider's name resolves to", .credential => "using the stored credential", }; } @@ -138,6 +150,10 @@ pub const Attempt = struct { redactions: usize = 0, /// Set when the request never went out. refusedAt: ?Stage = null, + /// Why the addresses were refused, when they were. Written in words, and + /// naming the address, because "refused" on its own sends somebody to read + /// the source. + addressProblem: []const u8 = "", /// The prompt's hash. The prompt itself is the caller's to store or not; /// the record says which prompt without holding it. promptHash: hashing.Hash, @@ -232,6 +248,14 @@ pub const Transport = struct { /// Optional so a wire-format test need not build one. Every path that /// reaches a real provider sets it. redactor: ?secrets.Redactor = null, + /// Checks where the provider's name actually resolves to, before anything + /// connects. + /// + /// The policy decides the host as text. This decides whether the text still + /// means what it said — whether a public name answers with a private + /// address, and whether a local model is still local. Optional for the same + /// reason the redactor is, and set on every path that reaches a provider. + guard: ?netguard.Guard = null, /// Decide, then send. /// @@ -280,6 +304,33 @@ pub const Transport = struct { return stopped(network); } + // 2a. Is the host still where its name says? The rule above allowed + // some text; this asks what that text resolves to. A public name + // answering with a private address, or a local model answering with + // a public one, is refused here — after the policy allowed the + // name and before any credential is read, so a rebinding attack + // cannot spend one. + if (self.guard) |guard| { + const endpoint = connector.endpoint(""); + const expectation: netguard.Expectation = + if (connector.locality == .local) .this_machine else .the_internet; + const verdict = guard.verify( + self.arena, + endpoint.authority(), + endpoint.port(), + expectation, + ) catch |err| { + attempt.refusedAt = .address; + attempt.addressProblem = addressProblemText(err); + return error.AddressRefused; + }; + if (verdict == .refused) { + attempt.refusedAt = .address; + attempt.addressProblem = verdict.refused.text(self.arena) catch "The address could not be checked."; + return error.AddressRefused; + } + } + // 3. May the stored credential be spent here? Asked before it is read, // so a refusal never touches the value. var api_key: []const u8 = ""; @@ -412,6 +463,20 @@ pub const Transport = struct { /// there" — usually a local server they have not started — and "the network /// would not carry it", so those are separated and everything else is left /// honestly vague rather than guessed at. +/// Why an address could not be checked, in words. +/// +/// A failure to check is a refusal, not a pass. The alternative — treating an +/// unreachable nameserver as permission — is how a control like this quietly +/// stops working on the day it is needed. +pub fn addressProblemText(err: netguard.Error) []const u8 { + return switch (err) { + error.HostUnusable => "The provider's address is not a name or an address this build can read.", + error.LookupFailed => "The provider's name could not be looked up, so where it goes is unknown. Nothing was sent.", + error.TooManyAddresses => "The provider's name answers with more addresses than can be checked. Nothing was sent.", + error.OutOfMemory, error.WriteFailed => "The address check could not be completed. Nothing was sent.", + }; +} + pub fn sendProblemText(err: anyerror, local: bool) []const u8 { return switch (err) { error.ConnectionRefused => if (local) @@ -939,3 +1004,100 @@ test "a credential in the prompt never reaches the wire" { try testing.expect(attempt.promptHash.eql(hashPrompt(outgoing))); try testing.expect(!attempt.promptHash.eql(hashPrompt(request))); } + +test "a provider whose name resolves somewhere private is refused before the credential is read" { + var fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + + // A connector whose base address is the cloud metadata service. The policy + // here allows everything, so the only thing that can stop this is the + // address check — which is the point: the two are separate questions and + // this proves the second one is asked. + const rogue: catalog.Connector = .{ + .id = "rogue", + .name = "Rogue", + .format = .openai, + .baseUrl = "http://169.254.169.254", + .keyVariable = "ANTHROPIC_API_KEY", + .locality = .hosted, + .verified = .format_only, + .note = "A connector pointed at the cloud metadata service.", + }; + + var transport = fixture.transport(with_key); + transport.guard = .{ .io = threaded.io() }; + + var attempt: Attempt = undefined; + const result = transport.send(rogue, ask(), fixture.context(), &attempt); + try testing.expectError(error.AddressRefused, result); + try testing.expectEqual(Stage.address, attempt.refusedAt.?); + + // Nothing was handed to the sender. A refusal that still made the request + // would be no refusal at all. + try testing.expectEqual(@as(usize, 0), fixture.recorded.seen.items.len); + + // The refusal names the address and what is wrong with it, in words. + try testing.expect(std.mem.indexOf(u8, attempt.addressProblem, "169.254.169.254") != null); + try testing.expect(std.mem.indexOf(u8, attempt.addressProblem, "this network link") != null); + + // The first two decisions were taken and recorded before the address was + // checked, so the record still shows the policy allowed the host by name. + // That distinction is the whole reason this is a separate stage. + try testing.expect(attempt.decisions.len >= 2); + for (attempt.decisions) |decision| try testing.expect(decision.isAllowed()); +} + +test "a hosted connector pointed at loopback is refused, and a local one is not" { + var fixture = try Fixture.init(&allow_everything, &.{ + .{ .status = 200, .body = answer }, + .{ .status = 200, .body = answer }, + }); + defer fixture.deinit(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + + var transport = fixture.transport(with_key); + transport.guard = .{ .io = threaded.io() }; + + // Ollama, on this machine, at the address it is supposed to be at. + const local: catalog.Connector = .{ + .id = "local", + .name = "Local", + // The wire format is beside the point here; the fixture answers in the + // one this repository was written against so that a decode failure + // cannot be mistaken for an address refusal. + .format = .anthropic, + .baseUrl = "http://127.0.0.1:11434", + .locality = .local, + .verified = .format_only, + .note = "A model on this computer.", + }; + var allowed: Attempt = undefined; + _ = try transport.send(local, ask(), fixture.context(), &allowed); + try testing.expect(allowed.refusedAt == null); + + // The identical address, offered by a connector that says it is hosted + // somewhere else. Same bytes, opposite answer, because the expectation is + // what the connector claimed about itself. + var disguised = local; + disguised.id = "not-local"; + disguised.locality = .hosted; + var refused: Attempt = undefined; + try testing.expectError(error.AddressRefused, transport.send(disguised, ask(), fixture.context(), &refused)); + try testing.expectEqual(Stage.address, refused.refusedAt.?); + try testing.expect(std.mem.indexOf(u8, refused.addressProblem, "127.0.0.1") != null); +} + +test "every stage of the gate says what it is in words" { + // A refusal names the stage it happened at, and a stage with no words would + // print an empty reason to somebody trying to understand a denial. + for (std.enums.values(Stage)) |stage| { + try testing.expect(stage.text().len > 0); + for (std.enums.values(Stage)) |other| { + if (stage == other) continue; + try testing.expect(!std.mem.eql(u8, stage.text(), other.text())); + } + } +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 22b22f5..c7b81b0 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1232,6 +1232,9 @@ fn askAModel( // the policy allowed to leave. The two are separate questions and this // is the second one. .redactor = try zag.security.secrets.Redactor.init(io), + // And nothing connects to a host whose name does not resolve to where + // the connector said it lives. + .guard = .{ .io = io }, }; // The loop, not a single question. A model that can read the workspace and diff --git a/src/root.zig b/src/root.zig index bb809bf..032d228 100644 --- a/src/root.zig +++ b/src/root.zig @@ -130,6 +130,7 @@ pub const ai = struct { pub const security = struct { pub const secrets = @import("security/secrets.zig"); + pub const netguard = @import("security/netguard.zig"); }; pub const records = struct { diff --git a/src/security/netguard.zig b/src/security/netguard.zig new file mode 100644 index 0000000..abcc523 --- /dev/null +++ b/src/security/netguard.zig @@ -0,0 +1,718 @@ +//! Checking where a name actually goes, not only what it is called. +//! +//! The policy engine decides `network.connect` on a host, and it decides it on +//! *text*: `api.anthropic.com`, `localhost:11434`, `*.internal.test`. That is +//! the right unit for a person to write a rule in, and it is not the thing a +//! socket connects to. Between the rule and the connection sits a name lookup +//! that the workspace does not control and, until this module, did not look at. +//! +//! Three things go wrong in that gap, and the third is the one that matters: +//! +//! * **A literal address dodges the name.** A rule listing hosts by name has +//! nothing to say about `https://169.254.169.254/`, which is the cloud +//! metadata service on every major provider and hands out credentials to +//! anything that asks. +//! * **A public name resolves somewhere private.** `evil.example` with an A +//! record of `10.0.0.5` is a request to the machine's own network, made +//! from inside it, wearing a name the allowlist accepted. +//! * **A name answers differently the second time.** The lookup that a check +//! performs and the lookup that the connection performs are two separate +//! questions, and an attacker who controls the name can answer them +//! differently. This is DNS rebinding and it is the reason the section at +//! the bottom of this comment exists. +//! +//! ## What is enforced +//! +//! Every address a name resolves to is classified and checked — not the first +//! one, not the one that happens to be chosen. A client is free to try any +//! address in the list, and a race that picks the bad one is exactly the case +//! worth stopping, so one bad address refuses the whole name. +//! +//! What counts as bad depends on where the connector said it lives, and the +//! two cases are opposites rather than degrees: +//! +//! * A **local** connector — Ollama, llama.cpp, LM Studio, anything at +//! `localhost` — must resolve onto this machine and nowhere else. A local +//! model whose name suddenly resolves to a public address is a workspace +//! sending its prompts somewhere it believes is its own laptop. +//! * A **remote** connector must resolve to public address space and nothing +//! else. This is the rule that stops the metadata service, the private +//! network and the loopback interface, and it stops them whether the +//! address arrived as a literal or as an answer to a question. +//! +//! ## The IPv4-mapped form is the same address +//! +//! `::ffff:169.254.169.254` is the metadata service written as IPv6, and a +//! checker that classifies IPv6 addresses by their own prefixes calls it a +//! global unicast address and lets it through. Every mapped address is unmapped +//! before it is classified, and there is a test for exactly this because it is +//! the bypass this kind of code is usually missing. +//! +//! ## What this does not close +//! +//! The connection is made by `std.http.Client`, which resolves the name again +//! when it connects. This module resolves, checks, and then hands the name +//! over — so a name that answers correctly to the check and incorrectly to the +//! connection a moment later is not caught. That is the rebinding case, it is +//! narrow, and it is real. +//! +//! Closing it needs the connection to be made to the address that was checked, +//! which means owning the socket rather than handing a URL to a client. That is +//! a larger change than this one and it is written down here rather than +//! implied away. What is closed today is every literal address, every name with +//! a bad answer, and every name whose answer set contains one bad address — +//! which is the whole of the attack that does not require controlling a +//! nameserver in real time. + +const std = @import("std"); +const net = std.Io.net; + +/// Where an address lives. +/// +/// The names are the ones the registries use, so that somebody checking this +/// against RFC 6890 or RFC 4193 is reading the same words. +pub const Reach = enum { + /// This machine. `127.0.0.0/8`, `::1`. + loopback, + /// This link only, and the address the cloud metadata service answers on. + /// `169.254.0.0/16`, `fe80::/10`. + link_local, + /// A private network. `10/8`, `172.16/12`, `192.168/16`, `fc00::/7`. + private, + /// Carrier-grade NAT, `100.64.0.0/10`. Not the public internet and not the + /// machine's own network either. + shared, + /// Many machines at once. `224.0.0.0/4`, `ff00::/8`. + multicast, + /// "This host on this network", `0.0.0.0` and `::`. Connecting to it means + /// connecting to this machine. + unspecified, + /// Reserved, documentation, benchmarking, or broadcast. Never a service. + reserved, + /// Ordinary public address space. + global, + + pub fn text(self: Reach) []const u8 { + return switch (self) { + .loopback => "this machine", + .link_local => "this network link", + .private => "a private network", + .shared => "carrier-grade NAT space", + .multicast => "a multicast group", + .unspecified => "the unspecified address", + .reserved => "reserved address space", + .global => "the public internet", + }; + } + + /// Whether an address with this reach is on the machine doing the asking. + pub fn isThisMachine(self: Reach) bool { + return self == .loopback or self == .unspecified; + } +}; + +/// Where a connector said it lives, and therefore what its addresses must be. +pub const Expectation = enum { + /// A model running on this computer. Anything but loopback is wrong. + this_machine, + /// A provider on the internet. Anything but public address space is wrong. + the_internet, + + pub fn accepts(self: Expectation, reach: Reach) bool { + return switch (self) { + .this_machine => reach.isThisMachine(), + .the_internet => reach == .global, + }; + } +}; + +/// Why a connection was refused, or that it was not. +pub const Verdict = union(enum) { + allowed: struct { + /// How many addresses were checked. Zero never reaches here — a name + /// that resolves to nothing is refused rather than allowed by default, + /// which is the direction an empty set has to fall in a security check. + checked: usize, + }, + refused: Refusal, + + pub fn isAllowed(self: Verdict) bool { + return self == .allowed; + } +}; + +/// A refusal, in enough detail to be read by a person and recorded. +pub const Refusal = struct { + /// What the rule was written against. + host: []const u8, + /// The address that failed, rendered. Empty when the name resolved to + /// nothing at all. + address: []const u8, + reach: Reach, + expectation: Expectation, + + /// One sentence, for a person who is trying to find out what went wrong. + pub fn writeTo(self: Refusal, w: *std.Io.Writer) std.Io.Writer.Error!void { + if (self.address.len == 0) { + try w.print("{s} does not resolve to any address.", .{self.host}); + return; + } + switch (self.expectation) { + .this_machine => try w.print( + "{s} resolves to {s}, which is {s}. A model that was supposed to be on this computer is not.", + .{ self.host, self.address, self.reach.text() }, + ), + .the_internet => try w.print( + "{s} resolves to {s}, which is {s} rather than a public address. The connection was not made.", + .{ self.host, self.address, self.reach.text() }, + ), + } + } + + pub fn text(self: Refusal, arena: std.mem.Allocator) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + try self.writeTo(&out.writer); + return out.written(); + } +}; + +/// Classify an address. +/// +/// IPv4-mapped IPv6 addresses are unmapped first, so `::ffff:127.0.0.1` is +/// loopback and not a global unicast address. That single line is the +/// difference between a check and the appearance of one. +pub fn reachOf(address: net.IpAddress) Reach { + return switch (address) { + .ip4 => |v4| reachOfIp4(v4.bytes), + .ip6 => |v6| if (mappedIp4(v6.bytes)) |v4| reachOfIp4(v4) else reachOfIp6(v6.bytes), + }; +} + +/// The IPv4 address inside an IPv4-mapped or IPv4-compatible IPv6 address. +/// +/// Both forms are covered. The compatible form (`::a.b.c.d`) is deprecated and +/// still parses, so a checker that only unmaps the `::ffff:` form leaves the +/// other one open. +fn mappedIp4(bytes: [16]u8) ?[4]u8 { + const zeros = std.mem.allEqual(u8, bytes[0..10], 0); + if (!zeros) return null; + if (bytes[10] == 0xff and bytes[11] == 0xff) return bytes[12..16].*; + if (bytes[10] == 0 and bytes[11] == 0) { + // `::` and `::1` are their own addresses, not IPv4 in disguise. + const tail = std.mem.readInt(u32, bytes[12..16], .big); + if (tail > 1) return bytes[12..16].*; + } + return null; +} + +fn reachOfIp4(bytes: [4]u8) Reach { + return switch (bytes[0]) { + 0 => if (std.mem.allEqual(u8, &bytes, 0)) .unspecified else .reserved, + 10 => .private, + 127 => .loopback, + // 100.64.0.0/10, carrier-grade NAT. + 100 => if (bytes[1] >= 64 and bytes[1] <= 127) .shared else .global, + // 169.254.0.0/16, which is where every cloud metadata service answers. + 169 => if (bytes[1] == 254) .link_local else .global, + // 172.16.0.0/12. + 172 => if (bytes[1] >= 16 and bytes[1] <= 31) .private else .global, + 192 => switch (bytes[1]) { + 168 => .private, + // 192.0.0.0/24 protocol assignments and 192.0.2.0/24 documentation. + 0 => if (bytes[2] == 0 or bytes[2] == 2) .reserved else .global, + else => .global, + }, + // 198.18.0.0/15 benchmarking, 198.51.100.0/24 documentation. + 198 => if (bytes[1] == 18 or bytes[1] == 19 or (bytes[1] == 51 and bytes[2] == 100)) .reserved else .global, + // 203.0.113.0/24 documentation. + 203 => if (bytes[1] == 0 and bytes[2] == 113) .reserved else .global, + 224...239 => .multicast, + // 240.0.0.0/4 reserved, and 255.255.255.255 broadcast inside it. + 240...255 => .reserved, + else => .global, + }; +} + +fn reachOfIp6(bytes: [16]u8) Reach { + if (std.mem.allEqual(u8, &bytes, 0)) return .unspecified; + if (std.mem.allEqual(u8, bytes[0..15], 0) and bytes[15] == 1) return .loopback; + if (bytes[0] == 0xff) return .multicast; + // fe80::/10, link-local unicast. + if (bytes[0] == 0xfe and (bytes[1] & 0xc0) == 0x80) return .link_local; + // fc00::/7, unique local. Private address space by another name. + if ((bytes[0] & 0xfe) == 0xfc) return .private; + // 2001:db8::/32, reserved for documentation. + if (bytes[0] == 0x20 and bytes[1] == 0x01 and bytes[2] == 0x0d and bytes[3] == 0xb8) return .reserved; + // 2001:20::/28 ORCHIDv2 and 2001:db8 above are the two that matter; the + // rest of 2001::/23 is IETF protocol assignment space. + if (bytes[0] == 0x20 and bytes[1] == 0x01 and bytes[2] == 0 and (bytes[3] & 0xf8) == 0) return .reserved; + // 100::/64, the discard prefix. A packet sent here goes nowhere. + if (bytes[0] == 0x01 and bytes[1] == 0x00 and std.mem.allEqual(u8, bytes[2..8], 0)) return .reserved; + return .global; +} + +/// Check a set of already-resolved addresses. +/// +/// Pure, so that every address family and every boundary can be tested without +/// a nameserver. `verify` is the same check with the lookup in front of it. +/// +/// One bad address refuses the whole name. A client may try any address a name +/// resolves to, so allowing a name because *one* of its addresses was +/// acceptable would allow exactly the case worth stopping. +pub fn check( + arena: std.mem.Allocator, + host: []const u8, + expectation: Expectation, + addresses: []const net.IpAddress, +) !Verdict { + if (addresses.len == 0) { + return .{ .refused = .{ + .host = host, + .address = "", + .reach = .unspecified, + .expectation = expectation, + } }; + } + for (addresses) |address| { + const reach = reachOf(address); + if (expectation.accepts(reach)) continue; + return .{ .refused = .{ + .host = host, + .address = try render(arena, address), + .reach = reach, + .expectation = expectation, + } }; + } + return .{ .allowed = .{ .checked = addresses.len } }; +} + +/// An address as text, without its port. +pub fn render(arena: std.mem.Allocator, address: net.IpAddress) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + switch (address) { + .ip4 => |v4| try out.writer.print("{d}.{d}.{d}.{d}", .{ + v4.bytes[0], v4.bytes[1], v4.bytes[2], v4.bytes[3], + }), + .ip6 => |v6| { + if (mappedIp4(v6.bytes)) |v4| { + // Rendered as what it is, so a refusal names the address a + // person would recognise rather than its disguise. + try out.writer.print("{d}.{d}.{d}.{d} (written as IPv6)", .{ + v4[0], v4[1], v4[2], v4[3], + }); + } else { + try out.writer.writeByte('['); + var index: usize = 0; + while (index < 16) : (index += 2) { + if (index > 0) try out.writer.writeByte(':'); + try out.writer.print("{x}", .{std.mem.readInt(u16, v6.bytes[index..][0..2], .big)}); + } + try out.writer.writeByte(']'); + } + }, + } + return out.written(); +} + +/// The most addresses a single name may resolve to before the guard stops +/// reading them. +/// +/// A name with more answers than this is refused rather than partly checked: +/// the alternative is an unbounded read driven by whoever controls the +/// nameserver, and a partial check is not a check. +pub const max_addresses = 32; + +pub const Error = error{ + /// The host is not a name or an address this build can parse. + HostUnusable, + /// The name could not be looked up at all. + LookupFailed, + /// More answers than `max_addresses`. + TooManyAddresses, +} || std.mem.Allocator.Error || std.Io.Writer.Error; + +/// Resolve a host and check every address it answers with. +/// +/// A literal address is checked without any lookup at all, which is the case a +/// name-based allowlist has nothing to say about. +pub fn verify( + arena: std.mem.Allocator, + io: std.Io, + host: []const u8, + port: u16, + expectation: Expectation, +) Error!Verdict { + const name = hostWithoutPort(host); + if (name.len == 0) return error.HostUnusable; + + // A literal first. `https://169.254.169.254/` never reaches a nameserver, + // so a guard that only inspects lookups never sees it. + if (net.IpAddress.parse(name, port)) |literal| { + return check(arena, host, expectation, &.{literal}); + } else |_| {} + + const host_name = net.HostName.init(name) catch return error.HostUnusable; + + var buffer: [max_addresses + 4]net.HostName.LookupResult = undefined; + var queue: std.Io.Queue(net.HostName.LookupResult) = .init(&buffer); + var lookup = io.async(net.HostName.lookup, .{ host_name, io, &queue, net.HostName.LookupOptions{ .port = port } }); + defer lookup.cancel(io) catch {}; + + var addresses: std.ArrayList(net.IpAddress) = .empty; + while (queue.getOne(io)) |result| { + switch (result) { + .address => |address| { + if (addresses.items.len >= max_addresses) return error.TooManyAddresses; + try addresses.append(arena, address); + }, + // The canonical name is not what is connected to, so it is not what + // is checked. Checking it instead of the addresses is the mistake + // that makes a guard look like it works. + .canonical_name => {}, + } + } else |err| switch (err) { + error.Closed => {}, + error.Canceled => return error.LookupFailed, + } + + lookup.await(io) catch return error.LookupFailed; + return check(arena, host, expectation, addresses.items); +} + +/// The address check, ready to be held by whatever makes the connection. +/// +/// Carries its own `Io` because the check needs a nameserver and the transport +/// that holds it does not otherwise touch the operating system. Optional +/// everywhere it is held, so that a wire-format test need not resolve anything; +/// every path that reaches a real provider sets it. +pub const Guard = struct { + io: std.Io, + + pub fn verify( + self: Guard, + arena: std.mem.Allocator, + host: []const u8, + port: u16, + expectation: Expectation, + ) Error!Verdict { + return @import("netguard.zig").verify(arena, self.io, host, port, expectation); + } +}; + +/// Strip a port, leaving the host. +/// +/// The same rules as the policy engine's own: a bracketed address carries its +/// port outside the brackets, and a bare IPv6 address is nothing but colons. +/// Kept here rather than imported so that this module has no dependency on the +/// policy layer — it is checked against it by a test instead, which is the +/// relationship that would otherwise drift. +pub fn hostWithoutPort(host: []const u8) []const u8 { + if (host.len == 0) return host; + if (host[0] == '[') { + if (std.mem.indexOfScalar(u8, host, ']')) |close| return host[1..close]; + return host[1..]; + } + var colons: usize = 0; + for (host) |byte| { + if (byte == ':') colons += 1; + } + if (colons > 1) return host; + if (std.mem.indexOfScalar(u8, host, ':')) |colon| return host[0..colon]; + return host; +} + +const testing = std.testing; + +fn ip4(text: []const u8) net.IpAddress { + return net.IpAddress.parseIp4(text, 443) catch unreachable; +} + +fn ip6(text: []const u8) net.IpAddress { + return net.IpAddress.parseIp6(text, 443) catch unreachable; +} + +/// An IPv6 address built from its bytes. +/// +/// Needed because two of the forms that matter here do not go through a text +/// parser at all. Zig's own IPv6 parser rejects both `::ffff:a9fe:a9fe` — the +/// hexadecimal spelling of a mapped address — and the deprecated +/// `::169.254.169.254` compatible form. Neither of those refusals protects +/// anything, because a nameserver answers an AAAA query with sixteen bytes and +/// no text at all. Classification works on the bytes, so the test does too. +fn ip6Bytes(bytes: [16]u8) net.IpAddress { + return .{ .ip6 = .{ .bytes = bytes, .port = 443 } }; +} + +/// The IPv4-mapped form, `::ffff:a.b.c.d`, as bytes. +fn mapped(a: u8, b: u8, c: u8, d: u8) net.IpAddress { + return ip6Bytes(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, a, b, c, d }); +} + +/// The deprecated IPv4-compatible form, `::a.b.c.d`, as bytes. +fn compatible(a: u8, b: u8, c: u8, d: u8) net.IpAddress { + return ip6Bytes(.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, a, b, c, d }); +} + +test "the addresses a remote provider must never resolve to" { + // Each of these is a real attack rather than a category. The first is the + // cloud metadata service, which hands credentials to anything on the + // machine that asks; the rest are the machine's own network, reachable + // from inside it under any name an allowlist happened to accept. + const cases = [_]struct { address: net.IpAddress, reach: Reach }{ + .{ .address = ip4("169.254.169.254"), .reach = .link_local }, + .{ .address = ip4("127.0.0.1"), .reach = .loopback }, + .{ .address = ip4("127.1.2.3"), .reach = .loopback }, + .{ .address = ip4("0.0.0.0"), .reach = .unspecified }, + .{ .address = ip4("10.0.0.5"), .reach = .private }, + .{ .address = ip4("172.16.0.1"), .reach = .private }, + .{ .address = ip4("172.31.255.254"), .reach = .private }, + .{ .address = ip4("192.168.1.1"), .reach = .private }, + .{ .address = ip4("100.64.0.1"), .reach = .shared }, + .{ .address = ip4("224.0.0.1"), .reach = .multicast }, + .{ .address = ip4("255.255.255.255"), .reach = .reserved }, + .{ .address = ip4("192.0.2.1"), .reach = .reserved }, + .{ .address = ip4("198.18.0.1"), .reach = .reserved }, + .{ .address = ip4("203.0.113.7"), .reach = .reserved }, + .{ .address = ip6("::1"), .reach = .loopback }, + .{ .address = ip6("::"), .reach = .unspecified }, + .{ .address = ip6("fe80::1"), .reach = .link_local }, + .{ .address = ip6("fc00::1"), .reach = .private }, + .{ .address = ip6("fd12:3456::1"), .reach = .private }, + .{ .address = ip6("ff02::1"), .reach = .multicast }, + .{ .address = ip6("2001:db8::1"), .reach = .reserved }, + }; + + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + for (cases) |case| { + try testing.expectEqual(case.reach, reachOf(case.address)); + const verdict = try check(arena, "provider.example", .the_internet, &.{case.address}); + try testing.expect(!verdict.isAllowed()); + try testing.expectEqual(case.reach, verdict.refused.reach); + } +} + +test "the addresses a remote provider is allowed to resolve to" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Ordinary public addresses, including the ones next door to a reserved + // range. A classifier that used a whole /8 where the registry says a /24 + // would refuse real providers, and a refusal nobody can explain is how a + // security control gets switched off. + for ([_]net.IpAddress{ + ip4("160.79.104.10"), + ip4("8.8.8.8"), + ip4("100.63.255.255"), + ip4("100.128.0.1"), + ip4("169.253.0.1"), + ip4("169.255.0.1"), + ip4("172.15.0.1"), + ip4("172.32.0.1"), + ip4("192.0.1.1"), + ip4("192.0.3.1"), + ip4("192.167.0.1"), + ip4("192.169.0.1"), + ip4("198.20.0.1"), + ip4("198.51.101.1"), + ip4("203.0.114.1"), + ip6("2606:4700::1111"), + ip6("2001:db9::1"), + }) |address| { + try testing.expectEqual(Reach.global, reachOf(address)); + const verdict = try check(arena, "api.example.com", .the_internet, &.{address}); + try testing.expect(verdict.isAllowed()); + } +} + +test "an IPv4 address wearing an IPv6 costume is the same address" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // This is the bypass this kind of code is usually missing. A checker that + // reads IPv6 prefixes and stops calls every one of these a global unicast + // address and lets the connection through. + const disguised = [_]struct { address: net.IpAddress, reach: Reach }{ + // The mapped form, spelled the way a URL would carry it. + .{ .address = ip6("::ffff:169.254.169.254"), .reach = .link_local }, + .{ .address = ip6("::ffff:127.0.0.1"), .reach = .loopback }, + .{ .address = ip6("::ffff:10.0.0.5"), .reach = .private }, + .{ .address = ip6("::ffff:192.168.1.1"), .reach = .private }, + // The same addresses as bytes, which is how a AAAA answer arrives. The + // hexadecimal spelling `::ffff:a9fe:a9fe` has no text parser here at + // all, so a check written against text would never see it. + .{ .address = mapped(169, 254, 169, 254), .reach = .link_local }, + .{ .address = mapped(127, 0, 0, 1), .reach = .loopback }, + .{ .address = mapped(192, 168, 1, 1), .reach = .private }, + // The deprecated IPv4-compatible form. + .{ .address = compatible(169, 254, 169, 254), .reach = .link_local }, + .{ .address = compatible(10, 0, 0, 5), .reach = .private }, + }; + + for (disguised) |case| { + const address = case.address; + try testing.expectEqual(case.reach, reachOf(address)); + const verdict = try check(arena, "evil.example", .the_internet, &.{address}); + try testing.expect(!verdict.isAllowed()); + + // And the refusal names the address a person would recognise, rather + // than the form it arrived in. + try testing.expect(std.mem.indexOf(u8, verdict.refused.address, "written as IPv6") != null); + } + + // `::1` and `::` are their own addresses and must not be unmapped into + // 0.0.0.1 and 0.0.0.0, which would give the right answer for the wrong + // reason and the wrong answer as soon as the ranges moved. + try testing.expectEqual(Reach.loopback, reachOf(ip6("::1"))); + try testing.expectEqual(Reach.unspecified, reachOf(ip6("::"))); +} + +test "one bad address refuses the whole name" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A client may try any address a name answers with, so a name is only as + // safe as its worst answer. Checking the first one, or accepting the name + // because one answer was fine, allows exactly the case worth stopping. + const mixed = [_]net.IpAddress{ + ip4("160.79.104.10"), + ip4("8.8.8.8"), + ip4("169.254.169.254"), + ip4("1.1.1.1"), + }; + const verdict = try check(arena, "half.example", .the_internet, &mixed); + try testing.expect(!verdict.isAllowed()); + try testing.expectEqual(Reach.link_local, verdict.refused.reach); + try testing.expectEqualStrings("169.254.169.254", verdict.refused.address); +} + +test "a name that resolves to nothing is refused, not allowed by default" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The direction an empty set has to fall in a security check. A loop over + // zero addresses that ends in "allowed" is the most common way a guard + // like this fails open. + const verdict = try check(arena, "nowhere.example", .the_internet, &.{}); + try testing.expect(!verdict.isAllowed()); + const message = try verdict.refused.text(arena); + try testing.expect(std.mem.indexOf(u8, message, "does not resolve") != null); +} + +test "a local model must be local, and the check runs the other way" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Ollama on this laptop. + for ([_]net.IpAddress{ ip4("127.0.0.1"), ip6("::1"), ip4("0.0.0.0") }) |address| { + try testing.expect((try check(arena, "localhost:11434", .this_machine, &.{address})).isAllowed()); + } + + // The same name, answering with a public address. A workspace that thinks + // it is talking to its own machine and is not is sending its prompts to a + // stranger, which is the worse of the two directions. + const stolen = try check(arena, "localhost:11434", .this_machine, &.{ip4("160.79.104.10")}); + try testing.expect(!stolen.isAllowed()); + const message = try stolen.refused.text(arena); + try testing.expect(std.mem.indexOf(u8, message, "supposed to be on this computer") != null); + + // A private address is not this machine either. It is somebody else's. + try testing.expect(!(try check(arena, "localhost", .this_machine, &.{ip4("192.168.1.50")})).isAllowed()); +} + +test "an expectation accepts exactly one class of address" { + // Written as a table over every reach so that adding a new one has to + // decide, in both directions, rather than falling through a default. + for (std.enums.values(Reach)) |reach| { + const local = Expectation.this_machine.accepts(reach); + const remote = Expectation.the_internet.accepts(reach); + // No reach is acceptable to both. That would mean an address that is + // simultaneously this machine and the public internet. + try testing.expect(!(local and remote)); + switch (reach) { + .loopback, .unspecified => try testing.expect(local), + .global => try testing.expect(remote), + else => try testing.expect(!local and !remote), + } + } +} + +test "a literal address is checked without any lookup" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + // The case a host allowlist written in names has nothing to say about. + // There is no nameserver in this test, and there does not need to be. + const metadata = try verify(arena, io, "169.254.169.254", 80, .the_internet); + try testing.expect(!metadata.isAllowed()); + try testing.expectEqual(Reach.link_local, metadata.refused.reach); + + const loopback = try verify(arena, io, "127.0.0.1:8080", 8080, .the_internet); + try testing.expect(!loopback.isAllowed()); + try testing.expectEqual(Reach.loopback, loopback.refused.reach); + + // Bracketed IPv6 with a port, which is where a hand-rolled parser usually + // gives up and passes the whole string to a resolver. + const bracketed = try verify(arena, io, "[::ffff:169.254.169.254]:443", 443, .the_internet); + try testing.expect(!bracketed.isAllowed()); + try testing.expectEqual(Reach.link_local, bracketed.refused.reach); + + // And the same literal is fine for a local model. + try testing.expect((try verify(arena, io, "127.0.0.1:11434", 11434, .this_machine)).isAllowed()); +} + +test "the port is stripped the same way the policy engine strips it" { + // Two definitions of "the host part" that disagree is the defect this + // repository already found once, between the policy and the executor. The + // two live in different modules on purpose; this is what stops them + // drifting apart again. + const policy = @import("../ai/policy.zig"); + for ([_][]const u8{ + "api.example.com", + "api.example.com:443", + "localhost:11434", + "::1", + "[::1]:8080", + "[2001:db8::1]:443", + "127.0.0.1:80", + "", + }) |host| { + try testing.expectEqualStrings(policy.hostWithoutPort(host), hostWithoutPort(host)); + } +} + +test "a refusal says what happened in words a person can act on" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const verdict = try check(arena, "api.provider.example", .the_internet, &.{ip4("169.254.169.254")}); + const message = try verdict.refused.text(arena); + + // Names the host, the address and what is wrong with it. A message that + // only said "refused" would send somebody to read this file. + try testing.expect(std.mem.indexOf(u8, message, "api.provider.example") != null); + try testing.expect(std.mem.indexOf(u8, message, "169.254.169.254") != null); + try testing.expect(std.mem.indexOf(u8, message, "this network link") != null); +} + +test "every reach names itself, and no two share a name" { + for (std.enums.values(Reach)) |reach| { + try testing.expect(reach.text().len > 0); + for (std.enums.values(Reach)) |other| { + if (reach == other) continue; + try testing.expect(!std.mem.eql(u8, reach.text(), other.text())); + } + } +} From ee506b39ced798904f88efa868c73a725ef84b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:23:53 +0000 Subject: [PATCH 09/12] Read a model's answer as it arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every connector waited for the whole reply. That is fine for a question and wrong for everything else: a model writing four thousand tokens holds the terminal silent for the minute it takes, and a person watching has no way to tell a long answer from a hung one. The awkward part is not the protocol. It is that bytes arrive in whatever sizes the network chose, and those sizes have nothing to do with where the messages are. A read can end in the middle of `data:`, between the two newlines that terminate an event, between the `\r` and the `\n` of one line ending. A parser that assumes each read holds whole events works perfectly against a fast local model and corrupts answers over a real connection, intermittently, in a way that looks like the provider's fault. So src/ai/stream.zig is two layers kept apart. `Framer` turns bytes into frames and knows nothing about models; it handles Server-Sent Events and newline-delimited JSON, which is what the five connectors use. `interpret` turns a frame into provider-neutral deltas for the Anthropic, OpenAI, Gemini and Ollama stream shapes. `Assembler` folds those back into exactly the `provider.Completion` the blocking path returns, so a caller that does not want progressive rendering gets the type it already handles — and so the tests can assert the two paths agree. The test that matters feeds the same stream at every chunk size from one byte upward and asserts the answer is identical each time. It found a real bug: frames pointed into the framer's carry-over buffer, which the next feed moves down and appends to, so a frame handed back stayed valid until the caller read more bytes — the one thing every caller does next. Only fragmented reads expose it. Several smaller things that are each a bug if missed: a `: ping` comment is not an event; several `data:` lines in one event are one payload joined by newlines; exactly one space after the colon belongs to the framing and a second belongs to the model; `[DONE]` is an ending and not a parse failure; a stream that ends without a final blank line has still sent its last event, which is where the stop reason and the usage live; usage is replaced rather than summed, because every provider sends a running total and adding them bills the same tokens as many times as the stream mentioned them; a tool called with no arguments is called with `{}` and not with the empty string, which would turn a valid call into a parse failure one layer up. The transport gained a second door, so the gate was made one function that both doors call. `send` and `sendStreaming` share `gate` — the four decisions and the redaction — because adding a second way to send with one decision missing is how a gate stops being a gate. There is a test that a policy refusing the model refuses it whichever way it is asked, and a test that a credential in a streamed prompt is redacted the same as in a blocking one. `StreamSink` is a `std.Io.Writer` whose drain forwards straight into the reader and keeps nothing, so no complete copy of the answer exists anywhere. A transport with no streaming sender returns `StreamingUnavailable` rather than quietly waiting for the whole reply — a caller that asked to watch an answer being written should be told when that is not going to happen. `zag ask --stream` prints the answer as it arrives, flushing each piece. A loop test runs the same answer through both paths and asserts the transcripts match, down to the token counts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- docs/roadmap.md | 6 +- evidence/conformance.jsonl | 34 +- src/ai/loop.zig | 111 +++- src/ai/stream.zig | 1150 ++++++++++++++++++++++++++++++++++++ src/ai/transport.zig | 437 +++++++++++++- src/cli/main.zig | 28 +- src/root.zig | 1 + 7 files changed, 1719 insertions(+), 48 deletions(-) create mode 100644 src/ai/stream.zig diff --git a/docs/roadmap.md b/docs/roadmap.md index 12e8516..a90a16f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -201,8 +201,10 @@ editing Zig. document and diagnostic models. - Implement the daemon interface described by the generated OpenAPI document, with authentication, authorisation, version negotiation and backpressure. -- Read a model's answer as it arrives. Every connector waits for the whole - reply, which is fine for a question and wrong for a long one. +- Show a streamed answer in the graphical and daemon surfaces. `zag ask + --stream` reads a model's answer as it arrives, for all five wire formats; + the desktop renderer and the daemon interface do not exist yet and will need + the same treatment. - Run more than one tool call at a time. A turn that asks for four independent reads runs them one after another. - Add macOS pseudoterminal and Windows ConPTY implementations, and the terminal diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index da2be65..9aca8e2 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:b561402c94639b930c3305a00003dfbcb45cdafcacb5f9f95666fcea5ac79747","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:09:01.001022894Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/ai/loop.zig b/src/ai/loop.zig index 9b69ade..be949cf 100644 --- a/src/ai/loop.zig +++ b/src/ai/loop.zig @@ -54,6 +54,7 @@ const std = @import("std"); const provider = @import("provider.zig"); const catalog = @import("catalog.zig"); const transport_mod = @import("transport.zig"); +const stream_mod = @import("stream.zig"); const toolschema = @import("toolschema.zig"); const executor_mod = @import("executor.zig"); const policy_mod = @import("policy.zig"); @@ -333,6 +334,24 @@ pub const Journal = struct { } }; +/// Somewhere to show an answer while it is still being written. +pub const Watch = struct { + context: ?*anyopaque = null, + /// Text only. A caller that wants tool arguments and reasoning as they + /// arrive uses the transport directly; this is the shape a terminal needs. + textFn: *const fn (context: ?*anyopaque, chunk: []const u8) anyerror!void, + + /// Bridges to the transport's delta callback, so that only text reaches + /// the caller and a failure to print never becomes a failure to answer. + fn onDelta(context: ?*anyopaque, delta: stream_mod.Delta) anyerror!void { + const self: *const Watch = @ptrCast(@alignCast(context orelse return)); + switch (delta) { + .text => |t| self.textFn(self.context, t.chunk) catch {}, + else => {}, + } + } +}; + pub const Runner = struct { arena: std.mem.Allocator, transport: transport_mod.Transport, @@ -341,6 +360,15 @@ pub const Runner = struct { clock: Clock = stopped_clock, /// Where this run is written down. None means nothing is written. journal: ?Journal = null, + /// Called with each piece of the answer as it arrives, when it is asked + /// for and the transport can read one. + /// + /// This is the only reason streaming exists at this level: a run that takes + /// a minute should not look identical to a run that has hung. The + /// transcript and the record are unchanged either way — the deltas are + /// shown, not stored, because the completion they assemble into is what the + /// log already holds. + watch: ?Watch = null, fn note(self: Runner, moment: Moment) void { if (self.journal) |journal| journal.record(moment); @@ -381,7 +409,7 @@ pub const Runner = struct { var number: usize = 1; while (number <= options.budget.turns) : (number += 1) { var attempt: transport_mod.Attempt = undefined; - const completion = self.transport.send(options.connector, .{ + const asked: provider.Request = .{ .model = options.model, .system = options.system, .messages = messages.items, @@ -389,7 +417,21 @@ pub const Runner = struct { .maxOutputTokens = options.maxOutputTokens, .effort = options.effort, .showThinking = options.showThinking, - }, context, &attempt) catch |err| { + }; + // Watched or not, the same gate and the same completion. Streaming + // changes when a person sees the answer, never what it is. + var watch = self.watch; + const completion = (if (watch != null and self.transport.streaming != null) + self.transport.sendStreaming( + options.connector, + asked, + context, + &attempt, + Watch.onDelta, + &watch.?, + ) + else + self.transport.send(options.connector, asked, context, &attempt)) catch |err| { try turns.append(self.arena, .{ .number = number, .attempt = attempt }); ending = switch (err) { error.NeedsApproval => .waiting_for_a_person, @@ -398,7 +440,7 @@ pub const Runner = struct { // stops the run the same way a policy denial does, because // that is what it is. error.NotPermitted, error.CredentialMissing, error.AddressRefused => .stopped_by_policy, - error.ProviderRejected, error.SendFailed => .provider_unavailable, + error.ProviderRejected, error.SendFailed, error.StreamingUnavailable => .provider_unavailable, error.OutOfMemory => return error.OutOfMemory, }; break; @@ -1270,3 +1312,66 @@ test "a refusal from the executor is not reported as the policy refusing" { try testing.expect(std.mem.indexOf(u8, shell.reply, "policy did not allow") == null); try testing.expect(std.mem.indexOf(u8, program.reply, "policy did not allow") == null); } + +test "watching a run changes when the answer is seen, never what it is" { + // The same answer, delivered two ways: as one object, and as the stream of + // objects Ollama actually sends. The transcripts must not differ. A + // streaming path that returns something slightly different from the + // blocking one surfaces months later as "the model behaves differently + // when you watch it". + var quiet = try Fixture.init(&allow_models_and_reading, &.{}); + defer quiet.deinit(); + quiet.recorded.responses = &.{try textResponse(quiet.arena(), "The tests pass.")}; + const unwatched = try quiet.runner(stopped_clock).run(quiet.options(), "Do the tests pass?", quiet.context()); + try testing.expectEqualStrings("The tests pass.", unwatched.answer); + + const streamed_body = + "{\"model\":\"test-model\",\"message\":{\"role\":\"assistant\",\"content\":\"The tests \"},\"done\":false}\n" ++ + "{\"model\":\"test-model\",\"message\":{\"role\":\"assistant\",\"content\":\"pass.\"},\"done\":false}\n" ++ + "{\"model\":\"test-model\",\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\"done_reason\":\"stop\",\"prompt_eval_count\":8,\"eval_count\":4}\n"; + + var loud = try Fixture.init(&allow_models_and_reading, &.{.{ .status = 200, .body = streamed_body }}); + defer loud.deinit(); + + const Seen = struct { + var chunks: usize = 0; + var text: std.ArrayList(u8) = .empty; + var arena: std.mem.Allocator = undefined; + fn take(_: ?*anyopaque, chunk: []const u8) anyerror!void { + chunks += 1; + try text.appendSlice(arena, chunk); + } + }; + Seen.chunks = 0; + Seen.text = .empty; + Seen.arena = loud.arena(); + + var watched_runner = loud.runner(stopped_clock); + watched_runner.transport.streaming = loud.recorded.streamingSender(); + watched_runner.watch = .{ .textFn = Seen.take }; + const watched = try watched_runner.run(loud.options(), "Do the tests pass?", loud.context()); + + // The answer was seen in more than one piece while it was being written, + // which is the whole feature. + try testing.expect(Seen.chunks >= 2); + try testing.expectEqualStrings("The tests pass.", Seen.text.items); + + // And both runs produced the same transcript. + try testing.expectEqualStrings(unwatched.answer, watched.answer); + try testing.expectEqual(unwatched.ending, watched.ending); + try testing.expectEqual(unwatched.turns.len, watched.turns.len); + try testing.expectEqual(unwatched.usage.inputTokens, watched.usage.inputTokens); + try testing.expectEqual(unwatched.usage.outputTokens, watched.usage.outputTokens); +} + +test "a run with no watcher never asks for a stream" { + // The flag is set by the runner, not by the caller, so a run nobody is + // watching must not ask for a framing it will not read. + var fixture = try Fixture.init(&allow_models_and_reading, &.{}); + defer fixture.deinit(); + fixture.recorded.responses = &.{try textResponse(fixture.arena(), "Yes.")}; + + _ = try fixture.runner(stopped_clock).run(fixture.options(), "Do they?", fixture.context()); + const body = fixture.recorded.seen.items[0].body; + try testing.expect(std.mem.indexOf(u8, body, "\"stream\":false") != null); +} diff --git a/src/ai/stream.zig b/src/ai/stream.zig new file mode 100644 index 0000000..6c156b1 --- /dev/null +++ b/src/ai/stream.zig @@ -0,0 +1,1150 @@ +//! Reading an answer while it is still being written. +//! +//! Every connector in this build waits for the whole reply before it returns +//! anything. That is correct for a question with a short answer and wrong for +//! everything else: a model writing four thousand tokens holds the terminal +//! silent for the whole minute it takes, and a person watching has no way to +//! tell a long answer from a hung one. +//! +//! The awkward part of streaming is not the protocol. It is that bytes arrive +//! in whatever sizes the network chose, and those sizes have nothing to do with +//! where the messages are. A read can end in the middle of `data:`, between the +//! two newlines that terminate an event, between the `\r` and the `\n` of one +//! line ending, or halfway through a UTF-8 sequence. A parser that assumes each +//! read holds whole events works perfectly on a fast local model and corrupts +//! answers over a real connection, intermittently, in a way that looks like the +//! provider's fault. +//! +//! So this file is two layers, and they are kept apart on purpose. +//! +//! ## Framing +//! +//! `Framer` turns a byte stream into complete frames and nothing else. It knows +//! about Server-Sent Events and about newline-delimited JSON, because those are +//! the two framings the providers in the catalogue use, and it knows nothing at +//! all about models. Bytes that do not yet complete a frame are held, and the +//! next feed continues from them. +//! +//! The property that matters is that framing is independent of how the bytes +//! were divided. There is a test that feeds the same stream one byte at a time +//! and asserts the frames are identical to feeding it whole, which is the only +//! form of this test worth writing. +//! +//! ## Interpretation +//! +//! `interpret` turns one frame into zero or more `Delta` values, which are +//! provider-neutral. Anthropic sends typed events with content-block indices; +//! OpenAI sends choice deltas and a `[DONE]` sentinel; Ollama sends one JSON +//! object per line with a `done` flag. Those three shapes become the same +//! sequence of deltas, so everything above this file is written once. +//! +//! ## The answer is the same answer +//! +//! `Assembler` folds deltas back into the `provider.Completion` that the +//! non-streaming path returns. That is deliberate: a caller that does not want +//! to render progressively gets exactly the type it already handles, and the +//! test suite can assert that streaming a response and decoding it whole +//! produce the same completion. A streaming path that quietly returns something +//! slightly different from the blocking path is a bug that surfaces months +//! later as "the model behaves differently when you watch it". + +const std = @import("std"); +const provider = @import("provider.zig"); +const catalog = @import("catalog.zig"); + +/// How a provider divides its stream. +pub const Framing = enum { + /// `text/event-stream`: fields, one per line, and a blank line between + /// events. Anthropic, OpenAI, Gemini and Hugging Face. + server_sent_events, + /// One JSON object per line. Ollama. + json_lines, + + pub fn of(format: catalog.Format) Framing { + return switch (format) { + .anthropic, .openai, .gemini, .huggingface => .server_sent_events, + .ollama => .json_lines, + }; + } +}; + +/// One complete message from the stream. +pub const Frame = struct { + /// The SSE `event:` field, empty for a framing that has none. + name: []const u8 = "", + /// The `data:` payload, with continuation lines joined by newlines, or the + /// whole line for JSON-lines framing. + data: []const u8, +}; + +/// Turns a byte stream into frames, however the bytes were divided. +/// +/// Feed it whatever arrives. It returns the frames that are now complete and +/// keeps the rest for next time. +pub const Framer = struct { + framing: Framing, + /// Bytes that did not complete a frame. Carried to the next feed. + pending: std.ArrayList(u8) = .empty, + /// Set once the stream said it was finished. Frames after this are ignored, + /// because a provider that keeps talking after `[DONE]` is not something to + /// interpret. + finished: bool = false, + + /// The most bytes a single unfinished frame may occupy. + /// + /// A frame that never terminates is an unbounded read driven by whoever is + /// on the other end of the socket. Exceeding this is an error rather than a + /// truncation, because half a JSON object parses as nothing useful and + /// silently dropping it would lose part of an answer. + pub const max_frame_bytes = 8 << 20; + + pub const Error = error{FrameTooLarge} || std.mem.Allocator.Error; + + pub fn init(framing: Framing) Framer { + return .{ .framing = framing }; + } + + /// Add bytes and return every frame they completed. + /// + /// The returned frames point into `arena`, and stay valid for as long as it + /// does. An empty chunk is legal and returns nothing, which is what a + /// zero-length read means. + pub fn feed(self: *Framer, arena: std.mem.Allocator, chunk: []const u8) Error![]const Frame { + var out: std.ArrayList(Frame) = .empty; + if (self.finished) return out.items; + try self.pending.appendSlice(arena, chunk); + if (self.pending.items.len > max_frame_bytes) return error.FrameTooLarge; + + var consumed: usize = 0; + while (self.nextBoundary(consumed)) |boundary| { + const block = self.pending.items[consumed..boundary.start]; + consumed = boundary.end; + const frame = switch (self.framing) { + .server_sent_events => try parseEvent(arena, block), + .json_lines => blk: { + const trimmed = std.mem.trim(u8, block, " \t\r"); + // Copied out of `pending`, which the next feed moves down + // and appends to. A frame handed back pointing into that + // buffer is valid until the caller reads more bytes, which + // is the one thing every caller does next. + break :blk if (trimmed.len == 0) null else Frame{ .data = try arena.dupe(u8, trimmed) }; + }, + } orelse continue; + try out.append(arena, frame); + } + + // Keep what is left. Copied down rather than reallocated, so a long + // stream of small frames does not grow the buffer without bound. + if (consumed > 0) { + const remaining = self.pending.items.len - consumed; + std.mem.copyForwards(u8, self.pending.items[0..remaining], self.pending.items[consumed..]); + self.pending.shrinkRetainingCapacity(remaining); + } + return out.items; + } + + /// Whatever is left over when the stream ends. + /// + /// A provider that closes the connection without a final blank line has + /// still sent a complete event, and dropping it loses the end of the + /// answer. Called once, after the last read. + pub fn finish(self: *Framer, arena: std.mem.Allocator) Error![]const Frame { + var out: std.ArrayList(Frame) = .empty; + const block = std.mem.trim(u8, self.pending.items, " \t\r\n"); + if (block.len == 0) return out.items; + const frame = switch (self.framing) { + .server_sent_events => try parseEvent(arena, self.pending.items), + .json_lines => Frame{ .data = try arena.dupe(u8, block) }, + }; + if (frame) |complete| try out.append(arena, complete); + self.pending.clearRetainingCapacity(); + return out.items; + } + + const Boundary = struct { start: usize, end: usize }; + + /// Where the frame beginning at `from` ends, and where the next begins. + /// + /// For JSON lines that is a newline. For events it is a blank line, which + /// may be written four different ways depending on the line endings, and a + /// parser that only looks for one of them stalls for ever on a provider + /// that uses another. + fn nextBoundary(self: *Framer, from: usize) ?Boundary { + const bytes = self.pending.items; + if (from >= bytes.len) return null; + switch (self.framing) { + .json_lines => { + const at = std.mem.indexOfScalarPos(u8, bytes, from, '\n') orelse return null; + return .{ .start = at, .end = at + 1 }; + }, + .server_sent_events => { + var index = from; + while (index < bytes.len) : (index += 1) { + if (bytes[index] != '\n') continue; + // "\n\n" + if (index + 1 < bytes.len and bytes[index + 1] == '\n') { + return .{ .start = index, .end = index + 2 }; + } + // "\n\r\n" + if (index + 2 < bytes.len and bytes[index + 1] == '\r' and bytes[index + 2] == '\n') { + return .{ .start = index, .end = index + 3 }; + } + // A lone "\n" at the very end may yet become "\n\n" when the + // next chunk arrives, so it is not a boundary until more + // bytes say otherwise. This is the split that breaks a + // parser written against whole responses. + if (index + 1 >= bytes.len) return null; + if (bytes[index + 1] == '\r' and index + 2 >= bytes.len) return null; + } + return null; + }, + } + } +}; + +/// Parse one Server-Sent Events block into a frame. +/// +/// Returns null for a block that carries no data: a comment, which providers +/// send as a heartbeat, or a block of fields this build has no use for. +fn parseEvent(arena: std.mem.Allocator, block: []const u8) !?Frame { + var name: []const u8 = ""; + var data: std.ArrayList(u8) = .empty; + var any_data = false; + + var lines = std.mem.splitScalar(u8, block, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + if (line.len == 0) continue; + // A line beginning with a colon is a comment. Anthropic sends `: ping` + // to keep a connection open, and a parser that treats it as a field + // with an empty name will try to interpret it. + if (line[0] == ':') continue; + + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const field = line[0..colon]; + var value = line[colon + 1 ..]; + // Exactly one leading space is part of the framing, not of the value. + if (value.len > 0 and value[0] == ' ') value = value[1..]; + + if (std.mem.eql(u8, field, "event")) { + // Copied for the same reason the payload is: `block` points into + // the framer's carry-over buffer, and the next feed moves it. + name = try arena.dupe(u8, value); + } else if (std.mem.eql(u8, field, "data")) { + // Several data lines in one event are one payload joined by + // newlines. Providers use this for anything containing a newline. + if (any_data) try data.append(arena, '\n'); + try data.appendSlice(arena, value); + any_data = true; + } + // `id` and `retry` are about reconnection, which this build does not do. + } + + if (!any_data) return null; + return .{ .name = name, .data = data.items }; +} + +/// One thing that happened, in terms this workbench understands. +/// +/// Deliberately finer than a message: a caller rendering progress needs to know +/// that text arrived without waiting to be told which block it belonged to, and +/// a caller assembling a completion needs both. +pub const Delta = union(enum) { + /// The provider named the model that is answering. It may not be the model + /// that was asked for, and the record has to say which ran. + started: struct { model: []const u8 }, + /// A new content block. `index` is the provider's own, so that deltas + /// arriving out of order still land in the right place. + block_opened: struct { index: usize, kind: BlockKind, id: []const u8 = "", name: []const u8 = "" }, + /// More text for the block at `index`. + text: struct { index: usize, chunk: []const u8 }, + /// More reasoning for the block at `index`. + thinking: struct { index: usize, chunk: []const u8 }, + /// More of a tool call's arguments, as partial JSON. Never parsed until the + /// block closes, because half a JSON object is not a smaller JSON object. + tool_arguments: struct { index: usize, chunk: []const u8 }, + block_closed: struct { index: usize }, + usage: provider.Usage, + stopped: struct { reason: provider.StopReason }, + /// The provider said something went wrong mid-stream. Its own words. + provider_error: struct { message: []const u8 }, + /// The stream said it was over. Not the same as `stopped`: one is the + /// model finishing a turn, the other is the connection finishing. + ended, + + pub const BlockKind = enum { text, thinking, tool_use }; +}; + +/// Turn one frame into deltas. +/// +/// Returns nothing for a frame this build has no use for, which is most of +/// them: providers send pings, block indices this build already has, and +/// bookkeeping about their own queues. +pub fn interpret( + arena: std.mem.Allocator, + format: catalog.Format, + frame: Frame, +) ![]const Delta { + return switch (format) { + .anthropic => interpretAnthropic(arena, frame), + .openai, .huggingface => interpretOpenAi(arena, frame), + .gemini => interpretGemini(arena, frame), + .ollama => interpretOllama(arena, frame), + }; +} + +fn objectOf(arena: std.mem.Allocator, data: []const u8) ?std.json.ObjectMap { + const parsed = std.json.parseFromSlice(std.json.Value, arena, data, .{}) catch return null; + return switch (parsed.value) { + .object => |o| o, + else => null, + }; +} + +fn stringAt(object: std.json.ObjectMap, key: []const u8) ?[]const u8 { + return switch (object.get(key) orelse return null) { + .string => |s| s, + else => null, + }; +} + +fn integerAt(object: std.json.ObjectMap, key: []const u8) ?u64 { + return switch (object.get(key) orelse return null) { + .integer => |i| if (i < 0) null else @intCast(i), + else => null, + }; +} + +fn objectAt(object: std.json.ObjectMap, key: []const u8) ?std.json.ObjectMap { + return switch (object.get(key) orelse return null) { + .object => |o| o, + else => null, + }; +} + +fn arrayAt(object: std.json.ObjectMap, key: []const u8) ?std.json.Array { + return switch (object.get(key) orelse return null) { + .array => |a| a, + else => null, + }; +} + +fn interpretAnthropic(arena: std.mem.Allocator, frame: Frame) ![]const Delta { + var out: std.ArrayList(Delta) = .empty; + const root = objectOf(arena, frame.data) orelse return out.items; + // The event name is on the SSE frame and repeated in the body. The body is + // used, so a proxy that drops the `event:` field does not silently turn + // every frame into nothing. + const kind = stringAt(root, "type") orelse frame.name; + + if (std.mem.eql(u8, kind, "message_start")) { + const message = objectAt(root, "message") orelse return out.items; + try out.append(arena, .{ .started = .{ .model = stringAt(message, "model") orelse "" } }); + if (objectAt(message, "usage")) |usage| try out.append(arena, .{ .usage = readAnthropicUsage(usage) }); + } else if (std.mem.eql(u8, kind, "content_block_start")) { + const index = integerAt(root, "index") orelse 0; + const block = objectAt(root, "content_block") orelse return out.items; + const block_type = stringAt(block, "type") orelse "text"; + if (std.mem.eql(u8, block_type, "tool_use")) { + try out.append(arena, .{ .block_opened = .{ + .index = index, + .kind = .tool_use, + .id = stringAt(block, "id") orelse "", + .name = stringAt(block, "name") orelse "", + } }); + } else if (std.mem.eql(u8, block_type, "thinking")) { + try out.append(arena, .{ .block_opened = .{ .index = index, .kind = .thinking } }); + } else { + try out.append(arena, .{ .block_opened = .{ .index = index, .kind = .text } }); + } + } else if (std.mem.eql(u8, kind, "content_block_delta")) { + const index = integerAt(root, "index") orelse 0; + const delta = objectAt(root, "delta") orelse return out.items; + const delta_type = stringAt(delta, "type") orelse ""; + if (std.mem.eql(u8, delta_type, "text_delta")) { + try out.append(arena, .{ .text = .{ .index = index, .chunk = stringAt(delta, "text") orelse "" } }); + } else if (std.mem.eql(u8, delta_type, "thinking_delta")) { + try out.append(arena, .{ .thinking = .{ .index = index, .chunk = stringAt(delta, "thinking") orelse "" } }); + } else if (std.mem.eql(u8, delta_type, "input_json_delta")) { + try out.append(arena, .{ .tool_arguments = .{ + .index = index, + .chunk = stringAt(delta, "partial_json") orelse "", + } }); + } + } else if (std.mem.eql(u8, kind, "content_block_stop")) { + try out.append(arena, .{ .block_closed = .{ .index = integerAt(root, "index") orelse 0 } }); + } else if (std.mem.eql(u8, kind, "message_delta")) { + if (objectAt(root, "delta")) |delta| { + if (stringAt(delta, "stop_reason")) |reason| { + try out.append(arena, .{ .stopped = .{ .reason = anthropicStopReason(reason) } }); + } + } + if (objectAt(root, "usage")) |usage| try out.append(arena, .{ .usage = readAnthropicUsage(usage) }); + } else if (std.mem.eql(u8, kind, "message_stop")) { + try out.append(arena, .ended); + } else if (std.mem.eql(u8, kind, "error")) { + const detail = objectAt(root, "error"); + try out.append(arena, .{ .provider_error = .{ + .message = if (detail) |d| stringAt(d, "message") orelse "The provider reported an error." else "The provider reported an error.", + } }); + } + return out.items; +} + +fn readAnthropicUsage(usage: std.json.ObjectMap) provider.Usage { + return .{ + .inputTokens = integerAt(usage, "input_tokens") orelse 0, + .outputTokens = integerAt(usage, "output_tokens") orelse 0, + .cacheReadTokens = integerAt(usage, "cache_read_input_tokens") orelse 0, + .cacheWriteTokens = integerAt(usage, "cache_creation_input_tokens") orelse 0, + }; +} + +fn anthropicStopReason(text: []const u8) provider.StopReason { + if (std.mem.eql(u8, text, "end_turn")) return .end_turn; + if (std.mem.eql(u8, text, "tool_use")) return .tool_use; + if (std.mem.eql(u8, text, "max_tokens")) return .max_tokens; + if (std.mem.eql(u8, text, "refusal")) return .refusal; + if (std.mem.eql(u8, text, "stop_sequence")) return .stop_sequence; + return .unknown; +} + +fn interpretOpenAi(arena: std.mem.Allocator, frame: Frame) ![]const Delta { + var out: std.ArrayList(Delta) = .empty; + // The sentinel. It is not JSON, and a parser that hands it to a JSON reader + // gets an error where it should get an ending. + if (std.mem.eql(u8, std.mem.trim(u8, frame.data, " \t"), "[DONE]")) { + try out.append(arena, .ended); + return out.items; + } + const root = objectOf(arena, frame.data) orelse return out.items; + + if (objectAt(root, "error")) |detail| { + try out.append(arena, .{ .provider_error = .{ + .message = stringAt(detail, "message") orelse "The provider reported an error.", + } }); + return out.items; + } + if (stringAt(root, "model")) |model| { + try out.append(arena, .{ .started = .{ .model = model } }); + } + + const choices = arrayAt(root, "choices") orelse { + // A usage-only frame, which is what `stream_options.include_usage` + // produces after the last choice. + if (objectAt(root, "usage")) |usage| try out.append(arena, .{ .usage = readOpenAiUsage(usage) }); + return out.items; + }; + for (choices.items) |item| { + const choice = switch (item) { + .object => |o| o, + else => continue, + }; + if (objectAt(choice, "delta")) |delta| { + if (stringAt(delta, "content")) |text| { + if (text.len > 0) try out.append(arena, .{ .text = .{ .index = 0, .chunk = text } }); + } + if (stringAt(delta, "reasoning_content")) |thought| { + if (thought.len > 0) try out.append(arena, .{ .thinking = .{ .index = 0, .chunk = thought } }); + } + if (arrayAt(delta, "tool_calls")) |calls| { + for (calls.items) |call_value| { + const call = switch (call_value) { + .object => |o| o, + else => continue, + }; + // Index within the tool-call list, offset past the text + // block so the two never collide in the assembler. + const index = 1 + (integerAt(call, "index") orelse 0); + if (stringAt(call, "id")) |id| { + const function = objectAt(call, "function"); + try out.append(arena, .{ .block_opened = .{ + .index = index, + .kind = .tool_use, + .id = id, + .name = if (function) |f| stringAt(f, "name") orelse "" else "", + } }); + } + if (objectAt(call, "function")) |function| { + if (stringAt(function, "arguments")) |args| { + if (args.len > 0) try out.append(arena, .{ .tool_arguments = .{ .index = index, .chunk = args } }); + } + } + } + } + } + if (stringAt(choice, "finish_reason")) |reason| { + try out.append(arena, .{ .stopped = .{ .reason = openAiStopReason(reason) } }); + } + } + if (objectAt(root, "usage")) |usage| try out.append(arena, .{ .usage = readOpenAiUsage(usage) }); + return out.items; +} + +fn readOpenAiUsage(usage: std.json.ObjectMap) provider.Usage { + return .{ + .inputTokens = integerAt(usage, "prompt_tokens") orelse 0, + .outputTokens = integerAt(usage, "completion_tokens") orelse 0, + }; +} + +fn openAiStopReason(text: []const u8) provider.StopReason { + if (std.mem.eql(u8, text, "stop")) return .end_turn; + if (std.mem.eql(u8, text, "tool_calls")) return .tool_use; + if (std.mem.eql(u8, text, "function_call")) return .tool_use; + if (std.mem.eql(u8, text, "length")) return .max_tokens; + if (std.mem.eql(u8, text, "content_filter")) return .refusal; + return .unknown; +} + +fn interpretGemini(arena: std.mem.Allocator, frame: Frame) ![]const Delta { + var out: std.ArrayList(Delta) = .empty; + const root = objectOf(arena, frame.data) orelse return out.items; + if (stringAt(root, "modelVersion")) |model| { + try out.append(arena, .{ .started = .{ .model = model } }); + } + const candidates = arrayAt(root, "candidates") orelse return out.items; + for (candidates.items) |candidate_value| { + const candidate = switch (candidate_value) { + .object => |o| o, + else => continue, + }; + if (objectAt(candidate, "content")) |content| { + if (arrayAt(content, "parts")) |parts| { + for (parts.items) |part_value| { + const part = switch (part_value) { + .object => |o| o, + else => continue, + }; + if (stringAt(part, "text")) |text| { + if (text.len > 0) try out.append(arena, .{ .text = .{ .index = 0, .chunk = text } }); + } + } + } + } + if (stringAt(candidate, "finishReason")) |reason| { + try out.append(arena, .{ .stopped = .{ .reason = geminiStopReason(reason) } }); + } + } + if (objectAt(root, "usageMetadata")) |usage| { + try out.append(arena, .{ .usage = .{ + .inputTokens = integerAt(usage, "promptTokenCount") orelse 0, + .outputTokens = integerAt(usage, "candidatesTokenCount") orelse 0, + } }); + } + return out.items; +} + +fn geminiStopReason(text: []const u8) provider.StopReason { + if (std.mem.eql(u8, text, "STOP")) return .end_turn; + if (std.mem.eql(u8, text, "MAX_TOKENS")) return .max_tokens; + if (std.mem.eql(u8, text, "SAFETY")) return .refusal; + if (std.mem.eql(u8, text, "RECITATION")) return .refusal; + return .unknown; +} + +fn interpretOllama(arena: std.mem.Allocator, frame: Frame) ![]const Delta { + var out: std.ArrayList(Delta) = .empty; + const root = objectOf(arena, frame.data) orelse return out.items; + if (stringAt(root, "model")) |model| { + try out.append(arena, .{ .started = .{ .model = model } }); + } + if (objectAt(root, "message")) |message| { + if (stringAt(message, "content")) |text| { + if (text.len > 0) try out.append(arena, .{ .text = .{ .index = 0, .chunk = text } }); + } + if (stringAt(message, "thinking")) |thought| { + if (thought.len > 0) try out.append(arena, .{ .thinking = .{ .index = 0, .chunk = thought } }); + } + } + const done = switch (root.get("done") orelse std.json.Value{ .bool = false }) { + .bool => |b| b, + else => false, + }; + if (done) { + try out.append(arena, .{ .usage = .{ + .inputTokens = integerAt(root, "prompt_eval_count") orelse 0, + .outputTokens = integerAt(root, "eval_count") orelse 0, + } }); + const reason = stringAt(root, "done_reason") orelse "stop"; + try out.append(arena, .{ .stopped = .{ + .reason = if (std.mem.eql(u8, reason, "length")) .max_tokens else .end_turn, + } }); + try out.append(arena, .ended); + } + return out.items; +} + +/// Folds deltas back into the completion the blocking path returns. +/// +/// Holds one growing buffer per content block, keyed by the provider's own +/// index, so blocks that interleave — which they do, when a model writes text +/// and calls a tool in the same turn — do not run into each other. +pub const Assembler = struct { + arena: std.mem.Allocator, + model: []const u8 = "", + stop_reason: provider.StopReason = .unknown, + usage: provider.Usage = .{}, + problem: []const u8 = "", + ended: bool = false, + parts: std.ArrayList(Part) = .empty, + + const Part = struct { + index: usize, + kind: Delta.BlockKind, + id: []const u8 = "", + name: []const u8 = "", + text: std.ArrayList(u8) = .empty, + closed: bool = false, + }; + + pub fn init(arena: std.mem.Allocator) Assembler { + return .{ .arena = arena }; + } + + /// Take one delta. + pub fn accept(self: *Assembler, delta: Delta) !void { + switch (delta) { + .started => |s| if (s.model.len > 0) { + self.model = s.model; + }, + .block_opened => |b| { + const part = try self.partAt(b.index, b.kind); + part.kind = b.kind; + if (b.id.len > 0) part.id = b.id; + if (b.name.len > 0) part.name = b.name; + }, + .text => |t| { + const part = try self.partAt(t.index, .text); + try part.text.appendSlice(self.arena, t.chunk); + }, + .thinking => |t| { + const part = try self.partAt(t.index, .thinking); + try part.text.appendSlice(self.arena, t.chunk); + }, + .tool_arguments => |t| { + const part = try self.partAt(t.index, .tool_use); + try part.text.appendSlice(self.arena, t.chunk); + }, + .block_closed => |b| { + if (self.find(b.index)) |part| part.closed = true; + }, + // Usage is replaced rather than added. Every provider here sends a + // running total, and adding them would count the same tokens as + // many times as it mentioned them. + .usage => |u| { + if (u.inputTokens > 0) self.usage.inputTokens = u.inputTokens; + if (u.outputTokens > 0) self.usage.outputTokens = u.outputTokens; + if (u.cacheReadTokens > 0) self.usage.cacheReadTokens = u.cacheReadTokens; + if (u.cacheWriteTokens > 0) self.usage.cacheWriteTokens = u.cacheWriteTokens; + }, + .stopped => |s| self.stop_reason = s.reason, + .provider_error => |e| self.problem = e.message, + .ended => self.ended = true, + } + } + + fn find(self: *Assembler, index: usize) ?*Part { + for (self.parts.items) |*part| { + if (part.index == index) return part; + } + return null; + } + + fn partAt(self: *Assembler, index: usize, kind: Delta.BlockKind) !*Part { + if (self.find(index)) |part| return part; + try self.parts.append(self.arena, .{ .index = index, .kind = kind }); + return &self.parts.items[self.parts.items.len - 1]; + } + + /// Whether the provider reported a failure mid-stream. + pub fn failed(self: Assembler) bool { + return self.problem.len > 0; + } + + /// The answer so far, as the type the blocking path returns. + /// + /// Safe to call at any point, so a caller that has to stop early still gets + /// the part that arrived rather than nothing. + pub fn completion(self: *Assembler) !provider.Completion { + var blocks: std.ArrayList(provider.Block) = .empty; + // The provider's own indices decide the order, so a stream whose frames + // arrived out of order still assembles the answer the model wrote. + std.mem.sort(Part, self.parts.items, {}, struct { + fn lessThan(_: void, a: Part, b: Part) bool { + return a.index < b.index; + } + }.lessThan); + + for (self.parts.items) |part| { + switch (part.kind) { + .text => if (part.text.items.len > 0) try blocks.append(self.arena, .{ .text = part.text.items }), + .thinking => if (part.text.items.len > 0) try blocks.append(self.arena, .{ .thinking = part.text.items }), + .tool_use => try blocks.append(self.arena, .{ + .tool_use = .{ + .id = part.id, + .name = part.name, + // An empty argument list is `{}`, not the empty string. A + // model that called a tool with no arguments sent no + // `input_json_delta` at all, and passing "" on to a JSON + // reader turns a valid call into a parse failure. + .argumentsJson = if (part.text.items.len == 0) "{}" else part.text.items, + }, + }), + } + } + + return .{ + .model = self.model, + .blocks = blocks.items, + .stopReason = self.stop_reason, + .usage = self.usage, + }; + } +}; + +/// A stream being read: framing, interpretation and assembly in one piece. +/// +/// This is what a caller uses. Feed it whatever the socket returned; it hands +/// back the deltas so progress can be shown, and holds the answer so far. +pub const Reader = struct { + arena: std.mem.Allocator, + format: catalog.Format, + framer: Framer, + assembler: Assembler, + + pub fn init(arena: std.mem.Allocator, format: catalog.Format) Reader { + return .{ + .arena = arena, + .format = format, + .framer = Framer.init(Framing.of(format)), + .assembler = Assembler.init(arena), + }; + } + + /// Add bytes. Returns the deltas they completed, in order. + pub fn feed(self: *Reader, chunk: []const u8) ![]const Delta { + const frames = try self.framer.feed(self.arena, chunk); + return self.absorb(frames); + } + + /// Tell the reader the connection closed, and take whatever was left. + pub fn finish(self: *Reader) ![]const Delta { + const frames = try self.framer.finish(self.arena); + return self.absorb(frames); + } + + fn absorb(self: *Reader, frames: []const Frame) ![]const Delta { + var out: std.ArrayList(Delta) = .empty; + for (frames) |frame| { + for (try interpret(self.arena, self.format, frame)) |delta| { + try self.assembler.accept(delta); + try out.append(self.arena, delta); + } + } + return out.items; + } + + /// The answer so far. + pub fn completion(self: *Reader) !provider.Completion { + return self.assembler.completion(); + } +}; + +const testing = std.testing; + +/// A real Anthropic stream, written the way the wire carries it. +/// +/// Text and a tool call in one turn, a thinking block, a ping in the middle, +/// and a `message_delta` carrying the stop reason and final usage. The tool +/// arguments arrive in three pieces that are not valid JSON on their own, which +/// is the case a naive reader parses too early. +const anthropic_stream = + "event: message_start\n" ++ + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude-opus-5\",\"usage\":{\"input_tokens\":42,\"output_tokens\":1}}}\n" ++ + "\n" ++ + "event: content_block_start\n" ++ + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n" ++ + "\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Check the log first.\"}}\n" ++ + "\n" ++ + "event: content_block_stop\n" ++ + "data: {\"type\":\"content_block_stop\",\"index\":0}\n" ++ + "\n" ++ + ": ping\n" ++ + "\n" ++ + "event: content_block_start\n" ++ + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n" ++ + "\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Reading \"}}\n" ++ + "\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"the log.\"}}\n" ++ + "\n" ++ + "event: content_block_stop\n" ++ + "data: {\"type\":\"content_block_stop\",\"index\":1}\n" ++ + "\n" ++ + "event: content_block_start\n" ++ + "data: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"read_file\"}}\n" ++ + "\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\"\"}}\n" ++ + "\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\":\\\"README\"}}\n" ++ + "\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\".md\\\"}\"}}\n" ++ + "\n" ++ + "event: content_block_stop\n" ++ + "data: {\"type\":\"content_block_stop\",\"index\":2}\n" ++ + "\n" ++ + "event: message_delta\n" ++ + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":42,\"output_tokens\":57}}\n" ++ + "\n" ++ + "event: message_stop\n" ++ + "data: {\"type\":\"message_stop\"}\n" ++ + "\n"; + +fn readWhole(arena: std.mem.Allocator, format: catalog.Format, source: []const u8, chunk_size: usize) !provider.Completion { + var reader = Reader.init(arena, format); + var at: usize = 0; + while (at < source.len) { + const end = @min(at + chunk_size, source.len); + _ = try reader.feed(source[at..end]); + at = end; + } + _ = try reader.finish(); + return reader.completion(); +} + +test "the answer does not depend on how the bytes were divided" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The test worth writing. A read can end at any byte — in the middle of + // `data:`, between the two newlines that end an event, inside a word — and + // the answer has to be the same one every time. One byte at a time is the + // worst case, and every size up to the whole stream is checked because the + // bug is usually at one specific size. + const whole = try readWhole(arena, .anthropic, anthropic_stream, anthropic_stream.len); + const expected_text = try whole.text(arena); + try testing.expectEqualStrings("Reading the log.", expected_text); + + var chunk_size: usize = 1; + while (chunk_size <= anthropic_stream.len) : (chunk_size += if (chunk_size < 40) 1 else 17) { + const answer = try readWhole(arena, .anthropic, anthropic_stream, chunk_size); + try testing.expectEqualStrings(expected_text, try answer.text(arena)); + try testing.expectEqualStrings(whole.model, answer.model); + try testing.expectEqual(whole.stopReason, answer.stopReason); + try testing.expectEqual(whole.usage.inputTokens, answer.usage.inputTokens); + try testing.expectEqual(whole.usage.outputTokens, answer.usage.outputTokens); + try testing.expectEqual(whole.blocks.len, answer.blocks.len); + for (whole.blocks, answer.blocks) |a, b| { + try testing.expectEqual(@as(std.meta.Tag(provider.Block), a), @as(std.meta.Tag(provider.Block), b)); + try testing.expectEqualStrings(a.textOf(), b.textOf()); + } + } +} + +test "a streamed answer is the answer the blocking path would have given" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const answer = try readWhole(arena, .anthropic, anthropic_stream, 7); + + // Everything the non-streaming decoder would have produced, produced the + // same way. A streaming path that returns something slightly different from + // the blocking one surfaces months later as "the model behaves differently + // when you watch it". + try testing.expectEqualStrings("claude-opus-5", answer.model); + try testing.expectEqual(provider.StopReason.tool_use, answer.stopReason); + try testing.expectEqual(@as(u64, 42), answer.usage.inputTokens); + try testing.expectEqual(@as(u64, 57), answer.usage.outputTokens); + + try testing.expectEqual(@as(usize, 3), answer.blocks.len); + try testing.expectEqualStrings("Check the log first.", answer.blocks[0].thinking); + try testing.expectEqualStrings("Reading the log.", answer.blocks[1].text); + + // The tool call, reassembled from three pieces none of which was valid + // JSON on its own. + const call = answer.blocks[2].tool_use; + try testing.expectEqualStrings("toolu_1", call.id); + try testing.expectEqualStrings("read_file", call.name); + try testing.expectEqualStrings("{\"path\":\"README.md\"}", call.argumentsJson); + + // And it parses, which is the only thing the rest of the workbench cares + // about. + const parsed = try std.json.parseFromSlice(std.json.Value, arena, call.argumentsJson, .{}); + try testing.expectEqualStrings("README.md", parsed.value.object.get("path").?.string); + + const calls = try answer.toolCalls(arena); + try testing.expectEqual(@as(usize, 1), calls.len); +} + +test "a heartbeat comment is not an event" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Providers send these to hold a connection open. A parser that treats a + // line beginning with a colon as a field with an empty name tries to + // interpret it, and a strict one errors on it. + var framer = Framer.init(.server_sent_events); + const frames = try framer.feed(arena, ": ping\n\n:\n\ndata: {\"type\":\"message_stop\"}\n\n"); + try testing.expectEqual(@as(usize, 1), frames.len); + try testing.expectEqualStrings("{\"type\":\"message_stop\"}", frames[0].data); +} + +test "an event split across every possible boundary still frames" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Two events, fed with the split moved one byte at a time through the whole + // stream. The interesting positions are inside `data:`, between the two + // newlines, and inside `\r\n` — each of which stalls a parser that decides + // a lone newline is a boundary. + const source = "event: a\r\ndata: one\r\n\r\nevent: b\r\ndata: two\r\n\r\n"; + var split: usize = 0; + while (split <= source.len) : (split += 1) { + var framer = Framer.init(.server_sent_events); + var collected: std.ArrayList(Frame) = .empty; + for (try framer.feed(arena, source[0..split])) |f| try collected.append(arena, f); + for (try framer.feed(arena, source[split..])) |f| try collected.append(arena, f); + for (try framer.finish(arena)) |f| try collected.append(arena, f); + + try testing.expectEqual(@as(usize, 2), collected.items.len); + try testing.expectEqualStrings("a", collected.items[0].name); + try testing.expectEqualStrings("one", collected.items[0].data); + try testing.expectEqualStrings("b", collected.items[1].name); + try testing.expectEqualStrings("two", collected.items[1].data); + } +} + +test "several data lines in one event are one payload" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The framing's own way of carrying a newline. Joining these with anything + // but a newline, or treating each as its own event, corrupts every payload + // that contains one. + var framer = Framer.init(.server_sent_events); + const frames = try framer.feed(arena, "data: {\"a\":\ndata: 1}\n\n"); + try testing.expectEqual(@as(usize, 1), frames.len); + try testing.expectEqualStrings("{\"a\":\n1}", frames[0].data); +} + +test "exactly one space after the colon belongs to the framing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var framer = Framer.init(.server_sent_events); + // One space is stripped; a second is part of the value, and eating it would + // change a model's own text. + const frames = try framer.feed(arena, "data: two spaces\n\ndata:none\n\n"); + try testing.expectEqual(@as(usize, 2), frames.len); + try testing.expectEqualStrings(" two spaces", frames[0].data); + try testing.expectEqualStrings("none", frames[1].data); +} + +test "a stream that ends without a final blank line keeps its last event" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A provider that closes the connection after the last event has still + // sent it. Dropping it loses the end of the answer, which is where the stop + // reason and the usage live. + var reader = Reader.init(arena, .anthropic); + _ = try reader.feed("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Done\"}}\n"); + const answer_before = try reader.completion(); + try testing.expectEqual(@as(usize, 0), answer_before.blocks.len); + + _ = try reader.finish(); + const answer = try reader.completion(); + try testing.expectEqualStrings("Done", try answer.text(arena)); +} + +test "the OpenAI sentinel is an ending, not a parse failure" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const source = + "data: {\"model\":\"gpt-x\",\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n" ++ + "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n" ++ + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":2}}\n\n" ++ + "data: [DONE]\n\n"; + + const answer = try readWhole(arena, .openai, source, 3); + try testing.expectEqualStrings("Hello", try answer.text(arena)); + try testing.expectEqualStrings("gpt-x", answer.model); + try testing.expectEqual(provider.StopReason.end_turn, answer.stopReason); + try testing.expectEqual(@as(u64, 9), answer.usage.inputTokens); + try testing.expectEqual(@as(u64, 2), answer.usage.outputTokens); +} + +test "an OpenAI tool call arrives in pieces and comes back whole" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const source = + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"run_tests\",\"arguments\":\"\"}}]}}]}\n\n" ++ + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"suite\\\"\"}}]}}]}\n\n" ++ + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\":\\\"unit\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n" ++ + "data: [DONE]\n\n"; + + const answer = try readWhole(arena, .openai, source, 5); + try testing.expectEqual(provider.StopReason.tool_use, answer.stopReason); + const calls = try answer.toolCalls(arena); + try testing.expectEqual(@as(usize, 1), calls.len); + try testing.expectEqualStrings("call_1", calls[0].id); + try testing.expectEqualStrings("run_tests", calls[0].name); + try testing.expectEqualStrings("{\"suite\":\"unit\"}", calls[0].argumentsJson); +} + +test "Ollama sends one object a line and says when it is done" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const source = + "{\"model\":\"llama3\",\"message\":{\"role\":\"assistant\",\"content\":\"Two \"},\"done\":false}\n" ++ + "{\"model\":\"llama3\",\"message\":{\"role\":\"assistant\",\"content\":\"plus two.\"},\"done\":false}\n" ++ + "{\"model\":\"llama3\",\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\"done_reason\":\"stop\",\"prompt_eval_count\":11,\"eval_count\":4}\n"; + + const answer = try readWhole(arena, .ollama, source, 1); + try testing.expectEqualStrings("Two plus two.", try answer.text(arena)); + try testing.expectEqualStrings("llama3", answer.model); + try testing.expectEqual(provider.StopReason.end_turn, answer.stopReason); + try testing.expectEqual(@as(u64, 11), answer.usage.inputTokens); + try testing.expectEqual(@as(u64, 4), answer.usage.outputTokens); +} + +test "a Gemini stream assembles the same way" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const source = + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"The answer \"}]}}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n" ++ + "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"is four.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":6,\"candidatesTokenCount\":3}}\n\n"; + + const answer = try readWhole(arena, .gemini, source, 4); + try testing.expectEqualStrings("The answer is four.", try answer.text(arena)); + try testing.expectEqualStrings("gemini-2.5-pro", answer.model); + try testing.expectEqual(provider.StopReason.end_turn, answer.stopReason); +} + +test "usage is replaced rather than added up" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Every provider here sends a running total. Adding them counts the same + // tokens as many times as the stream mentioned them, and a bill computed + // from that is wrong in the direction nobody notices until it is large. + var assembler = Assembler.init(arena); + try assembler.accept(.{ .usage = .{ .inputTokens = 42, .outputTokens = 1 } }); + try assembler.accept(.{ .usage = .{ .inputTokens = 42, .outputTokens = 30 } }); + try assembler.accept(.{ .usage = .{ .inputTokens = 42, .outputTokens = 57 } }); + const answer = try assembler.completion(); + try testing.expectEqual(@as(u64, 42), answer.usage.inputTokens); + try testing.expectEqual(@as(u64, 57), answer.usage.outputTokens); +} + +test "a tool called with no arguments is called with an empty object" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A model that calls a tool taking nothing sends no argument deltas at all. + // Passing the empty string on turns a valid call into a parse failure one + // layer up, which reads as the model having made a malformed request. + var assembler = Assembler.init(arena); + try assembler.accept(.{ .block_opened = .{ .index = 0, .kind = .tool_use, .id = "t1", .name = "list_blocks" } }); + try assembler.accept(.{ .block_closed = .{ .index = 0 } }); + const answer = try assembler.completion(); + try testing.expectEqualStrings("{}", answer.blocks[0].tool_use.argumentsJson); + _ = try std.json.parseFromSlice(std.json.Value, arena, answer.blocks[0].tool_use.argumentsJson, .{}); +} + +test "blocks come back in the provider's order, not the order they arrived" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var assembler = Assembler.init(arena); + try assembler.accept(.{ .text = .{ .index = 2, .chunk = "third" } }); + try assembler.accept(.{ .text = .{ .index = 0, .chunk = "first" } }); + try assembler.accept(.{ .text = .{ .index = 1, .chunk = "second" } }); + const answer = try assembler.completion(); + try testing.expectEqualStrings("firstsecondthird", try answer.text(arena)); +} + +test "a provider failing mid-stream is reported, not swallowed" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // An error frame after the answer has started. The bytes so far are still + // an answer and are kept; what must not happen is a caller being handed a + // truncated reply with no indication anything went wrong. + var reader = Reader.init(arena, .anthropic); + _ = try reader.feed("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Partial\"}}\n\n"); + const deltas = try reader.feed("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"); + + try testing.expectEqual(@as(usize, 1), deltas.len); + try testing.expectEqualStrings("Overloaded", deltas[0].provider_error.message); + try testing.expect(reader.assembler.failed()); + try testing.expectEqualStrings("Partial", try (try reader.completion()).text(arena)); +} + +test "a frame that never ends is refused rather than held for ever" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // An unbounded read driven by whoever is on the other end of the socket. + // Refusing is right; truncating would silently lose part of an answer. + var framer = Framer.init(.server_sent_events); + const filler = try arena.alloc(u8, Framer.max_frame_bytes + 1); + @memset(filler, 'x'); + try testing.expectError(error.FrameTooLarge, framer.feed(arena, filler)); +} + +test "an empty feed is legal and produces nothing" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // What a zero-length read means. A framer that treats it as an ending + // truncates every answer that arrives across a slow connection. + var reader = Reader.init(arena, .anthropic); + try testing.expectEqual(@as(usize, 0), (try reader.feed("")).len); + _ = try reader.feed("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"still here\"}}\n\n"); + try testing.expectEqualStrings("still here", try (try reader.completion()).text(arena)); +} + +test "every format has a framing, and every delta a shape" { + // A new format added to the catalogue has to decide how it is framed, + // rather than falling through to whichever branch was written first. + for (std.enums.values(catalog.Format)) |format| { + _ = Framing.of(format); + } + // And every provider in the catalogue is a format this reader can read. + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + for (catalog.connectors) |connector| { + var reader = Reader.init(arena_state.allocator(), connector.format); + _ = try reader.feed("data: {}\n\n"); + } +} diff --git a/src/ai/transport.zig b/src/ai/transport.zig index 44e8c59..7602fc7 100644 --- a/src/ai/transport.zig +++ b/src/ai/transport.zig @@ -41,6 +41,7 @@ const tools = @import("tools.zig"); const hashing = @import("../core/hash.zig"); const secrets = @import("../security/secrets.zig"); const netguard = @import("../security/netguard.zig"); +const stream_mod = @import("stream.zig"); pub const Connector = catalog.Connector; pub const Decision = policy_mod.Decision; @@ -62,6 +63,8 @@ pub const Error = error{ ProviderRejected, /// The request could not be sent at all. SendFailed, + /// A streaming answer was asked for and this transport cannot read one. + StreamingUnavailable, } || std.mem.Allocator.Error; /// What a person is told when a request does not go out. None of these mention @@ -74,6 +77,7 @@ pub fn refusalText(err: Error) []const u8 { error.AddressRefused => "The provider's name does not resolve to where it is supposed to go.", error.ProviderRejected => "The provider refused the request.", error.SendFailed => "The request could not be sent.", + error.StreamingUnavailable => "This build cannot read that provider's answer as it arrives.", error.OutOfMemory => "There was not enough memory to send this.", }; } @@ -233,6 +237,90 @@ pub const no_credentials: Credentials = .{ }.lookup, }; +/// Where the bytes of a streamed answer go as they arrive. +/// +/// A `std.Io.Writer` whose drain hands every byte straight to a stream reader +/// and keeps nothing. That is what makes it a stream rather than a slower way +/// of buffering: the HTTP client writes the response body into this, and this +/// forwards it, so no complete copy of the answer exists anywhere. +/// +/// The deltas are passed to `on_delta` as they are decoded, which is what lets +/// a caller print an answer while the model is still writing it. +pub const StreamSink = struct { + writer: std.Io.Writer, + reader: *stream_mod.Reader, + on_delta: ?*const fn (context: ?*anyopaque, delta: stream_mod.Delta) anyerror!void = null, + context: ?*anyopaque = null, + /// The first failure from the callback or the decoder. A drain cannot + /// return a typed error, so it is kept here and re-raised by the caller. + problem: ?anyerror = null, + + /// The sink needs no buffer of its own: everything it is given is forwarded + /// immediately, so there is nothing to hold. + pub fn init(reader: *stream_mod.Reader) StreamSink { + return .{ + .writer = .{ .vtable = &.{ .drain = drain }, .buffer = &.{} }, + .reader = reader, + }; + } + + fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { + const self: *StreamSink = @alignCast(@fieldParentPtr("writer", w)); + var written: usize = 0; + for (data, 0..) |slice, index| { + // The last slice is repeated `splat` times. Almost never more than + // once here, and getting it wrong would silently drop bytes. + const times = if (index == data.len - 1) splat else 1; + var remaining = times; + while (remaining > 0) : (remaining -= 1) { + self.take(slice) catch |err| { + if (self.problem == null) self.problem = err; + return error.WriteFailed; + }; + written += slice.len; + } + } + return written; + } + + fn take(self: *StreamSink, chunk: []const u8) !void { + for (try self.reader.feed(chunk)) |delta| { + if (self.on_delta) |notify| try notify(self.context, delta); + } + } + + /// Tell the reader the connection closed, and deliver what was left. + pub fn finish(self: *StreamSink) !void { + for (try self.reader.finish()) |delta| { + if (self.on_delta) |notify| try notify(self.context, delta); + } + } +}; + +/// Sends a request and writes the answer into a sink as it arrives. +/// +/// Separate from `Sender` because the two return different things: one returns +/// a body, and one returns only a status, because the body went somewhere else +/// while it was being read. +pub const StreamingSender = struct { + context: *anyopaque, + sendFn: *const fn ( + context: *anyopaque, + arena: std.mem.Allocator, + request: provider.HttpRequest, + sink: *StreamSink, + ) anyerror!u16, + + pub fn send( + self: StreamingSender, + arena: std.mem.Allocator, + request: provider.HttpRequest, + sink: *StreamSink, + ) anyerror!u16 { + return self.sendFn(self.context, arena, request, sink); + } +}; + pub const Transport = struct { arena: std.mem.Allocator, engine: *policy_mod.Engine, @@ -248,6 +336,11 @@ pub const Transport = struct { /// Optional so a wire-format test need not build one. Every path that /// reaches a real provider sets it. redactor: ?secrets.Redactor = null, + /// Sends and reads the answer as it arrives. Unset means this transport + /// can only wait for whole replies, and `sendStreaming` says so rather + /// than quietly falling back to blocking — a caller that asked to watch an + /// answer being written wants to know it is not going to happen. + streaming: ?StreamingSender = null, /// Checks where the provider's name actually resolves to, before anything /// connects. /// @@ -257,11 +350,7 @@ pub const Transport = struct { /// reason the redactor is, and set on every path that reaches a provider. guard: ?netguard.Guard = null, - /// Decide, then send. - /// - /// Every path through this function that reaches the sender has three - /// allowed decisions behind it, and every path that does not returns them - /// anyway through `attempt`. + /// Decide, then send, then wait for the whole answer. pub fn send( self: Transport, connector: Connector, @@ -269,6 +358,93 @@ pub const Transport = struct { context: policy_mod.Context, attempt: *Attempt, ) Error!provider.Completion { + const http = try self.gate(connector, request, context, attempt); + const response = self.sender.send(self.arena, http) catch |err| { + attempt.sendProblem = sendProblemText(err, connector.locality == .local); + return error.SendFailed; + }; + attempt.status = response.status; + + if (!response.ok()) { + attempt.providerError = messageIn(response.body); + return error.ProviderRejected; + } + + const completion = connector.wire().decode(self.arena, response.body) catch { + return error.ProviderRejected; + }; + attempt.completion = completion; + return completion; + } + + /// Decide, then send, and hand back each piece of the answer as it lands. + /// + /// The same gate as `send`: the four decisions and the redaction are one + /// function, called by both, so a caller cannot reach a provider by + /// choosing the streaming door. A transport with no streaming sender + /// refuses rather than quietly waiting for the whole reply, because a + /// caller that asked to watch an answer being written should be told when + /// that is not going to happen. + pub fn sendStreaming( + self: Transport, + connector: Connector, + request: provider.Request, + context: policy_mod.Context, + attempt: *Attempt, + on_delta: ?*const fn (context: ?*anyopaque, delta: stream_mod.Delta) anyerror!void, + on_delta_context: ?*anyopaque, + ) Error!provider.Completion { + const streaming = self.streaming orelse return error.StreamingUnavailable; + + var asked = request; + asked.stream = true; + const http = try self.gate(connector, asked, context, attempt); + + var reader = stream_mod.Reader.init(self.arena, connector.format); + var sink = StreamSink.init(&reader); + sink.on_delta = on_delta; + sink.context = on_delta_context; + + const status = streaming.send(self.arena, http, &sink) catch |err| { + // A failure inside the sink is the real cause and is more use than + // "the write failed", which is all the client can report. + const cause = sink.problem orelse err; + attempt.sendProblem = sendProblemText(cause, connector.locality == .local); + return error.SendFailed; + }; + attempt.status = status; + sink.finish() catch { + attempt.sendProblem = "The answer stopped part way through and could not be read to the end."; + return error.SendFailed; + }; + + if (status < 200 or status >= 300) { + attempt.providerError = "The provider refused the request."; + return error.ProviderRejected; + } + if (reader.assembler.failed()) { + // The provider said what went wrong mid-stream, in its own words. + attempt.providerError = messageIn(reader.assembler.problem); + return error.ProviderRejected; + } + + const completion = reader.completion() catch return error.ProviderRejected; + attempt.completion = completion; + return completion; + } + + /// Every decision, in order, ending in the bytes that are ready to go out. + /// + /// One function rather than two so that a second way of sending cannot be + /// added with one of the decisions missing, which is how a gate stops being + /// a gate. + fn gate( + self: Transport, + connector: Connector, + request: provider.Request, + context: policy_mod.Context, + attempt: *Attempt, + ) Error!provider.HttpRequest { var decisions: std.ArrayList(Decision) = .empty; const key_variable = connector.keyVariable; @@ -358,26 +534,8 @@ pub const Transport = struct { const outgoing = try self.withoutCredentials(request, attempt); attempt.promptHash = hashPrompt(outgoing); - const wire = connector.wire(); - const http = wire.encode(self.arena, connector.endpoint(api_key), outgoing) catch { - return error.SendFailed; - }; - const response = self.sender.send(self.arena, http) catch |err| { - attempt.sendProblem = sendProblemText(err, connector.locality == .local); - return error.SendFailed; - }; - attempt.status = response.status; - - if (!response.ok()) { - attempt.providerError = messageIn(response.body); - return error.ProviderRejected; - } - - const completion = wire.decode(self.arena, response.body) catch { - return error.ProviderRejected; - }; - attempt.completion = completion; - return completion; + return connector.wire().encode(self.arena, connector.endpoint(api_key), outgoing) catch + error.SendFailed; } /// A copy of `request` with credentials taken out of every text the model @@ -557,6 +715,46 @@ pub const Http = struct { return .{ .context = self, .sendFn = sendHttp }; } + /// The same client, reading the answer as it arrives. + pub fn streamingSender(self: *Http) StreamingSender { + return .{ .context = self, .sendFn = streamHttp }; + } + + fn streamHttp( + context: *anyopaque, + arena: std.mem.Allocator, + request: provider.HttpRequest, + sink: *StreamSink, + ) anyerror!u16 { + const self: *Http = @ptrCast(@alignCast(context)); + var extra: std.ArrayList(std.http.Header) = .empty; + var content_type: []const u8 = "application/json"; + for (request.headers) |header| { + if (std.ascii.eqlIgnoreCase(header.name, "content-type")) { + content_type = header.value; + continue; + } + try extra.append(arena, .{ .name = header.name, .value = header.value }); + } + + // The response body is written straight into the sink, which forwards + // every byte to the stream reader and keeps none. No complete copy of + // the answer exists anywhere, which is the difference between this and + // a slower way of buffering. + const result = try self.client.fetch(.{ + .location = .{ .url = request.url }, + .method = .POST, + .payload = request.body, + .headers = .{ .content_type = .{ .override = content_type } }, + .extra_headers = extra.items, + // A credential is in those headers, so a redirect is never + // followed here either. + .redirect_behavior = .unhandled, + .response_writer = &sink.writer, + }); + return @intFromEnum(result.status); + } + fn sendHttp( context: *anyopaque, arena: std.mem.Allocator, @@ -600,6 +798,12 @@ pub const Recorded = struct { /// Every request that was handed over, in order. A test asserts on this to /// prove that a refused request never reached here at all. seen: std.ArrayList(provider.HttpRequest) = .empty, + /// How many bytes a recorded streaming answer is handed over at a time. + /// + /// One byte by default, because that is the division that finds the bugs. + /// A test that hands a whole stream over in one write is testing a case + /// that does not happen on a real network. + chunk_size: usize = 1, arena: std.mem.Allocator, index: usize = 0, @@ -618,6 +822,32 @@ pub const Recorded = struct { defer self.index += 1; return self.responses[self.index]; } + + /// The same recorded answers, delivered the way a network delivers them. + pub fn streamingSender(self: *Recorded) StreamingSender { + return .{ .context = self, .sendFn = streamRecorded }; + } + + fn streamRecorded( + context: *anyopaque, + _: std.mem.Allocator, + request: provider.HttpRequest, + sink: *StreamSink, + ) anyerror!u16 { + const self: *Recorded = @ptrCast(@alignCast(context)); + try self.seen.append(self.arena, request); + if (self.index >= self.responses.len) return error.NoRecordedResponse; + defer self.index += 1; + const response = self.responses[self.index]; + + var at: usize = 0; + while (at < response.body.len) { + const end = @min(at + @max(self.chunk_size, 1), response.body.len); + try sink.writer.writeAll(response.body[at..end]); + at = end; + } + return response.status; + } }; const testing = std.testing; @@ -1101,3 +1331,160 @@ test "every stage of the gate says what it is in words" { } } } + +test "a streamed answer passes the same gate as a whole one" { + // A second way of sending is how a gate stops being a gate. The decisions + // live in one function that both doors call, and this is the test that says + // so: a policy that refuses the model refuses it whichever way it is asked. + const refuse_infer = [_]policy_mod.Rule{.{ + .id = "no-models", + .capabilities = &.{.@"model.infer"}, + .effect = .deny, + .reason = "This workspace does not use models.", + }}; + var fixture = try Fixture.init(&refuse_infer, &.{.{ .status = 200, .body = anthropic_sse }}); + defer fixture.deinit(); + + var transport = fixture.transport(with_key); + transport.streaming = fixture.recorded.streamingSender(); + + var attempt: Attempt = undefined; + const connector = catalog.find("anthropic").?; + try testing.expectError( + error.NotPermitted, + transport.sendStreaming(connector, ask(), fixture.context(), &attempt, null, null), + ); + try testing.expectEqual(Stage.infer, attempt.refusedAt.?); + // Nothing reached the sender. A refusal that still made the request is not + // a refusal. + try testing.expectEqual(@as(usize, 0), fixture.recorded.seen.items.len); +} + +test "a credential in a streamed prompt is redacted, same as a blocking one" { + var fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = anthropic_sse }}); + defer fixture.deinit(); + + var transport = fixture.transport(with_key); + transport.streaming = fixture.recorded.streamingSender(); + transport.redactor = secrets.Redactor.withSalt([_]u8{9} ** 16); + + const request: provider.Request = .{ + .model = "claude-opus-5", + .messages = &.{.{ .role = .user, .blocks = &.{.{ .text = "token ghp_" ++ "s" ** 36 }} }}, + }; + + var attempt: Attempt = undefined; + _ = try transport.sendStreaming(catalog.find("anthropic").?, request, fixture.context(), &attempt, null, null); + + const body = fixture.recorded.seen.items[0].body; + try testing.expect(std.mem.indexOf(u8, body, "ghp_") == null); + try testing.expectEqual(@as(usize, 1), attempt.redactions); + // And the request asked for a stream, which the caller never had to set. + try testing.expect(std.mem.indexOf(u8, body, "\"stream\":true") != null); +} + +test "a streamed answer arrives in pieces and ends up whole" { + var fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = anthropic_sse }}); + defer fixture.deinit(); + + var transport = fixture.transport(with_key); + transport.streaming = fixture.recorded.streamingSender(); + // One byte at a time, which is the division that finds the bugs. + fixture.recorded.chunk_size = 1; + + // What a caller does with a stream: watch it arrive. + const Watcher = struct { + var seen: usize = 0; + var text: std.ArrayList(u8) = .empty; + var arena: std.mem.Allocator = undefined; + + fn onDelta(_: ?*anyopaque, delta: stream_mod.Delta) anyerror!void { + seen += 1; + switch (delta) { + .text => |t| try text.appendSlice(arena, t.chunk), + else => {}, + } + } + }; + Watcher.seen = 0; + Watcher.text = .empty; + Watcher.arena = fixture.arena(); + + var attempt: Attempt = undefined; + const streamed = try transport.sendStreaming( + catalog.find("anthropic").?, + ask(), + fixture.context(), + &attempt, + Watcher.onDelta, + null, + ); + + // The caller saw the answer being written, in more than one piece. That is + // the whole feature: without it there is one delta at the end and this is + // a slower way of blocking. + try testing.expect(Watcher.seen > 3); + try testing.expectEqualStrings("Four.", Watcher.text.items); + + // And the completion is the one the blocking path would have returned. + try testing.expectEqualStrings("Four.", try streamed.text(fixture.arena())); + try testing.expectEqualStrings("claude-opus-5", streamed.model); + try testing.expectEqual(provider.StopReason.end_turn, streamed.stopReason); + try testing.expectEqual(@as(u64, 10), streamed.usage.inputTokens); + try testing.expectEqual(@as(u64, 2), streamed.usage.outputTokens); + try testing.expectEqual(@as(u16, 200), attempt.status.?); +} + +test "a transport with no streaming sender says so rather than blocking" { + var fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = answer }}); + defer fixture.deinit(); + + // Quietly waiting for the whole reply would look like the feature working + // badly rather than not being there. + const transport = fixture.transport(with_key); + var attempt: Attempt = undefined; + try testing.expectError( + error.StreamingUnavailable, + transport.sendStreaming(catalog.find("anthropic").?, ask(), fixture.context(), &attempt, null, null), + ); +} + +test "a provider that fails mid-stream is reported with its own words" { + const broken = + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Part\"}}\n\n" ++ + "event: error\n" ++ + "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"; + var fixture = try Fixture.init(&allow_everything, &.{.{ .status = 200, .body = broken }}); + defer fixture.deinit(); + + var transport = fixture.transport(with_key); + transport.streaming = fixture.recorded.streamingSender(); + + var attempt: Attempt = undefined; + // A 200 that turns into an error part way through. Returning the truncated + // answer as if it were complete is the failure worth avoiding. + try testing.expectError( + error.ProviderRejected, + transport.sendStreaming(catalog.find("anthropic").?, ask(), fixture.context(), &attempt, null, null), + ); + try testing.expect(std.mem.indexOf(u8, attempt.providerError, "Overloaded") != null); +} + +/// A short Anthropic stream, as the wire carries it. +const anthropic_sse = + "event: message_start\n" ++ + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude-opus-5\",\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n" ++ + "event: content_block_start\n" ++ + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" ++ + ": ping\n\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Fo\"}}\n\n" ++ + "event: content_block_delta\n" ++ + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ur.\"}}\n\n" ++ + "event: content_block_stop\n" ++ + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" ++ + "event: message_delta\n" ++ + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":2}}\n\n" ++ + "event: message_stop\n" ++ + "data: {\"type\":\"message_stop\"}\n\n"; diff --git a/src/cli/main.zig b/src/cli/main.zig index c7b81b0..7a86501 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -137,6 +137,7 @@ pub const help_text = \\ --model Which model to ask for. \\ --turns How many times a model may be asked in one run. \\ --raw Start the shell with no added prompt marks. + \\ --stream Print a model's answer as it arrives, not when it finishes. \\ --approve-truncate Apply the recovery plan after preserving the original log. \\ \\Read more in README.md, or run "zag doctor" to see this build. @@ -153,6 +154,8 @@ const Options = struct { root: []const u8 = ".", /// Run the shell exactly as it is, with no generated init file. raw: bool = false, + /// Print a model's answer as it arrives rather than when it is finished. + stream: bool = false, approve_truncate: bool = false, provider: []const u8 = "", model: []const u8 = "", @@ -191,6 +194,10 @@ fn parseOptions(arena: std.mem.Allocator, args: []const []const u8) !Options { options.raw = true; continue; } + if (std.mem.eql(u8, arg, "--stream")) { + options.stream = true; + continue; + } if (std.mem.eql(u8, arg, "--approve-truncate")) { options.approve_truncate = true; continue; @@ -1231,6 +1238,7 @@ fn askAModel( // Nothing that looks like a credential leaves this machine, whatever // the policy allowed to leave. The two are separate questions and this // is the second one. + .streaming = http.streamingSender(), .redactor = try zag.security.secrets.Redactor.init(io), // And nothing connects to a host whose name does not resolve to where // the connector said it lives. @@ -1244,6 +1252,17 @@ fn askAModel( .root = options.root, .io = io, }); + // Printing straight to the writer as the answer arrives. The writer is + // flushed on each piece, because a buffered stream shown at the end is a + // slower way of not streaming. + const Live = struct { + fn write(target: ?*anyopaque, chunk: []const u8) anyerror!void { + const out: *std.Io.Writer = @ptrCast(@alignCast(target orelse return)); + try out.writeAll(chunk); + try out.flush(); + } + }; + const runner: zag.ai.loop.Runner = .{ .arena = arena, .transport = transport, @@ -1251,6 +1270,10 @@ fn askAModel( .engine = &engine, .clock = monotonic(io), .journal = recorder.journal(), + .watch = if (options.stream) + .{ .context = w, .textFn = Live.write } + else + null, }; var budget: zag.ai.loop.Budget = .{}; @@ -1275,7 +1298,10 @@ fn askAModel( return 1; }; - if (transcript.answer.len > 0) try w.print("{s}\n\n", .{transcript.answer}); + // Already on screen when it was watched, so printing it again would show + // the answer twice. + if (!options.stream and transcript.answer.len > 0) try w.print("{s}\n\n", .{transcript.answer}); + if (options.stream and transcript.answer.len > 0) try w.writeAll("\n\n"); for (transcript.turns) |turn| { for (turn.steps) |step| { diff --git a/src/root.zig b/src/root.zig index 032d228..8abbeff 100644 --- a/src/root.zig +++ b/src/root.zig @@ -119,6 +119,7 @@ pub const ai = struct { pub const transport = @import("ai/transport.zig"); pub const loop = @import("ai/loop.zig"); pub const attention = @import("ai/attention.zig"); + pub const stream = @import("ai/stream.zig"); pub const wire = struct { pub const anthropic = @import("ai/wire/anthropic.zig"); pub const openai = @import("ai/wire/openai.zig"); From e549670e3a7fdf17073ccad5e4faaba156d966d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:36:17 +0000 Subject: [PATCH 10/12] Run independent tool calls at the same time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model that asks to read four files got them read one after another. Each read is a syscall that spends most of its time waiting, and there is no reason four should take four times as long as one. The reason it is not simply four threads is the record. Everything here ends up in an append-only, hash-chained log whose whole value is that it says what happened, and a log whose contents depend on which read finished first says something slightly different every time the same work is done. A chain over that proves nothing anybody wants proved. So the parallelism is deliberately narrow. `performOne` is split in two: `prepare` parses the call into a typed request, counts repeats and asks the policy engine — serially, in the order the model asked, exactly as before — and `runOne` spends the decision. Only the second half overlaps, and results are written back into the caller's array by index rather than as they arrive. A turn recorded from eight threads is the turn recorded from one, and there is a test that runs the same turn both ways and compares them step for step. src/ai/schedule.zig decides which calls may overlap. Independence comes from the typed request — the same value the policy engine decided about — never from anything the model wrote. Two claims conflict when they name overlapping resources and at least one writes. Paths overlap by prefix, so writing `src/` conflicts with reading `src/ai/loop.zig`; the separator test is what keeps `src` from containing `srcery`, and leaving it out is how a scheduler decides two unrelated directories are the same one. Different kinds never overlap: a host is not a path. A call whose effects cannot be bounded runs alone, waiting for everything before it and making everything after it wait. Running a process is the obvious one — a command can touch any file on the machine and reading its argument vector does not tell you which — along with commits, pushes, containers, remote execution, spawning an agent, calling out to an MCP server, and writing the shared knowledge base. The switch names every capability, so a new one added to the enum fails to compile here rather than quietly running beside anything. That is the conservative direction and it is the right one: missing a chance to overlap two reads costs milliseconds, and overlapping two calls that share a file costs correctness, intermittently. The property tests are over pseudo-random turns rather than examples somebody thought of: conflicting calls never share a wave, and never swap places, so the effect a model asked for second cannot land first. Conflict is asserted symmetric over every pair of claim shapes — an asymmetric test would schedule a pair one way and not the other, which shows up as a race depending on the order the model happened to list its calls in. `Runner.io` is optional. With no `io` the waves are walked in order and each call runs where it stands, which is a valid way to honour a plan and produces the identical record — the scheduler is a plan, not a thread pool. Measured: planning a sixteen-call turn costs 1.3 µs in ReleaseFast, which is noise against any real tool call. Added to the benchmark harness so a change that makes it expensive is noticed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- bench/main.zig | 45 ++++ docs/roadmap.md | 8 +- evidence/conformance.jsonl | 34 +-- src/ai/loop.zig | 234 ++++++++++++++++-- src/ai/schedule.zig | 480 +++++++++++++++++++++++++++++++++++++ src/cli/main.zig | 4 + src/root.zig | 1 + 7 files changed, 771 insertions(+), 35 deletions(-) create mode 100644 src/ai/schedule.zig diff --git a/bench/main.zig b/bench/main.zig index 460259e..d975cb5 100644 --- a/bench/main.zig +++ b/bench/main.zig @@ -373,6 +373,50 @@ fn benchmarkLineIndex(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerro return measure.into("line breaks found in a document", "bytes", text.items.len, text.items.len); } +/// Grouping a turn's tool calls into waves. +/// +/// The scheduler runs once per turn, before any work is done, so it has to be +/// cheap enough that a turn asking for sixteen calls does not pay for the +/// planning. Measured on a turn of the widest shape the budget allows, mixing +/// reads that overlap with writes and a barrier in the middle. +fn benchmarkSchedule(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result { + const turns: usize = 20_000; + const width: usize = 16; + + // The same shape every turn: a barrier in the middle, some reads that share + // a directory with a write, and some that do not. + const claims = try arena.alloc(?zag.ai.schedule.Claim, width); + for (claims, 0..) |*slot, index| { + slot.* = switch (index % 8) { + 0 => .everything, + 1, 2 => .{ .on = .{ + .resource = .{ .path = try std.fmt.allocPrint(arena, "src/shared-{d}", .{index % 3}) }, + .access = .write, + } }, + else => .{ .on = .{ + .resource = .{ .path = try std.fmt.allocPrint(arena, "src/file-{d}.zig", .{index}) }, + .access = .read, + } }, + }; + } + + var measure: Measure = .{}; + for (0..runs) |_| { + var scratch = std.heap.ArenaAllocator.init(arena); + defer scratch.deinit(); + const scratch_arena = scratch.allocator(); + const clock = Clock.start(io); + var planned: usize = 0; + for (0..turns) |_| { + const waves = try zag.ai.schedule.plan(scratch_arena, claims); + planned += waves.len; + } + measure.record(clock.elapsed()); + std.mem.doNotOptimizeAway(planned); + } + return measure.into("tool-call scheduling", "turns", turns, 0); +} + fn benchmarkLineage(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result { // A day of recorded work: commands, their output, and the files they // touched, with the files shared between them so the graph has real edges. @@ -448,6 +492,7 @@ pub fn main(init: std.process.Init) !u8 { .{ .run = benchmarkPlainLanguage, .times = runs }, .{ .run = benchmarkLineIndex, .times = runs }, .{ .run = benchmarkLineage, .times = runs }, + .{ .run = benchmarkSchedule, .times = runs }, }; for (benchmarks) |benchmark| { var scratch = std.heap.ArenaAllocator.init(gpa); diff --git a/docs/roadmap.md b/docs/roadmap.md index a90a16f..2fd7e28 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -205,8 +205,12 @@ editing Zig. --stream` reads a model's answer as it arrives, for all five wire formats; the desktop renderer and the daemon interface do not exist yet and will need the same treatment. -- Run more than one tool call at a time. A turn that asks for four independent - reads runs them one after another. +- Bound the width of a turn against the machine it runs on. Independent tool + calls now run at the same time, grouped into waves of calls that touch nothing + in common, and the record comes out identical either way. What is not decided + yet is how many units of concurrency a turn should be allowed on a small + machine — today it is however many the widest wave holds, up to the + calls-per-turn budget. - Add macOS pseudoterminal and Windows ConPTY implementations, and the terminal settings each one needs, with the same behavioural test suite used on Linux. - Define remote replay, reconnect and conflict semantics before allowing more diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index 9aca8e2..5a3e5d6 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:22:14.114136609Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/ai/loop.zig b/src/ai/loop.zig index be949cf..54cd69a 100644 --- a/src/ai/loop.zig +++ b/src/ai/loop.zig @@ -55,6 +55,7 @@ const provider = @import("provider.zig"); const catalog = @import("catalog.zig"); const transport_mod = @import("transport.zig"); const stream_mod = @import("stream.zig"); +const schedule = @import("schedule.zig"); const toolschema = @import("toolschema.zig"); const executor_mod = @import("executor.zig"); const policy_mod = @import("policy.zig"); @@ -360,6 +361,11 @@ pub const Runner = struct { clock: Clock = stopped_clock, /// Where this run is written down. None means nothing is written. journal: ?Journal = null, + /// Where concurrent tool calls run. None means every call runs in turn, + /// which produces the identical record — the scheduler is a plan, not a + /// thread pool, and running its waves one call at a time is a valid way to + /// honour it. + io: ?std.Io = null, /// Called with each piece of the answer as it arrives, when it is asked /// for and the transport can read one. /// @@ -486,10 +492,29 @@ pub const Runner = struct { var results: std.ArrayList(provider.Block) = .empty; var stop: ?Ending = null; - for (calls, 0..) |call, index| { - if (index >= options.budget.callsPerTurn) break; + const wanted = @min(calls.len, options.budget.callsPerTurn); + + // Every decision, serially, in the order the model asked. Parsing a + // call, counting repeats and asking the policy engine all read or + // write state that two threads must not touch at once — and, more + // than that, a decision taken concurrently would make the record + // depend on thread timing. + var prepared = try self.arena.alloc(Prepared, wanted); + var claims = try self.arena.alloc(?schedule.Claim, wanted); + for (calls[0..wanted], 0..) |call, index| { + prepared[index] = try self.prepare(call, context, &seen, options.budget.repeats); + claims[index] = prepared[index].claim; + } + + // Then the work, in waves of calls that touch nothing in common. + // Results are written back by index, never in the order they + // finished, so the turn recorded from eight threads is the turn + // recorded from one. + const waves = try schedule.plan(self.arena, claims); + try self.performWaves(prepared, waves); - const step = try self.performOne(call, context, &seen, options.budget.repeats); + for (calls[0..wanted], prepared) |call, done| { + const step = done.step; try steps.append(self.arena, step); self.recordStep(step); try results.append(self.arena, .{ .tool_result = .{ @@ -601,12 +626,41 @@ pub const Runner = struct { } /// One tool call, all the way through. - fn performOne( + /// A call that has been decided about and not yet run. + /// + /// The split exists so that everything which decides anything happens + /// serially, in the order the model asked, and only the executor's work is + /// allowed to overlap. A decision taken concurrently would make the record + /// depend on thread timing, which is the one thing an append-only log + /// cannot afford. + const Prepared = struct { + step: Step, + /// Set when there is still work to do. Null means the step is finished + /// — refused, or never a typed request in the first place. + pending: ?struct { decision: Decision, request: ToolRequest } = null, + claim: ?schedule.Claim = null, + }; + + /// Decide about one call. Never runs anything. + fn prepare( + self: Runner, + call: provider.ToolUse, + context: policy_mod.Context, + seen: *std.ArrayList(Repeat), + repeat_limit: usize, + ) Error!Prepared { + var out: Prepared = .{ .step = undefined }; + out.step = try self.decideOne(call, context, seen, repeat_limit, &out); + return out; + } + + fn decideOne( self: Runner, call: provider.ToolUse, context: policy_mod.Context, seen: *std.ArrayList(Repeat), repeat_limit: usize, + out: *Prepared, ) Error!Step { var step: Step = .{ .callId = call.id, @@ -676,8 +730,59 @@ pub const Runner = struct { } } - // 5. The executor spends the decision. It checks the match again, on - // its own terms, because this loop is not what makes that safe. + // 5. Everything is decided. What is left is the work itself, which is + // the only part allowed to overlap with another call's, and only + // when nothing they touch is shared. + out.pending = .{ .decision = decision, .request = request }; + out.claim = schedule.claimOf(self.arena, request) catch return error.OutOfMemory; + return step; + } + + /// Run every prepared call, a wave at a time. + /// + /// With no `io` there is nothing to run things on, so the waves are walked + /// in order and each call runs where it stands. The result is identical — + /// that is the point of the scheduler being a plan rather than a thread + /// pool — and there is a test that asserts it. + fn performWaves(self: Runner, prepared: []Prepared, waves: []const schedule.Wave) Error!void { + const io = self.io orelse { + for (waves) |wave| { + for (wave.members) |index| try self.perform(&prepared[index]); + } + return; + }; + + for (waves) |wave| { + if (wave.len() == 1) { + // One call is not worth a unit of concurrency, and most waves + // hold one. + try self.perform(&prepared[wave.members[0]]); + continue; + } + var futures = try self.arena.alloc(std.Io.Future(Error!Step), wave.len()); + for (wave.members, 0..) |index, at| { + const held = prepared[index].pending.?; + futures[at] = io.async(runOne, .{ self, prepared[index].step, held.decision, held.request }); + } + // Awaited in wave order, and each result stored at its own index. + // Nothing here depends on which finished first. + for (wave.members, 0..) |index, at| { + prepared[index].step = try futures[at].await(io); + prepared[index].pending = null; + } + } + } + + fn perform(self: Runner, item: *Prepared) Error!void { + const held = item.pending orelse return; + item.step = try runOne(self, item.step, held.decision, held.request); + item.pending = null; + } + + /// Spend a decision. Safe to call from more than one thread at once, on + /// calls the scheduler has said touch nothing in common. + fn runOne(self: Runner, step: Step, decision: Decision, request: ToolRequest) Error!Step { + var out = step; const result = self.executor.run(decision, request) catch |err| { // Some of these are refusals reached before anything happened, and // some are failures during the work. A person reading the record @@ -693,9 +798,9 @@ pub const Runner = struct { => true, else => false, }; - step.refused = refused_before_acting; - step.executed = !refused_before_acting; - step.result = .{ + out.refused = refused_before_acting; + out.executed = !refused_before_acting; + out.result = .{ .outcome = if (refused_before_acting) .denied else .failed, .summary = executor_mod.refusalText(err), }; @@ -703,16 +808,16 @@ pub const Runner = struct { // have allowed this: what refused it was the request model, and // telling a model "the policy did not allow this" when the policy // did would send it to ask a person about the wrong thing. - step.reply = if (refused_before_acting) + out.reply = if (refused_before_acting) executor_mod.refusalText(err) else - try toolschema.resultText(self.arena, step.result.?); - return step; + try toolschema.resultText(self.arena, out.result.?); + return out; }; - step.executed = true; - step.result = result; - step.reply = try toolschema.resultText(self.arena, result); - return step; + out.executed = true; + out.result = result; + out.reply = try toolschema.resultText(self.arena, result); + return out; } fn repeatedTooOften(self: Runner, seen: []const Repeat, limit: usize) bool { @@ -1375,3 +1480,100 @@ test "a run with no watcher never asks for a stream" { const body = fixture.recorded.seen.items[0].body; try testing.expect(std.mem.indexOf(u8, body, "\"stream\":false") != null); } + +test "independent reads overlap, and the record is the same as if they had not" { + // The property the whole scheduler exists to preserve. One turn asking for + // three independent reads, run once serially and once concurrently, and the + // two transcripts must be identical — same steps, same order, same replies. + // A log whose contents depend on which read finished first proves nothing + // anybody wants proved. + const three_reads = + \\{"model":"test-model","message":{"role":"assistant","content":"", + \\ "tool_calls":[ + \\ {"function":{"name":"read_file","arguments":{"path":"one.txt"}}}, + \\ {"function":{"name":"read_file","arguments":{"path":"two.txt"}}}, + \\ {"function":{"name":"read_file","arguments":{"path":"three.txt"}}}]}, + \\ "done":true,"done_reason":"stop","prompt_eval_count":10,"eval_count":5} + ; + + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + + // Both fixtures stay alive to the end of the test: the transcripts point + // into their arenas. + var fixtures: [2]*Fixture = undefined; + var transcripts: [2]Transcript = undefined; + for (0..2) |run| { + fixtures[run] = try Fixture.init(&allow_models_and_reading, &.{}); + const fixture = fixtures[run]; + fixture.recorded.responses = &.{ + .{ .status = 200, .body = three_reads }, + try textResponse(fixture.arena(), "All three read."), + }; + try fixture.place("one.txt", "first"); + try fixture.place("two.txt", "second"); + try fixture.place("three.txt", "third"); + + var runner = fixture.runner(stopped_clock); + // The only difference between the two runs. + if (run == 1) runner.io = threaded.io(); + transcripts[run] = try runner.run(fixture.options(), "Read all three.", fixture.context()); + + const steps = transcripts[run].turns[0].steps; + try testing.expectEqual(@as(usize, 3), steps.len); + // Each read ran, and each landed on the path the model asked for at + // that position — not another call's, which is what a scheduler that + // mixed up its indices would produce. + try testing.expectEqualStrings("one.txt", steps[0].request.?.read_file.path); + try testing.expectEqualStrings("two.txt", steps[1].request.?.read_file.path); + try testing.expectEqualStrings("three.txt", steps[2].request.?.read_file.path); + for (steps) |step| try testing.expect(step.executed); + } + defer for (fixtures) |fixture| fixture.deinit(); + + // Identical, step for step. + try testing.expectEqualStrings(transcripts[0].answer, transcripts[1].answer); + try testing.expectEqual(transcripts[0].ending, transcripts[1].ending); + try testing.expectEqual(transcripts[0].turns.len, transcripts[1].turns.len); + for (transcripts[0].turns, transcripts[1].turns) |a, b| { + try testing.expectEqual(a.steps.len, b.steps.len); + for (a.steps, b.steps) |x, y| { + try testing.expectEqualStrings(x.toolName, y.toolName); + try testing.expectEqualStrings(x.reply, y.reply); + try testing.expectEqual(x.refused, y.refused); + try testing.expectEqual(x.executed, y.executed); + } + } +} + +test "a write and the reads around it are never overlapped" { + // Reading a file while another call writes it is a race whose answer + // depends on timing. The scheduler is what stops that, and this is the + // shape it has to stop: the write is in its own wave, with a read on + // either side of it. + var fixture = try Fixture.init(&allow_everything_here, &.{}); + defer fixture.deinit(); + const arena = fixture.arena(); + + const claims = [_]?schedule.Claim{ + try schedule.claimOf(arena, .{ .read_file = .{ .path = "notes.md" } }), + try schedule.claimOf(arena, .{ .write_file = .{ .path = "notes.md", .contentHash = hashing.Hash.of("x"), .byteCount = 1 } }), + try schedule.claimOf(arena, .{ .read_file = .{ .path = "notes.md" } }), + try schedule.claimOf(arena, .{ .read_file = .{ .path = "other.md" } }), + }; + const waves = try schedule.plan(arena, &claims); + + // Three waves: the reads of `notes.md` cannot sit beside the write, and the + // unrelated file rides along with the first of them. + try testing.expectEqual(@as(usize, 3), waves.len); + try testing.expectEqualSlices(usize, &.{ 0, 3 }, waves[0].members); + try testing.expectEqualSlices(usize, &.{1}, waves[1].members); + try testing.expectEqualSlices(usize, &.{2}, waves[2].members); +} + +const allow_everything_here = [_]policy_mod.Rule{.{ + .id = "everything", + .capabilities = &.{ .@"model.infer", .@"network.connect", .@"fs.read", .@"fs.write" }, + .effect = .allow, + .reason = "This test workspace allows the tools it uses.", +}}; diff --git a/src/ai/schedule.zig b/src/ai/schedule.zig new file mode 100644 index 0000000..cfdcf70 --- /dev/null +++ b/src/ai/schedule.zig @@ -0,0 +1,480 @@ +//! Deciding which tool calls may run at the same time. +//! +//! A model that asks to read four files gets them read one after another. Each +//! read is a syscall that spends most of its time waiting, and there is no +//! reason four of them should take four times as long as one. That is the +//! feature. This file is about the reason it is not simply a matter of starting +//! four threads. +//! +//! ## The record has to come out the same +//! +//! Everything this workbench does ends up in an append-only, hash-chained log, +//! and the value of that log is that it says what happened. A log whose +//! contents depend on which read finished first says something slightly +//! different every time the same work is done, and a chain over that proves +//! nothing anybody wants proved. Two runs of the same turn must produce the +//! same events in the same order. +//! +//! So the parallelism here is deliberately narrow. Everything that decides +//! anything — parsing a call into a typed request, counting repeats, asking the +//! policy engine — happens serially, in the order the model asked, exactly as +//! it did before. Only the executor's work runs at once, and the results are +//! written back into the caller's own array by index rather than as they +//! arrive. A turn recorded from eight threads is byte-for-byte the turn +//! recorded from one. +//! +//! ## Two calls that touch the same thing do not overlap +//! +//! Reading a file while another call writes it is a race whose answer depends +//! on timing, and a workbench that produced one answer on a fast machine and +//! another on a slow one would be worse than a slow one. Calls are grouped into +//! waves: everything in a wave is independent of everything else in it, and one +//! wave finishes before the next starts. +//! +//! Independence is decided from the *typed request* — the same value the policy +//! engine decided about — never from anything the model wrote. Two claims +//! conflict when they name overlapping resources and at least one of them +//! writes. Paths overlap by prefix, so writing `src/` conflicts with reading +//! `src/ai/loop.zig`, which is the case a naive equality test misses. +//! +//! ## When in doubt, run it alone +//! +//! A call whose effects cannot be bounded is a barrier: it waits for everything +//! before it and everything after it waits for it. Running a process is the +//! obvious one — a command can touch any file on the machine, and no amount of +//! reading its argument vector tells you which. Pushing to a remote, signalling +//! a process and anything this build does not recognise are barriers too. +//! +//! That is the conservative direction, and it is the right one. Missing a +//! chance to overlap two reads costs milliseconds. Overlapping two calls that +//! turn out to share a file costs correctness, and costs it intermittently. + +const std = @import("std"); +const capability_mod = @import("capability.zig"); +const tools = @import("tools.zig"); + +pub const Capability = capability_mod.Capability; +pub const Resource = capability_mod.Resource; + +/// Whether a call only observes a resource, or changes it. +pub const Access = enum { + read, + write, + + pub fn text(self: Access) []const u8 { + return switch (self) { + .read => "reads", + .write => "changes", + }; + } +}; + +/// What one call touches. +pub const Claim = union(enum) { + /// Nothing outside itself. Two of these never conflict. + nothing, + /// A named resource, observed or changed. + on: struct { resource: Resource, access: Access }, + /// Effects this build cannot bound. Runs alone. + everything, + + /// Whether two calls may run at the same time. + /// + /// The relation is symmetric and reflexive-free in the sense that matters: + /// a call is never asked whether it conflicts with itself. + pub fn conflictsWith(self: Claim, other: Claim) bool { + if (self == .everything or other == .everything) return true; + if (self == .nothing or other == .nothing) return false; + const a = self.on; + const b = other.on; + // Two readers never conflict. That is the whole point of the exercise: + // reads are what a model asks for most, and reads are what overlap. + if (a.access == .read and b.access == .read) return false; + return overlaps(a.resource, b.resource); + } +}; + +/// Whether two resources name anything in common. +/// +/// Different kinds never overlap: a host is not a path, and a credential is not +/// a command. Within a kind, paths overlap by prefix and everything else by +/// equality. +pub fn overlaps(a: Resource, b: Resource) bool { + if (a == .none or b == .none) return false; + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; + return switch (a) { + .path => pathsOverlap(a.text(), b.text()), + .none => false, + else => std.mem.eql(u8, a.text(), b.text()), + }; +} + +/// Whether one path contains the other, or they are the same path. +/// +/// `src` contains `src/ai/loop.zig`; `src` does not contain `srcery`. The +/// separator test is what tells those apart, and leaving it out is how a +/// scheduler decides two unrelated directories are the same one. +fn pathsOverlap(a: []const u8, b: []const u8) bool { + const shorter = if (a.len <= b.len) a else b; + const longer = if (a.len <= b.len) b else a; + if (!std.mem.startsWith(u8, longer, shorter)) return false; + if (longer.len == shorter.len) return true; + // The shorter one is a directory only if the next byte begins a new + // segment. Trailing separators on the shorter path count as one already. + if (shorter.len > 0 and shorter[shorter.len - 1] == '/') return true; + return longer[shorter.len] == '/'; +} + +/// What a typed request touches, derived from the request rather than from +/// anything the model wrote. +/// +/// Every capability is named, so a new one added to the enum fails to compile +/// here rather than falling into a default that lets it run beside anything. +pub fn claimOf(arena: std.mem.Allocator, request: tools.ToolRequest) !Claim { + const capability = request.capability(); + const resource = try request.resource(arena); + + return switch (capability) { + // Observers. Any number may run at once, and beside any writer that + // does not touch what they are reading. + .@"fs.read", .@"git.read" => .{ .on = .{ .resource = resource, .access = .read } }, + + // Changers of one named thing. + .@"fs.write", .@"fs.delete" => .{ .on = .{ .resource = resource, .access = .write } }, + + // A commit changes the whole repository's state, not one path, and two + // of them racing produce a repository neither asked for. + .@"git.commit", .@"git.push" => .everything, + + // A command can touch any file on the machine, and reading its argument + // vector does not tell you which. This is the barrier that matters + // most, because it is the capability a model reaches for when it wants + // to do something the typed tools do not offer. + .@"process.execute", .@"process.signal" => .everything, + + // Network and credentials: the resource is the host or the variable, + // and two requests to the same host are ordered because a provider's + // rate limit and a server's state both care. + .@"network.connect", .@"network.listen" => .{ .on = .{ .resource = resource, .access = .write } }, + .@"credentials.use", .@"credentials.read" => .{ .on = .{ .resource = resource, .access = .read } }, + + // A model asking a model, a tool on a server this build did not write, + // a container, another machine, or a write to the shared knowledge + // base. None of these has effects this build can bound from the + // request, so each runs alone. + .@"model.infer", + .@"agent.spawn", + .@"mcp.invoke", + .@"container.start", + .@"remote.execute", + .@"knowledge.write", + => .everything, + }; +} + +/// A group of calls that may run at the same time. +/// +/// Holds indices into the caller's own array, so that results go back where +/// they came from and the record does not depend on which finished first. +pub const Wave = struct { + members: []const usize, + + pub fn len(self: Wave) usize { + return self.members.len; + } +}; + +/// Group calls into waves. +/// +/// A call joins the earliest wave that contains nothing it conflicts with, and +/// never overtakes a call it conflicts with — so the relative order of any two +/// dependent calls is the order the model asked for them in. Calls that conflict +/// with nothing all land in the first wave. +/// +/// `null` in `claims` means the call is not going to run at all — it was +/// refused, or never became a typed request — and takes no part in the +/// scheduling. +pub fn plan(arena: std.mem.Allocator, claims: []const ?Claim) ![]const Wave { + var waves: std.ArrayList(std.ArrayList(usize)) = .empty; + // What each wave collectively touches, so a candidate is checked against a + // wave rather than against every call ever placed. + var touched: std.ArrayList(std.ArrayList(Claim)) = .empty; + + for (claims, 0..) |maybe, index| { + const claim = maybe orelse continue; + + // The last wave holding something this conflicts with. Every wave after + // that one is free of conflicts by construction, so the first wave this + // may join is the one immediately after it — which is also the earliest + // wave that does not let it overtake a call it depends on. + var after: ?usize = null; + for (touched.items, 0..) |wave, at| { + for (wave.items) |placed| { + if (claim.conflictsWith(placed)) { + after = at; + break; + } + } + } + + const target = if (after) |last| last + 1 else 0; + while (waves.items.len <= target) { + try waves.append(arena, .empty); + try touched.append(arena, .empty); + } + try waves.items[target].append(arena, index); + try touched.items[target].append(arena, claim); + } + + var out: std.ArrayList(Wave) = .empty; + for (waves.items) |members| { + if (members.items.len == 0) continue; + try out.append(arena, .{ .members = members.items }); + } + return out.items; +} + +/// How many calls in the widest wave. What a caller sizes a pool from. +pub fn widest(waves: []const Wave) usize { + var most: usize = 0; + for (waves) |wave| most = @max(most, wave.len()); + return most; +} + +/// Whether any wave holds more than one call. +/// +/// When this is false, running the waves concurrently costs the setup and +/// gains nothing, so a caller may take the plain path and get an identical +/// result. +pub fn anyOverlap(waves: []const Wave) bool { + return widest(waves) > 1; +} + +const testing = std.testing; + +/// The two void variants as values rather than as tag literals, which is what +/// `Claim.everything` on its own resolves to. +const barrier: Claim = .everything; +const idle_claim: Claim = .nothing; + +fn reads(path: []const u8) ?Claim { + return .{ .on = .{ .resource = .{ .path = path }, .access = .read } }; +} + +fn writes(path: []const u8) ?Claim { + return .{ .on = .{ .resource = .{ .path = path }, .access = .write } }; +} + +test "four reads of four files are one wave" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The case the feature exists for. Four reads that touch nothing in common + // should take about as long as one, and they cannot unless they are in one + // wave. + const waves = try plan(arena, &.{ + reads("src/a.zig"), reads("src/b.zig"), reads("src/c.zig"), reads("src/d.zig"), + }); + try testing.expectEqual(@as(usize, 1), waves.len); + try testing.expectEqual(@as(usize, 4), waves[0].len()); + try testing.expect(anyOverlap(waves)); +} + +test "reading the file another call is writing waits for it" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A race whose answer depends on timing. A workbench that gave one answer + // on a fast machine and another on a slow one would be worse than a slow + // one. + const waves = try plan(arena, &.{ + reads("src/a.zig"), + writes("src/a.zig"), + reads("src/a.zig"), + }); + try testing.expectEqual(@as(usize, 3), waves.len); + try testing.expectEqualSlices(usize, &.{0}, waves[0].members); + try testing.expectEqualSlices(usize, &.{1}, waves[1].members); + try testing.expectEqualSlices(usize, &.{2}, waves[2].members); +} + +test "a directory contains the files under it, and does not contain its neighbour" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The case a naive equality test misses: writing a directory conflicts with + // reading anything inside it. + const nested = try plan(arena, &.{ writes("src"), reads("src/ai/loop.zig") }); + try testing.expectEqual(@as(usize, 2), nested.len); + + // And the case a naive prefix test gets wrong: `src` is not a prefix of + // `srcery` in any sense that matters, because they are different + // directories. + const neighbours = try plan(arena, &.{ writes("src"), reads("srcery/thing.zig") }); + try testing.expectEqual(@as(usize, 1), neighbours.len); + + try testing.expect(pathsOverlap("src", "src/ai/loop.zig")); + try testing.expect(pathsOverlap("src/", "src/ai/loop.zig")); + try testing.expect(pathsOverlap("src/a.zig", "src/a.zig")); + try testing.expect(!pathsOverlap("src", "srcery")); + try testing.expect(!pathsOverlap("src/a.zig", "src/b.zig")); + try testing.expect(!pathsOverlap("docs", "src")); +} + +test "running a command is a barrier in both directions" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A command can touch any file on the machine and reading its argument + // vector does not tell you which. Everything before it finishes first, and + // everything after it waits. + const waves = try plan(arena, &.{ + reads("src/a.zig"), + reads("src/b.zig"), + barrier, + reads("src/c.zig"), + reads("src/d.zig"), + }); + try testing.expectEqual(@as(usize, 3), waves.len); + try testing.expectEqualSlices(usize, &.{ 0, 1 }, waves[0].members); + try testing.expectEqualSlices(usize, &.{2}, waves[1].members); + try testing.expectEqualSlices(usize, &.{ 3, 4 }, waves[2].members); +} + +test "a refused call takes no part in the schedule" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A call that never became a typed request, or that the policy refused, is + // not going to run. Reserving a place for it would serialise calls that + // have nothing to do with it. + const waves = try plan(arena, &.{ reads("a"), null, reads("b"), null }); + try testing.expectEqual(@as(usize, 1), waves.len); + try testing.expectEqualSlices(usize, &.{ 0, 2 }, waves[0].members); +} + +test "different kinds of resource never conflict" { + // A host is not a path and a credential is not a command. Treating the text + // as the whole story would serialise a read of `build.zig` behind a + // connection to a host that happened to be called `build.zig`. + const path: Claim = .{ .on = .{ .resource = .{ .path = "x" }, .access = .write } }; + const host: Claim = .{ .on = .{ .resource = .{ .host = "x" }, .access = .write } }; + try testing.expect(!path.conflictsWith(host)); + + const same_host: Claim = .{ .on = .{ .resource = .{ .host = "x" }, .access = .write } }; + try testing.expect(host.conflictsWith(same_host)); +} + +test "nothing conflicts with nothing" { + const idle: Claim = .nothing; + try testing.expect(!idle.conflictsWith(idle)); + try testing.expect(!idle.conflictsWith(reads("a").?)); + // Except a barrier, which conflicts with everything including idleness, + // because "runs alone" has to mean alone. + try testing.expect(idle.conflictsWith(barrier)); + try testing.expect(barrier.conflictsWith(idle)); + try testing.expect(barrier.conflictsWith(barrier)); +} + +test "conflict is symmetric, whatever the pair" { + // An asymmetric conflict test schedules a pair one way and not the other, + // which shows up as a race that depends on the order the model listed its + // calls in. + const samples = [_]Claim{ + idle_claim, + barrier, + reads("src").?, + writes("src").?, + reads("src/a.zig").?, + writes("src/a.zig").?, + .{ .on = .{ .resource = .{ .host = "api.example.com" }, .access = .write } }, + .{ .on = .{ .resource = .{ .credential = "KEY" }, .access = .read } }, + .{ .on = .{ .resource = .none, .access = .read } }, + }; + for (samples) |a| { + for (samples) |b| { + try testing.expectEqual(a.conflictsWith(b), b.conflictsWith(a)); + } + } +} + +test "a plan never lets two conflicting calls share a wave, or swap places" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The two properties the whole file exists to guarantee, checked over + // pseudo-random turns rather than over the examples somebody thought of. + var prng = std.Random.DefaultPrng.init(0x5CEDA1E); + const random = prng.random(); + const paths = [_][]const u8{ "src", "src/a.zig", "src/b.zig", "docs", "docs/x.md", "build.zig" }; + + for (0..300) |_| { + const count = random.intRangeLessThan(usize, 1, 12); + const claims = try arena.alloc(?Claim, count); + for (claims) |*slot| { + slot.* = switch (random.intRangeLessThan(u8, 0, 10)) { + 0 => null, + 1 => barrier, + 2 => idle_claim, + 3, 4, 5 => reads(paths[random.intRangeLessThan(usize, 0, paths.len)]), + else => writes(paths[random.intRangeLessThan(usize, 0, paths.len)]), + }; + } + + const waves = try plan(arena, claims); + + // Where each call ended up. + var placed = try arena.alloc(?usize, count); + @memset(placed, null); + var total: usize = 0; + for (waves, 0..) |wave, at| { + for (wave.members) |index| { + try testing.expect(placed[index] == null); // never scheduled twice + placed[index] = at; + total += 1; + } + } + + // Every call that was going to run is in exactly one wave. + var expected: usize = 0; + for (claims) |claim| { + if (claim != null) expected += 1; + } + try testing.expectEqual(expected, total); + + for (claims, 0..) |maybe_a, i| { + const a = maybe_a orelse continue; + for (claims[i + 1 ..], i + 1..) |maybe_b, j| { + const b = maybe_b orelse continue; + if (!a.conflictsWith(b)) continue; + // Never in the same wave: they would race. + try testing.expect(placed[i].? != placed[j].?); + // And never reordered: the earlier call runs first, so the + // effect a model asked for second cannot land first. + try testing.expect(placed[i].? < placed[j].?); + } + } + } +} + +test "every capability decides what it touches, rather than falling through" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Reads overlap and writes do not. Anything unbounded is a barrier. The + // switch in `claimOf` names every capability, so a new one added to the + // enum fails to compile here rather than quietly running beside anything. + const reading: tools.ToolRequest = .{ .read_file = .{ .path = "README.md" } }; + const claim = try claimOf(arena, reading); + try testing.expectEqual(Access.read, claim.on.access); + + const running: tools.ToolRequest = .{ .execute = .{ .argv = &.{ "zig", "build" }, .workingDirectory = "." } }; + try testing.expectEqual(barrier, try claimOf(arena, running)); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 7a86501..68a1415 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1270,6 +1270,10 @@ fn askAModel( .engine = &engine, .clock = monotonic(io), .journal = recorder.journal(), + // Independent tool calls overlap. Which ones are independent is worked + // out from the typed requests the policy engine already decided about, + // and the record comes out the same either way. + .io = io, .watch = if (options.stream) .{ .context = w, .textFn = Live.write } else diff --git a/src/root.zig b/src/root.zig index 8abbeff..f9e9f4e 100644 --- a/src/root.zig +++ b/src/root.zig @@ -120,6 +120,7 @@ pub const ai = struct { pub const loop = @import("ai/loop.zig"); pub const attention = @import("ai/attention.zig"); pub const stream = @import("ai/stream.zig"); + pub const schedule = @import("ai/schedule.zig"); pub const wire = struct { pub const anthropic = @import("ai/wire/anthropic.zig"); pub const openai = @import("ai/wire/openai.zig"); From 01df615064c0614e8096e615f67296f329135333 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:45:47 +0000 Subject: [PATCH 11/12] Index the blocks, and measure what the index is actually worth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap asked for an index so a history query does not walk every block, and for the size and the measurement to be stated. Both are here, and the measurement says something the feature request did not anticipate. workspace/index.zig builds an inverted index over command text plus bitsets for status, kind, actor, exit status and shell-marked boundaries. Everything is built around one constraint: the index never changes an answer. It does not answer queries — it narrows the set of blocks worth asking, and the same matcher that answered before answers again on the survivors. Whatever the index gets wrong can only be a candidate that turns out not to match, never a match that was skipped. An index that returned nearly the same results would be worse than none: a person searching their own record for a command they know they ran, and not finding it, has been told something false about their own history. Four hundred pseudo-random queries run through both paths and the results are compared block for block. The text index is over tokens and the query language is not, so the dictionary is searched rather than the corpus: twenty thousand blocks hold a few thousand distinct tokens, and finding every token containing `hist` is a scan over those short strings rather than over twenty thousand command lines. That keeps `hist` finding `zag history`, rather than changing the query language to make the index easy. Measured on twenty thousand blocks, ReleaseFast: `status:failed zig build` takes 344 µs scanned and 72 µs indexed, 4.8 times faster. Building takes 1.8 ms. Those three numbers together mean an index never pays for a single query. Building costs about 89 ns a block and scanning about 17 ns, so one query through a freshly built index is five times slower than not building one, and that does not change with size because both grow linearly. So `zag history` does not use it. It was wired up, measured, and unwired, and the call site says why rather than leaving the next person to rediscover it. The index is for a surface that holds a workspace open and asks repeatedly — the graphical client, a request interface on the daemon, an interactive search in `zag term` — none of which exist yet. The roadmap item is rewritten to say that, and the milestone row now requires a surface where the index is cheaper than the scan it replaces, rather than only a measurement. Building the index found a real bug in the first version of itself: it wrote the lowercased token into the hash map's key *after* `getOrPut` had already placed the entry using the original key's hash, leaving every entry in a bucket its stored key does not hash to. The dictionary-size test caught it. Lowering now happens before the lookup. The bitset carries its own length and trims the bits past it, which is the classic place a population count comes out sixty-three too high; there is a test at every length either side of a word boundary. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- bench/main.zig | 53 ++++ docs/roadmap.md | 12 +- evidence/conformance.jsonl | 34 +-- src/cli/main.zig | 5 + src/root.zig | 1 + src/workspace/history.zig | 28 +- src/workspace/index.zig | 566 +++++++++++++++++++++++++++++++++++++ 7 files changed, 678 insertions(+), 21 deletions(-) create mode 100644 src/workspace/index.zig diff --git a/bench/main.zig b/bench/main.zig index d975cb5..8f67374 100644 --- a/bench/main.zig +++ b/bench/main.zig @@ -284,6 +284,57 @@ fn benchmarkBlockFold(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerro return measure.into("blocks folded from the log", "blocks", folded, 0); } +/// The same query, with the index in front of it. +/// +/// Both numbers are reported so the pair can be compared on one machine, which +/// is the only comparison that means anything. The index is built once here +/// because that is how a long-lived surface would hold it; the command-line +/// tool builds one per query and pays for it, which is honest at these sizes +/// and would not be at a hundred times them. +fn benchmarkIndexedQuery(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result { + const commands: usize = 20_000; + const log = try buildCommandLog(arena, commands); + const index = try zag.workspace.block.Index.build(arena, log); + const lookup = try zag.workspace.index.Lookup.build(arena, index); + const now = try zag.core.time.Timestamp.parseIso("2026-09-30T09:00:00Z"); + const parsed = try zag.workspace.history.parse(arena, "status:failed zig build"); + + var measure: Measure = .{}; + var matched: usize = 0; + for (0..runs) |_| { + const clock = Clock.start(io); + const results = try zag.workspace.history.run(arena, index, parsed.query, .{ + .now = now, + .limit = 20, + .lookup = &lookup, + }); + measure.record(clock.elapsed()); + matched += results.matchCount; + } + if (matched == 0) return error.QueryMatchedNothing; + return measure.into("the same query, through the index", "queries", 1, 0); +} + +/// Building the index itself, which the command-line tool pays for every run. +fn benchmarkIndexBuild(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result { + const commands: usize = 20_000; + const log = try buildCommandLog(arena, commands); + const index = try zag.workspace.block.Index.build(arena, log); + + var measure: Measure = .{}; + var tokens: usize = 0; + for (0..runs) |_| { + var scratch = std.heap.ArenaAllocator.init(arena); + defer scratch.deinit(); + const clock = Clock.start(io); + const lookup = try zag.workspace.index.Lookup.build(scratch.allocator(), index); + measure.record(clock.elapsed()); + tokens += lookup.tokenCount(); + } + std.mem.doNotOptimizeAway(tokens); + return measure.into("block index built over 20k blocks", "blocks", commands, 0); +} + fn benchmarkHistoryQuery(arena: std.mem.Allocator, io: std.Io, runs: usize) anyerror!Result { const commands: usize = 20_000; const log = try buildCommandLog(arena, commands); @@ -493,6 +544,8 @@ pub fn main(init: std.process.Init) !u8 { .{ .run = benchmarkLineIndex, .times = runs }, .{ .run = benchmarkLineage, .times = runs }, .{ .run = benchmarkSchedule, .times = runs }, + .{ .run = benchmarkIndexBuild, .times = runs }, + .{ .run = benchmarkIndexedQuery, .times = runs * 4 }, }; for (benchmarks) |benchmark| { var scratch = std.heap.ArenaAllocator.init(gpa); diff --git a/docs/roadmap.md b/docs/roadmap.md index 2fd7e28..4f61f2b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -190,8 +190,14 @@ editing Zig. branch, and whether the shell is marking its boundaries. - Hold more than one shell in one recorded session, with each block naming the pane it came from. -- Index the blocks, so a query does not walk them. State the size the index is - built for and measure a query at that size. +- Give the index somewhere to live. `workspace/index.zig` answers a query 4.8 + times faster over twenty thousand blocks — 344 µs scanned, 72 µs indexed — + and takes 1.8 ms to build, which means it never pays for a single query. + `zag history` runs one query and exits, so it still scans, and says so. The + index needs either a surface that holds a workspace open and asks repeatedly, + or to be persisted beside the log and kept current as events are appended. + The second brings a stale index as a new failure mode and needs its own + design. ## Priority 3: finish the large surfaces @@ -239,7 +245,7 @@ editing Zig. | Durable record | Fault-injection tests, object audit, recovery exercise and signed checkpoint verification | | Configurable terminal | A settings file read at start, a named report for each unreadable setting, and tests for the defaults | | Many shells in one session | Two shells recorded in one log, each block naming its pane, and the log verifying afterwards | -| Fast recall | A stated index size, a measured query at that size, and the measurement repeated by the benchmark harness | +| Fast recall | A stated index size, a measured query at that size, the measurement repeated by the benchmark harness, and a surface where the index is cheaper than the scan it replaces | | Enforced agent boundary | Executor integration tests, path and network escape tests, and an independent security review | | Accessible desktop | Automated checks plus task completion with keyboard and screen-reader users | | Remote workspace | Authentication review, reconnect tests, conflict tests and load results with declared units | diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index 5a3e5d6..2b5a3e7 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:34:41.776557435Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/cli/main.zig b/src/cli/main.zig index 68a1415..07cc6de 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -979,6 +979,11 @@ fn historySearch(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, option try w.writeAll("The log is incomplete or damaged, so these results cover only its verified prefix.\n\n"); } const index = try opened.service.blocks(); + // No lookup here, deliberately. Building one costs about five times what a + // single scan of the same blocks costs, so for a command that runs one + // query and exits it would make `zag history` slower. The index is for a + // surface that holds a workspace open and asks repeatedly; the numbers are + // in `zig build bench` and the reasoning is in `workspace/index.zig`. const results = try zag.workspace.history.run(arena, index, parsed.query, .{ .now = wallClock(io), .limit = 20, diff --git a/src/root.zig b/src/root.zig index f9e9f4e..f5ab9b2 100644 --- a/src/root.zig +++ b/src/root.zig @@ -81,6 +81,7 @@ pub const workspace = struct { pub const model = @import("workspace/model.zig"); pub const workflow = @import("workspace/workflow.zig"); pub const history = @import("workspace/history.zig"); + pub const index = @import("workspace/index.zig"); pub const service = @import("workspace/service.zig"); }; diff --git a/src/workspace/history.zig b/src/workspace/history.zig index f8bc97b..8a242ab 100644 --- a/src/workspace/history.zig +++ b/src/workspace/history.zig @@ -22,6 +22,7 @@ const std = @import("std"); const block_mod = @import("block.zig"); +const index_mod = @import("index.zig"); const timeutil = @import("../core/time.zig"); const provenance = @import("../data/provenance.zig"); @@ -298,6 +299,12 @@ pub const Options = struct { /// The moment "recent" is measured from. Callers pass the clock; tests pass /// a fixed value, so ranking is deterministic. now: Timestamp, + /// Narrows the blocks worth asking about, when one has been built. + /// + /// It never decides a match — the same matcher answers either way, on + /// whatever survives. An index that changed an answer would tell a person + /// something false about their own history, which is worse than being slow. + lookup: ?*const index_mod.Lookup = null, }; /// Run a query over a block index. @@ -306,7 +313,26 @@ pub fn run(arena: std.mem.Allocator, index: block_mod.Index, query: Query, optio var by_command: std.StringArrayHashMapUnmanaged(usize) = .empty; var match_count: usize = 0; - for (index.blocks.items) |b| { + // Which blocks to ask. With no lookup, or one built over a different set of + // blocks, that is all of them — a mismatched lookup would answer about the + // wrong history, so it is ignored rather than trusted. + const usable = if (options.lookup) |lookup| + (if (lookup.blocks == index.blocks.items.len) lookup else null) + else + null; + + var narrowed: ?index_mod.Set = null; + if (usable) |lookup| narrowed = try lookup.candidates(arena, query); + + var walk: ?index_mod.Set.Iterator = if (narrowed) |set| set.iterate() else null; + var cursor: usize = 0; + while (true) { + const position = if (walk) |*iterator| (iterator.next() orelse break) else blk: { + if (cursor >= index.blocks.items.len) break; + defer cursor += 1; + break :blk cursor; + }; + const b = index.blocks.items[position]; if (!query.matches(b)) continue; match_count += 1; const command = b.commandText orelse ""; diff --git a/src/workspace/index.zig b/src/workspace/index.zig new file mode 100644 index 0000000..fa35543 --- /dev/null +++ b/src/workspace/index.zig @@ -0,0 +1,566 @@ +//! Finding recorded work without reading all of it. +//! +//! `zag history` answers a query by walking every block and asking each one +//! whether it matches. That is the right first version — it is obviously +//! correct, and correctness is what a record is for — and it is linear in the +//! whole history. Twenty thousand blocks took three milliseconds a query on the +//! machine this was written on, which is fine, and a hundred times that many +//! would not be. +//! +//! ## The index never changes an answer +//! +//! This is the constraint everything here is built around. An index that +//! returns *nearly* the same results as the scan is worse than no index: a +//! person searching their own record for a command they know they ran, and not +//! finding it, has been told something false about their own history. +//! +//! So the index does not answer queries. It narrows the set of blocks worth +//! asking, and the same matcher that answered before answers again, on the +//! survivors. Whatever the index gets wrong can only be a *candidate* that +//! turns out not to match — never a match that was skipped. There is a test +//! that runs several hundred pseudo-random queries through both paths and +//! asserts the results are identical, block for block, in order. +//! +//! ## Why the text index is over tokens and the query is not +//! +//! A word in a query matches anywhere in a command: `hist` finds +//! `zag history`. A plain token index cannot answer that, and changing the +//! query language to make the index easy would be the tail wagging the dog. +//! +//! Instead the dictionary is searched, not the corpus. A workspace with twenty +//! thousand blocks has a few thousand distinct tokens, and finding every token +//! that contains `hist` is a scan over those few thousand short strings rather +//! than over twenty thousand command lines. The postings of the matching tokens +//! are unioned, and that is the candidate set. +//! +//! ## Why the typed filters are bitsets +//! +//! `status:failed` is the query people actually run, and it is a filter that +//! throws away most of the history before any text is looked at. One word of a +//! bitset answers it for sixty-four blocks at a time, and intersecting two +//! filters is an `and` per word. The text pass then runs over what is left, +//! which for a real query is a small fraction of the whole. +//! +//! ## What this is worth, measured +//! +//! On twenty thousand blocks, `status:failed zig build` takes 344 µs scanned +//! and 72 µs through the index — 4.8 times faster. Building the index takes +//! 1.8 ms. +//! +//! Those three numbers together say something the first two on their own do +//! not: **an index never pays for a single query.** Building costs about +//! 89 ns a block and scanning costs about 17 ns, so one query through a +//! freshly built index is five times slower than not building one. That does +//! not change at a larger size — both grow linearly — so no threshold makes it +//! worthwhile. +//! +//! `zag history` therefore does not use it, and says so where it does not. The +//! index is for a surface that holds a workspace open and asks repeatedly: the +//! graphical client, a request interface on the daemon, an interactive search +//! inside `zag term`. None of those exist yet. What would make it pay for a +//! one-shot command is persisting it beside the log and updating it as events +//! are appended, and that is a different piece of work with its own failure +//! mode — a stale index — which is exactly the thing the whole file is written +//! to avoid. + +const std = @import("std"); +const block_mod = @import("block.zig"); +const provenance = @import("../data/provenance.zig"); + +pub const Block = block_mod.Block; +pub const Status = block_mod.Status; +pub const Kind = block_mod.Kind; + +/// A set of block positions, one bit each. +/// +/// Sized once at build time and never grown, because the set of blocks an index +/// was built over does not change while it is being queried. +pub const Set = struct { + words: []u64, + /// How many blocks the set is over. The bits above this in the last word + /// are always zero and must stay that way, or a count is wrong by up to + /// sixty-three. + len: usize, + + pub fn init(arena: std.mem.Allocator, len: usize) !Set { + const words = try arena.alloc(u64, (len + 63) / 64); + @memset(words, 0); + return .{ .words = words, .len = len }; + } + + /// Every block, set. + pub fn full(arena: std.mem.Allocator, len: usize) !Set { + var set = try init(arena, len); + @memset(set.words, ~@as(u64, 0)); + set.trim(); + return set; + } + + /// Clear the bits past `len` in the last word. + fn trim(self: *Set) void { + if (self.words.len == 0) return; + const used = self.len % 64; + if (used != 0) self.words[self.words.len - 1] &= (@as(u64, 1) << @intCast(used)) - 1; + } + + pub fn add(self: *Set, at: usize) void { + self.words[at / 64] |= @as(u64, 1) << @intCast(at % 64); + } + + pub fn has(self: Set, at: usize) bool { + if (at >= self.len) return false; + return self.words[at / 64] & (@as(u64, 1) << @intCast(at % 64)) != 0; + } + + pub fn andWith(self: *Set, other: Set) void { + for (self.words, other.words) |*word, mask| word.* &= mask; + } + + pub fn orWith(self: *Set, other: Set) void { + for (self.words, other.words) |*word, mask| word.* |= mask; + } + + pub fn count(self: Set) usize { + var total: usize = 0; + for (self.words) |word| total += @popCount(word); + return total; + } + + pub fn isEmpty(self: Set) bool { + for (self.words) |word| { + if (word != 0) return false; + } + return true; + } + + /// Walks the set positions in order, a word at a time. + /// + /// Skipping a whole empty word is the reason this is a bitset rather than a + /// list: a filter that keeps one block in a thousand leaves most words zero, + /// and those cost one comparison each. + pub const Iterator = struct { + set: Set, + word: usize = 0, + remaining: u64 = 0, + + pub fn next(self: *Iterator) ?usize { + while (self.remaining == 0) { + if (self.word >= self.set.words.len) return null; + self.remaining = self.set.words[self.word]; + self.word += 1; + } + const bit = @ctz(self.remaining); + self.remaining &= self.remaining - 1; + return (self.word - 1) * 64 + bit; + } + }; + + pub fn iterate(self: Set) Iterator { + return .{ .set = self }; + } +}; + +/// The lookup structures over one block index. +/// +/// Built once and read many times. Rebuilding is cheaper than maintaining, at +/// the sizes this is for, and a stale index is the kind of bug that makes a +/// person distrust their own record. +pub const Lookup = struct { + /// How many blocks this was built over. A lookup used against a different + /// index would silently answer about the wrong blocks, so callers check. + blocks: usize, + /// Token to the positions of the blocks whose command text holds it. + /// Insertion order is the order tokens were first seen, which makes the + /// dictionary scan deterministic. + postings: std.StringArrayHashMapUnmanaged(std.ArrayList(u32)) = .empty, + by_status: std.AutoArrayHashMapUnmanaged(Status, Set) = .empty, + by_kind: std.AutoArrayHashMapUnmanaged(Kind, Set) = .empty, + by_actor: std.AutoArrayHashMapUnmanaged(provenance.ProducerKind, Set) = .empty, + /// Blocks whose boundary came from shell integration. + shell_marked: Set, + /// Blocks that have an exit status at all, keyed by it. + by_exit: std.AutoArrayHashMapUnmanaged(u8, Set) = .empty, + + /// The longest token the dictionary holds. + /// + /// A command line can be enormous — a linker invocation with two thousand + /// object files — and indexing every token of one would make the dictionary + /// larger than the corpus. Tokens past this are indexed by their first + /// `max_token_bytes` and the matcher settles the rest, which is safe + /// because the index only ever narrows. + pub const max_token_bytes = 64; + + /// Build the lookup over every block in `index`. + pub fn build(arena: std.mem.Allocator, index: block_mod.Index) !Lookup { + const count = index.blocks.items.len; + var self: Lookup = .{ + .blocks = count, + .shell_marked = try Set.init(arena, count), + }; + + for (index.blocks.items, 0..) |b, at| { + (try setFor(arena, &self.by_status, b.status, count)).add(at); + (try setFor(arena, &self.by_kind, b.kind, count)).add(at); + (try setFor(arena, &self.by_actor, b.actor.kind, count)).add(at); + if (b.exitStatus) |code| (try setFor(arena, &self.by_exit, code, count)).add(at); + if (b.boundaryFromShell) self.shell_marked.add(at); + + const text = b.commandText orelse continue; + var walk = Tokens{ .text = text }; + var lowered: [max_token_bytes]u8 = undefined; + while (walk.next()) |token| { + const cut = token[0..@min(token.len, max_token_bytes)]; + // Lowered *before* the lookup, not after. Writing a different + // key into the map once the entry exists leaves it in the + // bucket its old hash chose, and every later lookup misses it. + const key = lowered[0..cut.len]; + for (cut, 0..) |byte, offset| key[offset] = std.ascii.toLower(byte); + + const entry = try self.postings.getOrPut(arena, key); + if (!entry.found_existing) { + // The key so far points at a stack buffer that is about to + // be reused, so the map gets its own copy. + entry.key_ptr.* = try arena.dupe(u8, key); + entry.value_ptr.* = .empty; + } + // Postings are appended in block order and so are already + // sorted, and a block never appends the same token twice + // because the last position is checked. + const list = entry.value_ptr; + if (list.items.len > 0 and list.items[list.items.len - 1] == at) continue; + try list.append(arena, @intCast(at)); + } + } + return self; + } + + fn setFor( + arena: std.mem.Allocator, + map: anytype, + key: anytype, + count: usize, + ) !*Set { + const entry = try map.getOrPut(arena, key); + if (!entry.found_existing) entry.value_ptr.* = try Set.init(arena, count); + return entry.value_ptr; + } + + /// Blocks that might match. Never fewer than the blocks that do. + /// + /// Every filter here is one the index can answer exactly. Anything it + /// cannot — a time range, a directory prefix, a branch — is left to the + /// matcher, which runs on the survivors. + pub fn candidates(self: Lookup, arena: std.mem.Allocator, query: anytype) !Set { + var out = try Set.full(arena, self.blocks); + + if (query.status) |status| out.andWith(self.by_status.get(status) orelse try Set.init(arena, self.blocks)); + if (query.kind) |kind| out.andWith(self.by_kind.get(kind) orelse try Set.init(arena, self.blocks)); + if (query.actor) |actor| out.andWith(self.by_actor.get(actor) orelse try Set.init(arena, self.blocks)); + if (query.exit_status) |code| out.andWith(self.by_exit.get(code) orelse try Set.init(arena, self.blocks)); + if (query.shell_marked_only) out.andWith(self.shell_marked); + + for (query.words) |word| { + if (word.len == 0) continue; + if (out.isEmpty()) break; + out.andWith(try self.blocksHolding(arena, word)); + } + return out; + } + + /// Every block whose command text contains `word`, anywhere. + /// + /// The dictionary is scanned rather than the corpus. A workspace with + /// twenty thousand blocks has a few thousand distinct tokens, and a + /// substring search over those short strings is far cheaper than one over + /// every command line. + /// + /// A word longer than a token can hold cannot be answered from the + /// dictionary at all, so every block is a candidate and the matcher + /// decides. That is slow and correct, which is the right way round. + fn blocksHolding(self: Lookup, arena: std.mem.Allocator, word: []const u8) !Set { + if (word.len > max_token_bytes) return Set.full(arena, self.blocks); + + var lowered_buffer: [max_token_bytes]u8 = undefined; + const lowered = lowered_buffer[0..word.len]; + for (word, 0..) |byte, at| lowered[at] = std.ascii.toLower(byte); + + var out = try Set.init(arena, self.blocks); + var entries = self.postings.iterator(); + while (entries.next()) |entry| { + if (std.mem.indexOf(u8, entry.key_ptr.*, lowered) == null) continue; + for (entry.value_ptr.items) |at| out.add(at); + } + return out; + } + + /// How many distinct tokens the dictionary holds. Reported so that a + /// measurement can say what size it was taken at. + pub fn tokenCount(self: Lookup) usize { + return self.postings.count(); + } +}; + +/// Splits command text into the runs a query word could sit inside. +/// +/// A token is a run of letters, digits and the punctuation that holds a path or +/// a flag together: `--summary`, `src/ai/loop.zig`, `v1.2.3`. Splitting those +/// apart would put `src` and `loop` in different tokens and make +/// `src/ai/loop` findable only by scanning, which is the thing being avoided. +pub const Tokens = struct { + text: []const u8, + at: usize = 0, + + fn isTokenByte(byte: u8) bool { + return switch (byte) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '_', '.', '/', '\\', ':', '@', '+', '~', '=' => true, + else => false, + }; + } + + pub fn next(self: *Tokens) ?[]const u8 { + while (self.at < self.text.len and !isTokenByte(self.text[self.at])) self.at += 1; + if (self.at >= self.text.len) return null; + const start = self.at; + while (self.at < self.text.len and isTokenByte(self.text[self.at])) self.at += 1; + return self.text[start..self.at]; + } +}; + +const testing = std.testing; +const history = @import("history.zig"); +const event_mod = @import("../events/event.zig"); +const idmod = @import("../core/id.zig"); +const timeutil = @import("../core/time.zig"); + +/// A workspace's worth of blocks, built without a log so the test is about the +/// index rather than about folding events. +fn sampleBlocks(arena: std.mem.Allocator, count: usize, seed: u64) !block_mod.Index { + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + + const programs = [_][]const u8{ "zig", "git", "grep", "zag", "curl", "make" }; + const verbs = [_][]const u8{ "build", "test", "status", "commit", "history", "check", "fmt" }; + const paths = [_][]const u8{ "src/ai/loop.zig", "src/workspace/index.zig", "README.md", "docs/roadmap.md", "" }; + const statuses = [_]Status{ .succeeded, .failed, .running, .denied, .cancelled, .unknown }; + const kinds = [_]Kind{ .command, .agent_run, .tool_call, .message }; + const actors = [_]provenance.ProducerKind{ .person, .agent, .system }; + + var index: block_mod.Index = .{ .arena = arena }; + var gen: idmod.Generator = .init(seed, 1_780_000_000_000); + for (0..count) |at| { + const text = try std.fmt.allocPrint(arena, "{s} {s} {s}", .{ + programs[random.intRangeLessThan(usize, 0, programs.len)], + verbs[random.intRangeLessThan(usize, 0, verbs.len)], + paths[random.intRangeLessThan(usize, 0, paths.len)], + }); + const status = statuses[random.intRangeLessThan(usize, 0, statuses.len)]; + try index.blocks.append(arena, .{ + .id = gen.next(idmod.BlockId), + .session = gen.next(idmod.SessionId), + .kind = kinds[random.intRangeLessThan(usize, 0, kinds.len)], + .status = status, + .createdAt = .{ .ns = 1_780_000_000_000_000_000 + @as(i64, @intCast(at)) * 1_000_000_000 }, + .workingDirectory = if (random.boolean()) "/home/user/zag" else "/tmp/scratch", + .commandText = text, + .exitStatus = if (status == .failed) random.intRangeLessThan(u8, 1, 5) else @as(?u8, if (status == .succeeded) 0 else null), + .actor = .{ + .id = gen.next(idmod.ActorId), + .kind = actors[random.intRangeLessThan(usize, 0, actors.len)], + .label = "someone", + }, + .boundaryFromShell = random.boolean(), + }); + } + return index; +} + +test "the index narrows the search and never changes the answer" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The test the whole file rests on. An index that returns nearly the same + // results as the scan is worse than no index: a person searching their own + // record for a command they know they ran, and not finding it, has been + // told something false about their own history. + const blocks = try sampleBlocks(arena, 900, 0x1DEA); + const lookup = try Lookup.build(arena, blocks); + const now: timeutil.Timestamp = .{ .ns = 1_781_000_000_000_000_000 }; + + var prng = std.Random.DefaultPrng.init(0xC0FFEE); + const random = prng.random(); + const words = [_][]const u8{ "zig", "git", "test", "loop", "ZAG", "hist", "src/ai", ".md", "nothing-here", "z" }; + + for (0..400) |_| { + var query: history.Query = .{}; + var chosen: std.ArrayList([]const u8) = .empty; + const word_count = random.intRangeLessThan(usize, 0, 3); + for (0..word_count) |_| { + try chosen.append(arena, words[random.intRangeLessThan(usize, 0, words.len)]); + } + query.words = chosen.items; + if (random.boolean()) query.status = @enumFromInt(random.intRangeLessThan(u8, 0, 6)); + if (random.boolean()) query.kind = @enumFromInt(random.intRangeLessThan(u8, 0, 6)); + if (random.boolean()) query.actor = @enumFromInt(random.intRangeLessThan(u8, 0, 3)); + if (random.boolean()) query.exit_status = random.intRangeLessThan(u8, 0, 5); + if (random.boolean()) query.shell_marked_only = true; + if (random.boolean()) query.directory = "/home/user/zag"; + if (random.boolean()) query.since = .{ .ns = 1_780_000_000_000_000_000 + 400 * 1_000_000_000 }; + + const options: history.Options = .{ .now = now, .foldRepeats = random.boolean() }; + var with_index = options; + with_index.lookup = &lookup; + + const scanned = try history.run(arena, blocks, query, options); + const indexed = try history.run(arena, blocks, query, with_index); + + try testing.expectEqual(scanned.matchCount, indexed.matchCount); + try testing.expectEqual(scanned.hits.len, indexed.hits.len); + for (scanned.hits, indexed.hits) |a, b| { + try testing.expect(a.block.id.eql(b.block.id)); + try testing.expectEqual(a.repeatCount, b.repeatCount); + } + } +} + +test "a lookup built over different blocks is ignored, not trusted" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A stale index would answer about the wrong history, quietly. Falling back + // to the scan is slow and right; using it anyway is fast and wrong. + const small = try sampleBlocks(arena, 20, 1); + const large = try sampleBlocks(arena, 200, 2); + const stale = try Lookup.build(arena, small); + const now: timeutil.Timestamp = .{ .ns = 1_781_000_000_000_000_000 }; + + const query: history.Query = .{ .words = &.{"zig"} }; + const scanned = try history.run(arena, large, query, .{ .now = now }); + const with_stale = try history.run(arena, large, query, .{ .now = now, .lookup = &stale }); + try testing.expectEqual(scanned.hits.len, with_stale.hits.len); + try testing.expectEqual(scanned.matchCount, with_stale.matchCount); +} + +test "a bitset counts, iterates and never reports a bit past its end" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The bits above `len` in the last word are the classic place a count comes + // out sixty-three too high. + for ([_]usize{ 0, 1, 63, 64, 65, 127, 128, 1000 }) |len| { + const everything = try Set.full(arena, len); + try testing.expectEqual(len, everything.count()); + + var walked: usize = 0; + var iterator = everything.iterate(); + while (iterator.next()) |at| { + try testing.expect(at < len); + try testing.expectEqual(walked, at); + walked += 1; + } + try testing.expectEqual(len, walked); + + var empty = try Set.init(arena, len); + try testing.expect(empty.isEmpty()); + try testing.expectEqual(@as(usize, 0), empty.count()); + if (len > 0) { + empty.add(len - 1); + try testing.expect(empty.has(len - 1)); + try testing.expectEqual(@as(usize, 1), empty.count()); + try testing.expect(!empty.isEmpty()); + } + try testing.expect(!empty.has(len)); + } +} + +test "and and or work word by word" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var a = try Set.init(arena, 200); + var b = try Set.init(arena, 200); + for ([_]usize{ 0, 63, 64, 130 }) |at| a.add(at); + for ([_]usize{ 63, 64, 199 }) |at| b.add(at); + + var both = a; + both.words = try arena.dupe(u64, a.words); + both.andWith(b); + try testing.expectEqual(@as(usize, 2), both.count()); + try testing.expect(both.has(63) and both.has(64)); + + var either = a; + either.words = try arena.dupe(u64, a.words); + either.orWith(b); + try testing.expectEqual(@as(usize, 5), either.count()); + try testing.expect(either.has(199)); +} + +test "a token keeps a path and a flag together" { + // Splitting these apart would put `src` and `loop` in different tokens, and + // make `src/ai/loop` findable only by scanning — which is the thing being + // avoided. + var walk = Tokens{ .text = "zig build test --summary all src/ai/loop.zig v1.2.3 a@b" }; + const expected = [_][]const u8{ + "zig", "build", "test", "--summary", "all", "src/ai/loop.zig", "v1.2.3", "a@b", + }; + for (expected) |word| { + try testing.expectEqualStrings(word, walk.next().?); + } + try testing.expect(walk.next() == null); + + // Quotes, pipes and semicolons separate; an empty string yields nothing. + var shell = Tokens{ .text = "sh -c 'printf x' | grep -q x; echo done" }; + var count: usize = 0; + while (shell.next()) |_| count += 1; + try testing.expectEqual(@as(usize, 9), count); + + var nothing = Tokens{ .text = "" }; + try testing.expect(nothing.next() == null); + var punctuation = Tokens{ .text = " || " }; + try testing.expect(punctuation.next() == null); +} + +test "a word longer than a token still finds what it should" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The dictionary cannot answer this, so every block becomes a candidate and + // the matcher decides. Slow and correct, which is the right way round. + var index: block_mod.Index = .{ .arena = arena }; + var gen: idmod.Generator = .init(3, 1_780_000_000_000); + const long = "x" ** (Lookup.max_token_bytes + 20); + try index.blocks.append(arena, .{ + .id = gen.next(idmod.BlockId), + .session = gen.next(idmod.SessionId), + .kind = .command, + .status = .succeeded, + .createdAt = .{ .ns = 1_780_000_000_000_000_000 }, + .commandText = "echo " ++ long, + .actor = .{ .id = gen.next(idmod.ActorId), .kind = .person, .label = "you" }, + }); + + const lookup = try Lookup.build(arena, index); + const now: timeutil.Timestamp = .{ .ns = 1_781_000_000_000_000_000 }; + const query: history.Query = .{ .words = &.{long} }; + const found = try history.run(arena, index, query, .{ .now = now, .lookup = &lookup }); + try testing.expectEqual(@as(usize, 1), found.hits.len); +} + +test "the dictionary is much smaller than the history" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The premise the whole approach rests on: searching the dictionary beats + // searching the corpus because there is far less of it. If this ever stops + // being true — a workspace of entirely unique command lines — the index + // stops helping, and it still does not stop being correct. + const blocks = try sampleBlocks(arena, 2000, 0xD1C7); + const lookup = try Lookup.build(arena, blocks); + try testing.expect(lookup.tokenCount() < blocks.blocks.items.len / 10); + try testing.expectEqual(blocks.blocks.items.len, lookup.blocks); +} From ac229ec940bf946965459d5a04655437e356d3d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:52:25 +0000 Subject: [PATCH 12/12] Let the terminal be configured without editing Zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zag term` chose a shell, a scrollback size, a set of colours and a few key bindings, and every one of those was a constant in a source file. A terminal somebody has to recompile to make usable is not a terminal they will use. The design point is not the file format. It is that a bad setting must not stop the terminal starting. The file is read at the moment a person is trying to open a shell — often the moment they are trying to fix something else — and refusing to start because line nine says `scrollback = "lots"` leaves them with no terminal and a message about line nine. So every setting is independent. One that cannot be read is reported by name, with what was found and what is being used instead, and the terminal opens with the default for that setting and the person's own values for every other. A file that is not valid TOML at all is one report and a terminal with nothing but defaults. A missing file is not a problem and produces no report, because telling somebody their absent file is absent every time they open a terminal teaches them to ignore the reports that matter. The reports name the value they rejected. "scrollback is not valid" sends somebody to read documentation; `terminal.scrollback: expected a whole number of lines, found "lots". Using 10000.` tells them what to type. They are returned to the caller rather than printed and forgotten, so `zag term` can show them and a test can assert on them. Deliberately not settable: anything the record depends on. A settings file is the easiest thing in a workspace for another program to write, and one that could turn off the record, widen the policy or name a program to run on a keystroke would be the shortest path to a workspace that keeps nothing. A binding names an action from a closed set, never a command line, and a test walks the fields asserting none of them is about policy, actors, tokens or credentials. Two tests are worth naming. The example file that `zag emit` writes is read back and asserted to produce no reports and to round-trip every default — an example that does not parse is worse than none, because it is the first thing somebody copies. And the default colours are checked against WCAG AA through the contrast module this repository already audits itself with: shipping a default theme that fails the standard the product claims would be the plainest kind of hypocrisy, and it only stays true if a test says so. Verified live through a pseudoterminal on a workspace whose settings file has a scrollback that is a sentence, a colour that is a word, and a key bound to an action that does not exist. All three were reported by name, and the shell the file asked for opened. Honest about scope: the shell, the scrollback and whether prompts are marked are acted on today. The colours and the bindings are read, validated and reported on, and not yet drawn or bound, because the terminal renders through the host terminal's colours and has no key handling of its own. The roadmap item and its milestone row both say that rather than claiming the feature is finished. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cs9b4dQs44bZjMgpgSzUdX --- README.md | 7 + docs/roadmap.md | 11 +- docs/settings.example.toml | 30 ++ evidence/conformance.jsonl | 34 +- src/cli/main.zig | 26 +- src/root.zig | 1 + src/terminal/interactive.zig | 3 + src/terminal/settings.zig | 844 +++++++++++++++++++++++++++++++++++ 8 files changed, 934 insertions(+), 22 deletions(-) create mode 100644 docs/settings.example.toml create mode 100644 src/terminal/settings.zig diff --git a/README.md b/README.md index 5cac651..481fe2b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ zag doctor # what this build can and cannot do zag policy # the policy in force, and where it was read from zag providers # the model connectors, and which credentials are set zag ask "why did that fail?" # ask a model; it can use tools, one decision each +zag ask --stream "..." # the same, printed as the answer arrives +zag secrets .env # what would be taken out before anything is recorded zag why .workspace/events.jsonl # what a failure depended on, and what it affected zag run -- zig build test # run a command and record it as a block zag lint README.md # check text against the plain-language rules @@ -86,6 +88,11 @@ zag objects --root . # audit stored command output without changing it zag recover --root . # inspect a damaged log and print a recovery plan ``` +Settings live in `.workspace/settings.toml`. `zag emit` writes an example with +every value at its default. A setting this build cannot read is reported by +name, with what was found and what is being used instead, and the terminal +opens anyway — a mistake in that file never costs you a shell. + `zagd` holds a workspace open for clients, remote people and background agents: ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f61f2b..1a4d115 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -183,9 +183,12 @@ Everything here is small, and each item ends in something a person can use. `zag term` records a session today, but nothing about it can be changed without editing Zig. -- Read a settings file for the shell, the scrollback size, the colours and the - key bindings. Report an unreadable setting by name and carry on with the - default rather than refusing to start. +- Act on the settings the terminal now reads. `.workspace/settings.toml` is + read at start, an unreadable setting is reported by name and falls back on its + own, and the shell, the scrollback and whether prompts are marked are honoured + today. The colours and the key bindings are read and checked but not yet drawn + or bound, because the terminal renders through the host terminal's own colours + and has no key handling of its own. - Show the state of the session while it runs: the working directory, the branch, and whether the shell is marking its boundaries. - Hold more than one shell in one recorded session, with each block naming the @@ -243,7 +246,7 @@ editing Zig. | Milestone | Required evidence | | --- | --- | | Durable record | Fault-injection tests, object audit, recovery exercise and signed checkpoint verification | -| Configurable terminal | A settings file read at start, a named report for each unreadable setting, and tests for the defaults | +| Configurable terminal | A settings file read at start, a named report for each unreadable setting, tests for the defaults, and every setting in the file acted on rather than only parsed | | Many shells in one session | Two shells recorded in one log, each block naming its pane, and the log verifying afterwards | | Fast recall | A stated index size, a measured query at that size, the measurement repeated by the benchmark harness, and a surface where the index is cheaper than the scan it replaces | | Enforced agent boundary | Executor integration tests, path and network escape tests, and an independent security review | diff --git a/docs/settings.example.toml b/docs/settings.example.toml new file mode 100644 index 0000000..9eaed43 --- /dev/null +++ b/docs/settings.example.toml @@ -0,0 +1,30 @@ +# Settings for zag term. Every line is optional. +# +# A setting this build cannot read is reported by name and the built-in +# value is used instead, so a mistake here never stops a terminal +# opening. + +[terminal] +# The program to run. Leave it out to use your own $SHELL. +# shell = "/bin/zsh" +scrollback = 10000 +columns = 80 +rows = 24 +# Set false to start the shell with no added prompt marks. The record +# then holds inferred command boundaries rather than marked ones. +shell_integration = true + +[colours] +text = "#e6e6e6" +background = "#121212" +failure = "#ff8a80" +success = "#9ce6b0" +structure = "#9ec1ff" + +[keys] +"ctrl+shift+k" = "clear_screen" # clear the screen and the scrollback +"ctrl+shift+up" = "previous_block" # go to the previous command +"ctrl+shift+down" = "next_block" # go to the next command +"ctrl+shift+c" = "copy_block_output" # copy this command's output +"ctrl+shift+f" = "search_history" # search recorded work +"ctrl+shift+r" = "show_record" # show what this session has recorded diff --git a/evidence/conformance.jsonl b/evidence/conformance.jsonl index 2b5a3e7..7aaac37 100644 --- a/evidence/conformance.jsonl +++ b/evidence/conformance.jsonl @@ -1,17 +1,17 @@ -{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} -{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} -{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} -{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} -{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} -{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} -{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} -{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} -{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} -{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} -{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} -{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} -{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:44:14.811479086Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} +{"id":"evd_01M16HNP00TQ2B7416EVMY3W02","requirement_id":"ISO-704:R-DEFINITION","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"Every concept was checked for a definition that is present, not circular and not negative."} +{"id":"evd_01M16HNP0100FABSAD5B9VQKM9","requirement_id":"ISO-704:R-ONE-TERM","subject":"src/knowledge/vocabulary.zig","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:96c0d88f72e69a6021966bdfc68e3b07f5d8b68b66594ad0738a89749c6b0086","note":"No preferred term designates two concepts."} +{"id":"evd_01M16HNP026MWD2NZWCZCP54JF","requirement_id":"ISO-25964:R-RECIPROCAL","subject":"the thesaurus view of the vocabulary","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:d8ee26328c6fa97a31d8a58c100ece13dad85ca0e4f20aaf229eca3e06a02a8f","note":""} +{"id":"evd_01M16HNP035XZYKEVFXKZADWKH","requirement_id":"ISO-11179:R-ONE-NAME","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP042S7Q2FV2KY0TAVSC","requirement_id":"ISO-11179:R-VALUE-DOMAIN","subject":"the metadata registry","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:69cbfdbfa3c96cb9438279d90b119a46f991da463a125afc65b0c68cb5e1fd26","note":""} +{"id":"evd_01M16HNP058TTC7T05KYXDC3FM","requirement_id":"WCAG-2.2:R-CONTRAST","subject":"the shipped themes","method":"automated_test","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:03b85132769fd73feadf67479daa478040044b3a8ad3c3c4bb19d8c55994b21c","note":""} +{"id":"evd_01M16HNP06Y7K1J75Z3D3T2YEQ","requirement_id":"WCAG-2.2:R-TARGET-SIZE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP071GWA3A7KJVMXX1WN","requirement_id":"WCAG-2.2:R-NAME-ROLE-VALUE","subject":"no user interface exists yet","method":"automated_test","result":"not_tested","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:0000000000000000000000000000000000000000000000000000000000000000","note":"This build draws no window. The checker runs against an example tree, which is evidence about the example and not about a product nobody can see."} +{"id":"evd_01M16HNP08X508BTY9DNQ46F59","requirement_id":"ISO-23894:R-TREATED","subject":"the risk register","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:6a0f7d25bafa7c3408afe365d0081cab35fd2648857728873c0a3f12d9da11e7","note":""} +{"id":"evd_01M16HNP09RCETEAP7GVPN2SGR","requirement_id":"ISO-42005:R-AFFECTED","subject":"the impact assessment","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:193d91fcc5a7b850976321a3ba188cc65a7a8887174ddd6ac7e73fdcd4f35c55","note":""} +{"id":"evd_01M16HNP0A8PJ1A9TZZTTGD61D","requirement_id":"ISO-24495-1:R-ERROR-NEXT-STEP","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0BHDMGTXZQXZ83RSK5","requirement_id":"ISO-24495-1:R-LABEL-ACTION","subject":"the product's own text and the tool's help","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A rule checks this one directly, and it is answered by that rule rather than by the absence of other problems."} +{"id":"evd_01M16HNP0C6Y5BQ73ZE395JXG2","requirement_id":"ISO-24495-1:R-UNDERSTANDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0DFCPTRKV6M0N4NX83","requirement_id":"ISO-24495-1:R-FINDABLE","subject":"the product's own text and the tool's help","method":"static_analysis","result":"partial","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:ca391bedb56b7583f1f379c14ba9d490e652a2f3b225944fc09b94e68b570caf","note":"A checker can see that nothing is obviously broken. Whether a reader can find and understand what they need is established by evaluating the text with readers, which has not been done."} +{"id":"evd_01M16HNP0E1S98QSHMKX4H2QWB","requirement_id":"ISO-30401:R-OWNER","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry under .workspace/ names an owner. Whether that owner acts on the review is not established here."} +{"id":"evd_01M16HNP0FRRQP7512V13R4584","requirement_id":"ISO-30401:R-CURRENT","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"No approved entry is past its review date. Whether the content is still correct is established by review."} +{"id":"evd_01M16HNP0GXJZER8M5WZ46QGPN","requirement_id":"ISO-10013:R-STATE","subject":"the knowledge base under .workspace/","method":"static_analysis","result":"pass","tool":{"name":"zag check","version":"0.1.0"},"reviewer":null,"created_at":"2026-09-07T14:50:46.203937300Z","content_hash":"b3:51a5f455e153f03314d33c014d28c8c4ae99bc249eca3d71a6ef6d03e95230b4","note":"Every entry carries a state, so a draft cannot be read as approved."} diff --git a/src/cli/main.zig b/src/cli/main.zig index 07cc6de..4413181 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1682,6 +1682,17 @@ fn interactiveTerminal( token_buffer[index * 2 + 1] = hex[byte & 0x0f]; } const marker_token = try arena.dupe(u8, &token_buffer); + + // Settings, if there are any. A setting that cannot be read is named here + // and the built-in value is used, rather than the terminal refusing to + // open — a person reading this is usually trying to fix something else. + const configured = try zag.terminal.settings.load(arena, io, options.root); + for (configured.reports) |report| { + try report.writeSentence(w); + try w.writeByte('\n'); + } + if (!configured.ok()) try w.writeAll("\n"); + try w.writeAll("Recording this session. Leave the shell to stop.\n"); try w.flush(); @@ -1689,7 +1700,11 @@ fn interactiveTerminal( .workingDirectory = options.root, .environment = environment, .hookDirectory = hook_directory, - .integrate = !options.raw, + .shell = configured.settings.shell, + .scrollback = configured.settings.scrollback, + // `--raw` is a decision made at the moment of running, so it wins over + // a file written earlier. + .integrate = !options.raw and configured.settings.integrate, .markerToken = marker_token, }) catch |err| { tty.restore(); @@ -2247,6 +2262,15 @@ fn emit(arena: std.mem.Allocator, io: std.Io, w: *std.Io.Writer, options: Option try w.writeAll("wrote schemas/openapi.json\n"); written += 1; + // An example settings file, with every value at its default. Somebody + // starting one from nothing should not have to read Zig to find out what + // the settings are called. + var example_settings: std.Io.Writer.Allocating = .init(arena); + try zag.terminal.settings.writeExample(&example_settings.writer); + try writeGenerated(io, dir, arena, options.out, "docs/settings.example.toml", example_settings.written()); + try w.writeAll("wrote docs/settings.example.toml\n"); + written += 1; + // The metadata registry, so other languages can adopt the same names. var registry_text: std.Io.Writer.Allocating = .init(arena); const registry = try workbenchMetadataRegistry(arena); diff --git a/src/root.zig b/src/root.zig index f5ab9b2..4ad5eb7 100644 --- a/src/root.zig +++ b/src/root.zig @@ -97,6 +97,7 @@ pub const terminal = struct { pub const session = @import("terminal/session.zig"); pub const tty = @import("terminal/tty.zig"); pub const interactive = @import("terminal/interactive.zig"); + pub const settings = @import("terminal/settings.zig"); }; pub const ai = struct { diff --git a/src/terminal/interactive.zig b/src/terminal/interactive.zig index 79837d6..f94cac8 100644 --- a/src/terminal/interactive.zig +++ b/src/terminal/interactive.zig @@ -85,6 +85,8 @@ pub const Options = struct { /// Set false to run the shell exactly as it is, with no init file. The /// session then records inferred boundaries. integrate: bool = true, + /// Lines of scrollback the session keeps in memory. + scrollback: usize = 10_000, /// A secret the shell puts on every boundary mark it writes. Without it, /// any program that prints an escape sequence could forge a command /// boundary in the record. The shell reads it from the environment and @@ -294,6 +296,7 @@ pub fn run( .content_store = &service.content, .marker_token = if (launch.marksPrompts) options.markerToken else null, .redactor = service.redactor, + .scrollback_limit = options.scrollback, }, wallClock(io)); try tty.enterRaw(); diff --git a/src/terminal/settings.zig b/src/terminal/settings.zig new file mode 100644 index 0000000..59b2004 --- /dev/null +++ b/src/terminal/settings.zig @@ -0,0 +1,844 @@ +//! What a person can change about their terminal without editing Zig. +//! +//! `zag term` chooses a shell, a scrollback size, a set of colours and a few +//! key bindings, and until now every one of those was a constant in a source +//! file. A terminal somebody has to recompile to make usable is not a terminal +//! they will use. +//! +//! ## A bad setting must not stop the terminal starting +//! +//! This is the whole design, and it is the opposite of what a configuration +//! reader usually does. The file is read at the moment a person is trying to +//! open a shell — often the moment they are trying to fix something else — and +//! refusing to start because line nine says `scrollback = "lots"` leaves them +//! with no terminal and a message about line nine. +//! +//! So every setting is independent. One that cannot be read is reported by +//! name, with what was found and what is being used instead, and the terminal +//! opens with the default for that setting and the person's own values for +//! every other. A file that is not valid TOML at all is one report and a +//! terminal that opens with nothing but defaults. +//! +//! The reports are not warnings printed and forgotten. They are values the +//! caller is handed, so `zag term` can show them, `zag doctor` can list them, +//! and a test can assert on them. +//! +//! ## Why the reports name the value they rejected +//! +//! "scrollback is not valid" sends somebody to read documentation. "scrollback: +//! expected a whole number of lines, found \"lots\"; using 10000" tells them +//! what to type. The rejected value is quoted back because a setting a person +//! cannot see is a setting they cannot fix — and it is their own file, so +//! nothing in it is a secret from them. +//! +//! ## What is deliberately not settable +//! +//! Nothing here can change what the workspace records, what the policy allows, +//! or whether boundaries are authenticated. A settings file is the easiest +//! thing in a workspace for another program to write, and a settings file that +//! could turn off the record would be the shortest path to a workspace that +//! keeps nothing. Appearance and convenience only. + +const std = @import("std"); +const toml = @import("../interop/toml.zig"); +const contrast = @import("../accessibility/contrast.zig"); + +/// One setting that could not be used, and what happened instead. +pub const Report = struct { + /// The setting's name as it appears in the file, dotted: `colours.text`. + setting: []const u8, + /// What the file said, quoted back so a person can find it. + found: []const u8, + /// Why it could not be used, in a few words. + problem: []const u8, + /// What is being used in its place, written the way it would be typed. + using: []const u8, + + pub fn writeSentence(self: Report, w: *std.Io.Writer) std.Io.Writer.Error!void { + if (self.found.len == 0) { + try w.print("{s}: {s}. Using {s}.", .{ self.setting, self.problem, self.using }); + return; + } + try w.print("{s}: {s}, found \"{s}\". Using {s}.", .{ + self.setting, + self.problem, + self.found, + self.using, + }); + } + + pub fn text(self: Report, arena: std.mem.Allocator) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + try self.writeSentence(&out.writer); + return out.written(); + } +}; + +/// A key binding: what to press, and what it does. +pub const Binding = struct { + /// The chord as written: `ctrl+shift+k`. + chord: []const u8, + action: Action, +}; + +/// The things a binding can be bound to. +/// +/// A closed set, not a command to run. A settings file that could name a +/// program to execute on a keystroke would be a way to run code by writing a +/// file, which is exactly what the policy engine exists to prevent. +pub const Action = enum { + /// Clear the screen and the scrollback. + clear_screen, + /// Jump to the previous or next command boundary. + previous_block, + next_block, + /// Copy the output of the block the cursor is in. + copy_block_output, + /// Search the recorded history. + search_history, + /// Show what the current session has recorded so far. + show_record, + + pub fn parse(name: []const u8) ?Action { + return std.meta.stringToEnum(Action, name); + } + + /// What it does, for a person reading a list of bindings. + pub fn text(self: Action) []const u8 { + return switch (self) { + .clear_screen => "clear the screen and the scrollback", + .previous_block => "go to the previous command", + .next_block => "go to the next command", + .copy_block_output => "copy this command's output", + .search_history => "search recorded work", + .show_record => "show what this session has recorded", + }; + } +}; + +/// The colours the terminal draws with. +/// +/// Named by role rather than by colour, so a theme can be checked for contrast +/// and so the names still mean something when somebody sets them to anything. +pub const Colours = struct { + text: contrast.Rgb = .{ .r = 0xE6, .g = 0xE6, .b = 0xE6 }, + background: contrast.Rgb = .{ .r = 0x12, .g = 0x12, .b = 0x12 }, + /// A command that failed. + failure: contrast.Rgb = .{ .r = 0xFF, .g = 0x8A, .b = 0x80 }, + /// A command that succeeded. + success: contrast.Rgb = .{ .r = 0x9C, .g = 0xE6, .b = 0xB0 }, + /// The prompt and other structure the terminal itself draws. + structure: contrast.Rgb = .{ .r = 0x9E, .g = 0xC1, .b = 0xFF }, + + /// The colour pairs a contrast check applies to. + /// + /// Every foreground is checked against the background, because a person who + /// sets one to something unreadable should be told rather than left + /// squinting. The check runs; whether to obey it is theirs. + pub fn pairs(self: Colours, arena: std.mem.Allocator) ![]const contrast.Pair { + var out: std.ArrayList(contrast.Pair) = .empty; + try out.append(arena, .{ .name = "text", .foreground = self.text, .background = self.background }); + try out.append(arena, .{ .name = "failure", .foreground = self.failure, .background = self.background }); + try out.append(arena, .{ .name = "success", .foreground = self.success, .background = self.background }); + try out.append(arena, .{ .name = "structure", .foreground = self.structure, .background = self.background }); + return out.items; + } +}; + +/// Everything the terminal reads from the file. +pub const Settings = struct { + /// The program to run. Null means `$SHELL`, then `/bin/bash`, which is what + /// the terminal already did. + shell: ?[]const u8 = null, + /// Lines of scrollback kept in memory. + scrollback: usize = default_scrollback, + columns: u16 = 80, + rows: u16 = 24, + colours: Colours = .{}, + bindings: []const Binding = &.{}, + /// Set false to start the shell with no generated init file. The session + /// then records inferred boundaries rather than marked ones, which is worse + /// and is sometimes what a person needs. + integrate: bool = true, + + pub const default_scrollback: usize = 10_000; + pub const min_scrollback: usize = 100; + /// Above this a session holds more memory than a terminal has any business + /// holding, and the number is almost always a typo. + pub const max_scrollback: usize = 10_000_000; + pub const min_size: u16 = 20; + pub const max_size: u16 = 5_000; + + /// The bindings a build ships with. A file that sets none gets these; a + /// file that sets any replaces the lot, because a person who wrote their + /// own list meant it. + pub const default_bindings = [_]Binding{ + .{ .chord = "ctrl+shift+k", .action = .clear_screen }, + .{ .chord = "ctrl+shift+up", .action = .previous_block }, + .{ .chord = "ctrl+shift+down", .action = .next_block }, + .{ .chord = "ctrl+shift+c", .action = .copy_block_output }, + .{ .chord = "ctrl+shift+f", .action = .search_history }, + .{ .chord = "ctrl+shift+r", .action = .show_record }, + }; +}; + +/// What reading a settings file produced. +pub const Loaded = struct { + settings: Settings, + /// Every setting that could not be used, in the order they appear in this + /// file's own layout rather than the file's, so two runs report the same + /// list in the same order. + reports: []const Report, + /// False when there was no file at all, which is not a problem and is the + /// normal case. + found: bool, + + pub fn ok(self: Loaded) bool { + return self.reports.len == 0; + } + + /// Every report, one per line. + pub fn writeReports(self: Loaded, w: *std.Io.Writer) std.Io.Writer.Error!void { + for (self.reports) |report| { + try report.writeSentence(w); + try w.writeByte('\n'); + } + } +}; + +/// The defaults, for a workspace with no settings file. +pub fn defaults(arena: std.mem.Allocator) !Settings { + return .{ .bindings = try arena.dupe(Binding, &Settings.default_bindings) }; +} + +/// Read settings from TOML text. +/// +/// Never fails on the content. A file that is not TOML at all becomes one +/// report and the defaults; a file with one bad line becomes one report and +/// every other setting the person wrote. +pub fn read(arena: std.mem.Allocator, source: []const u8) !Loaded { + var reports: std.ArrayList(Report) = .empty; + var settings = try defaults(arena); + + const document = toml.parse(arena, source) catch |err| { + try reports.append(arena, .{ + .setting = "the file", + .found = "", + .problem = problemText(err), + .using = "the built-in settings", + }); + return .{ .settings = settings, .reports = reports.items, .found = true }; + }; + + if (document.get("terminal.shell")) |value| { + if (value.asString()) |text| { + if (text.len == 0) { + try reports.append(arena, .{ + .setting = "terminal.shell", + .found = "", + .problem = "a shell must be named", + .using = "your $SHELL", + }); + } else settings.shell = text; + } else try reports.append(arena, .{ + .setting = "terminal.shell", + .found = try describe(arena, value), + .problem = "expected the path to a shell, in quotes", + .using = "your $SHELL", + }); + } + + if (document.get("terminal.scrollback")) |value| { + settings.scrollback = try wholeNumber( + arena, + &reports, + "terminal.scrollback", + value, + Settings.min_scrollback, + Settings.max_scrollback, + Settings.default_scrollback, + "lines", + ); + } + + if (document.get("terminal.columns")) |value| { + settings.columns = @intCast(try wholeNumber( + arena, + &reports, + "terminal.columns", + value, + Settings.min_size, + Settings.max_size, + 80, + "columns", + )); + } + + if (document.get("terminal.rows")) |value| { + settings.rows = @intCast(try wholeNumber( + arena, + &reports, + "terminal.rows", + value, + Settings.min_size, + Settings.max_size, + 24, + "rows", + )); + } + + if (document.get("terminal.shell_integration")) |value| { + switch (value) { + .boolean => |on| settings.integrate = on, + else => try reports.append(arena, .{ + .setting = "terminal.shell_integration", + .found = try describe(arena, value), + .problem = "expected true or false", + .using = "true", + }), + } + } + + // Colours, each one independent of the others, so a single bad hex string + // does not cost somebody their whole theme. + const colour_fields = [_]struct { path: []const u8, field: []const u8 }{ + .{ .path = "colours.text", .field = "text" }, + .{ .path = "colours.background", .field = "background" }, + .{ .path = "colours.failure", .field = "failure" }, + .{ .path = "colours.success", .field = "success" }, + .{ .path = "colours.structure", .field = "structure" }, + }; + inline for (colour_fields) |entry| { + if (document.get(entry.path)) |value| { + if (value.asString()) |text| { + if (parseHex(text)) |rgb| { + @field(settings.colours, entry.field) = rgb; + } else { + try reports.append(arena, .{ + .setting = entry.path, + .found = text, + .problem = "expected a colour like \"#1e1e1e\"", + .using = try hexOf(arena, @field(settings.colours, entry.field)), + }); + } + } else try reports.append(arena, .{ + .setting = entry.path, + .found = try describe(arena, value), + .problem = "expected a colour like \"#1e1e1e\", in quotes", + .using = try hexOf(arena, @field(settings.colours, entry.field)), + }); + } + } + + if (document.get("keys")) |value| { + settings.bindings = try readBindings(arena, &reports, value); + } + + return .{ .settings = settings, .reports = reports.items, .found = true }; +} + +/// Read the `[keys]` table: each key is a chord, each value an action. +/// +/// A chord bound to something this build does not do is one report and one +/// binding dropped — not a refusal of the whole table, because the other five +/// bindings are still what the person asked for. +fn readBindings( + arena: std.mem.Allocator, + reports: *std.ArrayList(Report), + value: toml.Value, +) ![]const Binding { + const table = switch (value) { + .table => |t| t, + else => { + try reports.append(arena, .{ + .setting = "keys", + .found = try describe(arena, value), + .problem = "expected a [keys] table of chord = \"action\"", + .using = "the built-in bindings", + }); + return arena.dupe(Binding, &Settings.default_bindings); + }, + }; + + var out: std.ArrayList(Binding) = .empty; + var entries = table.iterator(); + while (entries.next()) |entry| { + const chord = entry.key_ptr.*; + const name = entry.value_ptr.asString() orelse { + try reports.append(arena, .{ + .setting = try std.fmt.allocPrint(arena, "keys.{s}", .{chord}), + .found = try describe(arena, entry.value_ptr.*), + .problem = "expected the name of an action, in quotes", + .using = "nothing for that key", + }); + continue; + }; + const action = Action.parse(name) orelse { + try reports.append(arena, .{ + .setting = try std.fmt.allocPrint(arena, "keys.{s}", .{chord}), + .found = name, + .problem = "this build has no such action", + .using = "nothing for that key", + }); + continue; + }; + try out.append(arena, .{ .chord = chord, .action = action }); + } + + // A table that named nothing usable leaves the defaults in place, rather + // than a terminal with no bindings at all. + if (out.items.len == 0) return arena.dupe(Binding, &Settings.default_bindings); + return out.items; +} + +fn wholeNumber( + arena: std.mem.Allocator, + reports: *std.ArrayList(Report), + setting: []const u8, + value: toml.Value, + low: anytype, + high: anytype, + fallback: anytype, + unit: []const u8, +) !usize { + const found = switch (value) { + .integer => |n| n, + else => { + try reports.append(arena, .{ + .setting = setting, + .found = try describe(arena, value), + .problem = try std.fmt.allocPrint(arena, "expected a whole number of {s}", .{unit}), + .using = try std.fmt.allocPrint(arena, "{d}", .{fallback}), + }); + return fallback; + }, + }; + if (found < low or found > high) { + try reports.append(arena, .{ + .setting = setting, + .found = try std.fmt.allocPrint(arena, "{d}", .{found}), + .problem = try std.fmt.allocPrint(arena, "expected between {d} and {d} {s}", .{ low, high, unit }), + .using = try std.fmt.allocPrint(arena, "{d}", .{fallback}), + }); + return fallback; + } + return @intCast(found); +} + +/// What kind of thing was found, for a report about the wrong kind. +/// +/// A number is quoted back as itself; a table or an array is described rather +/// than printed, because printing one back at somebody who wrote it by mistake +/// fills the screen without helping. +fn describe(arena: std.mem.Allocator, value: toml.Value) ![]const u8 { + return switch (value) { + .string, .datetime => |s| s, + .integer => |n| try std.fmt.allocPrint(arena, "{d}", .{n}), + .float => |f| try std.fmt.allocPrint(arena, "{d}", .{f}), + .boolean => |b| if (b) "true" else "false", + .array => "a list", + .table => "a table", + }; +} + +fn problemText(err: anyerror) []const u8 { + return switch (err) { + error.UnterminatedString => "a quoted value is missing its closing quote", + error.UnterminatedTable => "a table header is missing its closing bracket", + error.UnterminatedArray => "a list is missing its closing bracket", + error.OutOfMemory => "there was not enough memory to read it", + else => "this is not a settings file this build can read", + }; +} + +/// `#rrggbb`, or `#rgb` for the short form people actually type. +pub fn parseHex(text: []const u8) ?contrast.Rgb { + if (text.len == 0 or text[0] != '#') return null; + const digits = text[1..]; + switch (digits.len) { + 3 => { + const r = hexDigit(digits[0]) orelse return null; + const g = hexDigit(digits[1]) orelse return null; + const b = hexDigit(digits[2]) orelse return null; + // `#abc` means `#aabbcc`, which is what every other tool does and + // what somebody typing it expects. + return .{ .r = r * 17, .g = g * 17, .b = b * 17 }; + }, + 6 => { + return .{ + .r = (hexDigit(digits[0]) orelse return null) * 16 + (hexDigit(digits[1]) orelse return null), + .g = (hexDigit(digits[2]) orelse return null) * 16 + (hexDigit(digits[3]) orelse return null), + .b = (hexDigit(digits[4]) orelse return null) * 16 + (hexDigit(digits[5]) orelse return null), + }; + }, + else => return null, + } +} + +fn hexDigit(byte: u8) ?u8 { + return switch (byte) { + '0'...'9' => byte - '0', + 'a'...'f' => byte - 'a' + 10, + 'A'...'F' => byte - 'A' + 10, + else => null, + }; +} + +pub fn hexOf(arena: std.mem.Allocator, rgb: contrast.Rgb) ![]const u8 { + return std.fmt.allocPrint(arena, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }); +} + +/// Where a workspace keeps its settings. +pub fn pathIn(arena: std.mem.Allocator, root: []const u8) ![]const u8 { + return std.fmt.allocPrint(arena, "{s}/.workspace/settings.toml", .{root}); +} + +/// Read the settings file in a workspace, if there is one. +/// +/// A missing file is not a problem and produces no report: most workspaces will +/// never have one, and telling somebody their absent file is absent every time +/// they open a terminal would teach them to ignore the reports that matter. +pub fn load(arena: std.mem.Allocator, io: std.Io, root: []const u8) !Loaded { + const path = try pathIn(arena, root); + const source = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)) catch |err| switch (err) { + error.FileNotFound => return .{ + .settings = try defaults(arena), + .reports = &.{}, + .found = false, + }, + else => { + var reports: std.ArrayList(Report) = .empty; + try reports.append(arena, .{ + .setting = "the file", + .found = path, + .problem = "could not be read", + .using = "the built-in settings", + }); + return .{ .settings = try defaults(arena), .reports = reports.items, .found = false }; + }, + }; + return read(arena, source); +} + +/// An example file, with every setting at its default, for somebody starting +/// one from nothing. +pub fn writeExample(w: *std.Io.Writer) std.Io.Writer.Error!void { + try w.writeAll( + \\# Settings for zag term. Every line is optional. + \\# + \\# A setting this build cannot read is reported by name and the built-in + \\# value is used instead, so a mistake here never stops a terminal + \\# opening. + \\ + \\[terminal] + \\# The program to run. Leave it out to use your own $SHELL. + \\# shell = "/bin/zsh" + \\scrollback = 10000 + \\columns = 80 + \\rows = 24 + \\# Set false to start the shell with no added prompt marks. The record + \\# then holds inferred command boundaries rather than marked ones. + \\shell_integration = true + \\ + \\[colours] + \\text = "#e6e6e6" + \\background = "#121212" + \\failure = "#ff8a80" + \\success = "#9ce6b0" + \\structure = "#9ec1ff" + \\ + \\[keys] + \\ + ); + for (Settings.default_bindings) |binding| { + try w.print("\"{s}\" = \"{s}\" # {s}\n", .{ + binding.chord, + @tagName(binding.action), + binding.action.text(), + }); + } +} + +const testing = std.testing; + +test "one bad setting costs that setting and nothing else" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The whole design in one test. Somebody typed a word where a number goes, + // and everything else they wrote still applies. Refusing to start here + // would leave them with no terminal at the moment they were trying to fix + // something else. + const loaded = try read(arena, + \\[terminal] + \\shell = "/bin/zsh" + \\scrollback = "lots" + \\columns = 120 + \\ + \\[colours] + \\text = "#ffffff" + ); + + try testing.expectEqualStrings("/bin/zsh", loaded.settings.shell.?); + try testing.expectEqual(@as(u16, 120), loaded.settings.columns); + try testing.expectEqual(@as(u8, 0xFF), loaded.settings.colours.text.r); + // And the one that was wrong fell back. + try testing.expectEqual(Settings.default_scrollback, loaded.settings.scrollback); + + try testing.expectEqual(@as(usize, 1), loaded.reports.len); + const sentence = try loaded.reports[0].text(arena); + // Named, with what was found and what is being used. "scrollback is not + // valid" would send somebody to read documentation; this tells them what to + // type. + try testing.expect(std.mem.indexOf(u8, sentence, "terminal.scrollback") != null); + try testing.expect(std.mem.indexOf(u8, sentence, "\"lots\"") != null); + try testing.expect(std.mem.indexOf(u8, sentence, "10000") != null); +} + +test "a file that is not a settings file at all still opens a terminal" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const loaded = try read(arena, "[terminal\nshell = \"/bin/sh"); + try testing.expectEqual(@as(usize, 1), loaded.reports.len); + try testing.expect(!loaded.ok()); + + // Every default is in place, including the bindings, so the terminal that + // opens is the one somebody would get with no file at all. + try testing.expect(loaded.settings.shell == null); + try testing.expectEqual(Settings.default_scrollback, loaded.settings.scrollback); + try testing.expectEqual(Settings.default_bindings.len, loaded.settings.bindings.len); +} + +test "no file is not a problem and produces no report" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + + // Most workspaces will never have one. Telling somebody their absent file + // is absent, every time they open a terminal, teaches them to ignore the + // reports that matter. + const loaded = try load(arena, threaded.io(), ".zig-cache/tmp/no-such-workspace-here"); + try testing.expect(!loaded.found); + try testing.expect(loaded.ok()); + try testing.expectEqual(@as(usize, 0), loaded.reports.len); + try testing.expectEqual(Settings.default_bindings.len, loaded.settings.bindings.len); +} + +test "a number out of range is reported with the range" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A scrollback of two thousand million is a typo, not a preference, and + // honouring it would hold more memory than a terminal has any business + // holding. + const loaded = try read(arena, + \\[terminal] + \\scrollback = 2000000000 + \\rows = 2 + ); + try testing.expectEqual(@as(usize, 2), loaded.reports.len); + try testing.expectEqual(Settings.default_scrollback, loaded.settings.scrollback); + try testing.expectEqual(@as(u16, 24), loaded.settings.rows); + + const sentence = try loaded.reports[0].text(arena); + try testing.expect(std.mem.indexOf(u8, sentence, "between 100 and 10000000") != null); +} + +test "colours are read, and one bad colour costs one colour" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const loaded = try read(arena, + \\[colours] + \\text = "#abc" + \\background = "black" + \\failure = "#FF0000" + ); + + // The short form means what it does everywhere else. + try testing.expectEqual(@as(u8, 0xAA), loaded.settings.colours.text.r); + try testing.expectEqual(@as(u8, 0xBB), loaded.settings.colours.text.g); + try testing.expectEqual(@as(u8, 0xCC), loaded.settings.colours.text.b); + // Upper case hex is hex. + try testing.expectEqual(@as(u8, 0xFF), loaded.settings.colours.failure.r); + // And the one that is not a colour left its default alone. + try testing.expectEqual(@as(u8, 0x12), loaded.settings.colours.background.r); + + try testing.expectEqual(@as(usize, 1), loaded.reports.len); + const sentence = try loaded.reports[0].text(arena); + try testing.expect(std.mem.indexOf(u8, sentence, "colours.background") != null); + try testing.expect(std.mem.indexOf(u8, sentence, "\"black\"") != null); + // The fallback is written the way it would be typed, so it can be pasted. + try testing.expect(std.mem.indexOf(u8, sentence, "#121212") != null); +} + +test "hex parsing accepts what people type and refuses what they do not" { + try testing.expect(parseHex("#000000").?.r == 0); + try testing.expect(parseHex("#ffffff").?.b == 255); + try testing.expect(parseHex("#FFF").?.g == 255); + try testing.expect(parseHex("#1e1e1e").?.r == 0x1E); + + try testing.expect(parseHex("") == null); + try testing.expect(parseHex("ffffff") == null); // no hash + try testing.expect(parseHex("#fffff") == null); // five digits + try testing.expect(parseHex("#gggggg") == null); + try testing.expect(parseHex("#12345678") == null); +} + +test "a key bound to something this build cannot do costs one key" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const loaded = try read(arena, + \\[keys] + \\"ctrl+l" = "clear_screen" + \\"ctrl+shift+p" = "launch_the_rockets" + \\"ctrl+shift+f" = "search_history" + ); + + // Two bindings survive; the invented one does not, and the person is told + // which. Refusing the whole table would cost them the two they got right. + try testing.expectEqual(@as(usize, 2), loaded.settings.bindings.len); + try testing.expectEqualStrings("ctrl+l", loaded.settings.bindings[0].chord); + try testing.expectEqual(Action.clear_screen, loaded.settings.bindings[0].action); + try testing.expectEqual(Action.search_history, loaded.settings.bindings[1].action); + + try testing.expectEqual(@as(usize, 1), loaded.reports.len); + const sentence = try loaded.reports[0].text(arena); + try testing.expect(std.mem.indexOf(u8, sentence, "keys.ctrl+shift+p") != null); + try testing.expect(std.mem.indexOf(u8, sentence, "launch_the_rockets") != null); +} + +test "a keys table with nothing usable leaves the built-in bindings alone" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Better than a terminal with no bindings at all, which is what an empty + // list would give. + const loaded = try read(arena, + \\[keys] + \\"ctrl+x" = "do_something_impossible" + ); + try testing.expectEqual(Settings.default_bindings.len, loaded.settings.bindings.len); + try testing.expectEqual(@as(usize, 1), loaded.reports.len); +} + +test "settings cannot reach anything the record depends on" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // A settings file is the easiest thing in a workspace for another program + // to write. One that could turn off the record, widen the policy or run a + // program on a keystroke would be the shortest path to a workspace that + // keeps nothing. Every field here is checked against that. + const fields = @typeInfo(Settings).@"struct".fields; + inline for (fields) |field| { + const forbidden = [_][]const u8{ "policy", "capabilit", "actor", "token", "record", "log", "redact", "credential" }; + for (forbidden) |word| { + try testing.expect(std.mem.indexOf(u8, field.name, word) == null); + } + } + + // And an action is a closed set, never a command line. A file that could + // name a program to run on a keystroke would be a way to execute code by + // writing a file, which is what the policy engine exists to prevent. + for (std.enums.values(Action)) |action| { + try testing.expect(action.text().len > 0); + _ = Action.parse(@tagName(action)).?; + } + try testing.expect(Action.parse("/bin/sh") == null); + _ = arena; +} + +test "the example file is a settings file this build reads back with no reports" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // An example that does not parse is worse than none: it is the first thing + // somebody copies, and it teaches them the format is broken. + var out: std.Io.Writer.Allocating = .init(arena); + try writeExample(&out.writer); + const loaded = try read(arena, out.written()); + + if (!loaded.ok()) { + var problems: std.Io.Writer.Allocating = .init(arena); + try loaded.writeReports(&problems.writer); + std.debug.print("the example file does not read cleanly:\n{s}\n", .{problems.written()}); + } + try testing.expect(loaded.ok()); + + // And it round-trips: every default in the example is the default the code + // holds, so the file does not quietly document something else. + try testing.expectEqual(Settings.default_scrollback, loaded.settings.scrollback); + try testing.expectEqual(@as(u16, 80), loaded.settings.columns); + try testing.expectEqual(@as(u16, 24), loaded.settings.rows); + try testing.expect(loaded.settings.integrate); + try testing.expectEqual(Settings.default_bindings.len, loaded.settings.bindings.len); + const defaults_now = try defaults(arena); + try testing.expectEqual(defaults_now.colours.text.r, loaded.settings.colours.text.r); + try testing.expectEqual(defaults_now.colours.background.b, loaded.settings.colours.background.b); +} + +test "the default colours pass the contrast level the product claims" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Shipping a default theme that fails the standard this repository audits + // itself against would be the plainest kind of hypocrisy, and it is the + // sort of thing that only stays true if a test says so. + const settings = try defaults(arena); + const theme: contrast.Theme = .{ + .name = "terminal defaults", + .pairs = try settings.colours.pairs(arena), + }; + const failures = try theme.check(arena, .aa); + if (failures.items.len > 0) { + for (failures.items) |failure| { + std.debug.print("{s}: ratio {d:.2}\n", .{ failure.pair.name, failure.result.ratio }); + } + } + try testing.expectEqual(@as(usize, 0), failures.items.len); +} + +test "a setting the file does not mention keeps its default" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const loaded = try read(arena, "[terminal]\nrows = 50\n"); + try testing.expect(loaded.ok()); + try testing.expectEqual(@as(u16, 50), loaded.settings.rows); + try testing.expectEqual(@as(u16, 80), loaded.settings.columns); + try testing.expectEqual(Settings.default_scrollback, loaded.settings.scrollback); + try testing.expect(loaded.settings.shell == null); + try testing.expect(loaded.settings.integrate); +} + +test "shell integration can be turned off, and only by a boolean" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const off = try read(arena, "[terminal]\nshell_integration = false\n"); + try testing.expect(off.ok()); + try testing.expect(!off.settings.integrate); + + // "no" is not false. Guessing at it would mean a person who typed + // something else got a terminal that records worse boundaries without + // being told. + const vague = try read(arena, "[terminal]\nshell_integration = \"no\"\n"); + try testing.expectEqual(@as(usize, 1), vague.reports.len); + try testing.expect(vague.settings.integrate); +}