Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
# Normalize text files to LF in the repo, but pin line endings where the
# platform demands a specific one. POSIX shells choke on CRLF shebangs;
# Windows batch/PowerShell scripts expect CRLF.
* text=auto

*.zig text eol=lf
*.sh text eol=lf
*.ps1 text eol=crlf
*.bat text eol=crlf

*.png binary
*.ico binary
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [master]
branches: [main]
pull_request:
branches: [master]
branches: [main]

jobs:
build-test-smoke:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: Release
# Windows runner, smoke-tests the extracted binaries on Linux and macOS,
# generates checksums, and uploads everything to a draft GitHub Release.
#
# See docs/RELEASE_CHECKLIST.md for the human checklist that wraps this.
# See docs/guides/release-checklist.md for the human checklist that wraps this.

on:
push:
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ dist/

# Compiled binary output
bin/
!bin/.gitkeep

# Ignore any .o or .h files generated during build
*.o
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to ZiggyZag. The format is loosely [Keep a Changelog](https:

## [Unreleased] — Wave 2

### Fixed
- **AgentD could emit invalid JSON.** `appendJsonString` passed bytes ≥ 0x80 through verbatim, so a `file.read`/`rg.search`/`git.diff` over a binary or non-UTF-8 file produced an envelope that strict JSON parsers reject. It now validates each UTF-8 sequence and substitutes `U+FFFD` for malformed bytes (well-formed UTF-8 still passes through unchanged).
- **Unbounded reads when appending to history / redirect targets.** `writeHistoryFile` and the `>>` redirect append path read the existing file with no size cap while every other read path capped at `max_history_file_bytes`; both now use the same bound.
- **Desktop config validation was dead code.** `loadDesktopConfig` never called the existing `migrate()`/`validate()`, so an out-of-range value (font size, scrollback, pane count, orientation) loaded silently. They are now wired into the load path.
- **CI never ran.** `.github/workflows/ci.yml` triggered on `master`, but the default branch is `main`, so no push or PR ever exercised build/test/smoke. Pointed it at `main`.
- **macOS/BSD background jobs were never reaped.** `childHasExited` only implemented the Windows and Linux paths and fell through to `return false` elsewhere, so on macOS finished children lingered as zombies and `jobs` showed them Running forever. Added a libc `waitpid(WNOHANG)` branch that handles `ECHILD` without panicking.
- **Pipeline stages could double-execute.** `tryRunNativePipeline` detected an unsupported (redirected/empty) later stage only after earlier stages had already spawned, then fell back to the system shell and re-ran the whole line — running a non-idempotent first stage (e.g. `rm f | grep x > out`) twice. All stages are now validated before any run.
- **Desktop JSON value parser** mis-read a string value ending in a backslash (e.g. a Windows path `"C:\\dev\\"`) as unterminated. `jsonStringValue` now tracks escape state.
- `.gitattributes` now pins `*.sh`/`*.zig` to LF and `*.ps1`/`*.bat` to CRLF so a Windows checkout can't commit CRLF shebangs that break POSIX CI.

### Security
- **AgentD no longer passes the provider API key as a curl argv element.** The `Authorization: Bearer …` header was visible to any local user via `ps` / `/proc/<pid>/cmdline`. It is now written to a private `0600` temp file and passed via `curl -H @file`, then deleted.
- **AgentD provider responses are now redacted.** `raw_response`/`stderr` from the provider were emitted verbatim; they now pass through `redactSecretsAlloc` like the file/search/git tool outputs, matching the documented "redacted output" posture, and the envelope carries a `redacted` flag.
- An oversized JSON-lines input no longer tears down the AgentD session — it returns a structured `read_error` envelope and stops cleanly.

### Changed
- Platform support trimmed to the targets that actually build, link, and are tested: **Windows, Linux, macOS**. The FreeBSD/NetBSD/OpenBSD entries (which never linked — no libc was linked for them) were dropped from the support gates; the inert BSD code paths remain but are no longer advertised.
- README metrics corrected against the tree (Zig lines 18,395 → 18,850; unit tests 198 → 213).

### Added
- **Unified theme protocol v1.** A single `ZIGGYZAG_THEME` environment variable now drives both the desktop terminal palette and the shell prompt accent. The desktop sets it when spawning the shell child; the shell reads it at startup. See [`docs/reference/theme-protocol.md`](docs/reference/theme-protocol.md).
- `theme` shell builtin: `theme`, `theme list`, `theme <id>`, `theme --json`. Switches this session's active theme and reports the current one.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ Pick a theme in the desktop with `Ctrl+Shift+T`. The terminal palette AND the sh

| | |
| --- | --- |
| Zig lines | 18,395 |
| Unit tests | 198 (196 pass, 2 skip) |
| Zig lines | 18,850 |
| Unit tests | 213 (some skip on non-target platforms) |
| Binaries | 4 |
| Cross-built targets | 5 (windows-x86_64, linux-x86_64, linux-aarch64, macos-x86_64, macos-aarch64) |
| Third-party deps | 0 |
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ These are the protections shipped today. We expect to add more; new layers shoul
- **Path sandboxing for `file.read`**: lexical filter (no `..`, no absolute paths, no NTFS Alternate Data Stream syntax), then a non-following `lstat` walk to reject any intermediate symlink component, then a canonicalising `realpath` workspace-containment guard. Sibling-prefix escapes (`/ws-evil/...`), final-symlink escapes, and directory-symlink escapes are covered by tests.
- **Bounded output**: 64 KB per file read, 96 KB per command stdout, 24 KB per stderr; 16 KB max for `terminal.write` input.
- **Per-tool execution timeout** with a watchdog kill in the AgentD process tools.
- **Secret redaction** in tool output: `Authorization:` headers, Slack/GitHub/AWS-shaped tokens, PEM private-key blocks, and `KEY=value`-style secret-assignment patterns are scrubbed before the bytes leave AgentD.
- **Secret redaction** in tool output: `Authorization:` headers, Slack/GitHub/AWS-shaped tokens, PEM private-key blocks, and `KEY=value`-style secret-assignment patterns are scrubbed before the bytes leave AgentD. Provider responses (`raw_response`/`stderr`) pass through the same redaction.
- **Provider credentials never appear in process argv**: the provider API key (the `Authorization: Bearer …` header) is written to a private `0600` temp file and passed to `curl` via `-H @file`, then deleted — so it is not exposed to other local users through `ps` or `/proc/<pid>/cmdline`.
- **OSC consumption**: the terminal core consumes non-777 OSC strings (BEL/ST/CAN/SUB-terminated) without acting on their payloads, so a hostile remote process cannot use OSC titles or clipboard sequences to drive UI state.
- **No `@panic` calls** anywhere in the source. Errors propagate; the process does not crash on user input.
- **No `std.debug.assert` calls** that would be compiled out — invariants are explicit `return error.X` instead.
Expand Down
2 changes: 1 addition & 1 deletion apps/agentd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Responses are envelopes:

```json
{"id":0,"ok":true,"result":{"provider":"ollama","model":"qwen2.5-coder:1.5b","endpoint":"http://127.0.0.1:11434/api/chat","health_endpoint":"http://127.0.0.1:11434/api/tags","stream":false,"curl":"available","api_key":"not_required","provider_status":"reachable","ready":true}}
{"id":2,"ok":false,"error":{"code":"UnknownTool","message":"unknown tool name"}}
{"id":2,"ok":false,"error":{"code":"unknown_tool","message":"unknown tool name"}}
```

`agent/health` is a readiness check, not just a config echo. It reports `curl`, `api_key`, `provider_status`, and `ready`. Expected `provider_status` values include `reachable`, `unreachable`, `missing_api_key`, and `curl_unavailable`.
Expand Down
43 changes: 36 additions & 7 deletions apps/agentd/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,18 @@ fn runStdio(
var stdin_buffer: [4096]u8 = undefined;
var stdin = std.Io.File.stdin().readerStreaming(io, &stdin_buffer);
while (true) {
const line = (try stdin.interface.takeDelimiter('\n')) orelse break;
// A line longer than stdin_buffer (or an underlying read failure) would
// otherwise propagate out and tear down the whole JSON-lines session on
// a single bad input. Emit a structured error and stop cleanly — with a
// fixed buffer we can't safely resume mid-line, and continuing would
// re-hit the full buffer in a tight loop.
const maybe_line = stdin.interface.takeDelimiter('\n') catch |err| {
const json = agentd.protocol.writeErrorEnvelope(allocator, "", "read_error", @errorName(err)) catch break;
writer.writeAll(json) catch {};
allocator.free(json);
break;
};
const line = maybe_line orelse break;
const request_line = std.mem.trim(u8, line, " \t\r");
if (request_line.len == 0) continue;
var request = agentd.protocol.parseRequestAlloc(allocator, request_line) catch |err| {
Expand Down Expand Up @@ -237,6 +248,15 @@ fn providerAgentRunJsonAlloc(
const call = try callProviderCurl(allocator, io, env, config, endpoint, body);
defer call.deinit(allocator);

// Provider stdout/stderr can echo secret-shaped strings (a key pasted into
// a prompt, an auth header bounced back in an error body). Redact both
// before they enter the envelope, matching the file.read / rg.search /
// git.diff tool paths and the "redacted output" posture in the README.
const response_redacted = try agentd.tools.redactSecretsAlloc(allocator, call.stdout);
defer response_redacted.deinit(allocator);
const stderr_redacted = try agentd.tools.redactSecretsAlloc(allocator, call.stderr);
defer stderr_redacted.deinit(allocator);

var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(allocator);
try out.appendSlice(allocator, "{\"provider\":");
Expand All @@ -248,9 +268,11 @@ fn providerAgentRunJsonAlloc(
try out.appendSlice(allocator, ",\"status\":");
try agentd.protocol.appendJsonString(allocator, &out, if (call.status == 0) "ok" else "provider_error");
try out.appendSlice(allocator, ",\"raw_response\":");
try agentd.protocol.appendJsonString(allocator, &out, call.stdout);
try agentd.protocol.appendJsonString(allocator, &out, response_redacted.text);
try out.appendSlice(allocator, ",\"stderr\":");
try agentd.protocol.appendJsonString(allocator, &out, call.stderr);
try agentd.protocol.appendJsonString(allocator, &out, stderr_redacted.text);
try out.appendSlice(allocator, ",\"redacted\":");
try out.appendSlice(allocator, if (response_redacted.changed or stderr_redacted.changed) "true" else "false");
try out.append(allocator, '}');
return out.toOwnedSlice(allocator);
}
Expand Down Expand Up @@ -307,8 +329,14 @@ fn callProviderCurl(
) !ProviderCall {
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(allocator);
var auth_header: ?[]u8 = null;
defer if (auth_header) |header| allocator.free(header);
// The bearer token goes through a private 0600 temp file passed as
// `-H @path`, never as an argv element — see writeAuthHeaderFile. Both the
// file and the `@path` argument live until this function returns, by which
// point curl has already run to completion.
var auth_file: ?agentd.provider.AuthHeaderFile = null;
defer if (auth_file) |f| f.deinit(allocator, io);
var auth_arg: ?[]u8 = null;
defer if (auth_arg) |a| allocator.free(a);

try argv.appendSlice(allocator, &.{
"curl",
Expand All @@ -322,8 +350,9 @@ fn callProviderCurl(
});
if (config.kind == .openai_compatible) {
const api_key = config.api_key orelse return error.MissingApiKey;
auth_header = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{api_key});
try argv.appendSlice(allocator, &.{ "-H", auth_header.? });
auth_file = try agentd.provider.writeAuthHeaderFile(allocator, io, env, api_key);
auth_arg = try std.fmt.allocPrint(allocator, "@{s}", .{auth_file.?.path});
try argv.appendSlice(allocator, &.{ "-H", auth_arg.? });
}
try argv.appendSlice(allocator, &.{ "-d", "@-", endpoint });

Expand Down
79 changes: 61 additions & 18 deletions apps/agentd/src/protocol.zig
Original file line number Diff line number Diff line change
Expand Up @@ -186,25 +186,48 @@ pub fn writeErrorEnvelope(

pub fn appendJsonString(allocator: Allocator, out: *std.ArrayList(u8), text: []const u8) !void {
try out.append(allocator, '"');
for (text) |byte| {
switch (byte) {
'"' => try out.appendSlice(allocator, "\\\""),
'\\' => try out.appendSlice(allocator, "\\\\"),
'\n' => try out.appendSlice(allocator, "\\n"),
'\r' => try out.appendSlice(allocator, "\\r"),
'\t' => try out.appendSlice(allocator, "\\t"),
0x08 => try out.appendSlice(allocator, "\\b"),
0x0c => try out.appendSlice(allocator, "\\f"),
else => {
if (byte < 0x20) {
var buf: [6]u8 = undefined;
const escaped = std.fmt.bufPrint(&buf, "\\u{x:0>4}", .{byte}) catch unreachable;
try out.appendSlice(allocator, escaped);
} else {
try out.append(allocator, byte);
}
},
var i: usize = 0;
while (i < text.len) {
const byte = text[i];
if (byte < 0x80) {
switch (byte) {
'"' => try out.appendSlice(allocator, "\\\""),
'\\' => try out.appendSlice(allocator, "\\\\"),
'\n' => try out.appendSlice(allocator, "\\n"),
'\r' => try out.appendSlice(allocator, "\\r"),
'\t' => try out.appendSlice(allocator, "\\t"),
0x08 => try out.appendSlice(allocator, "\\b"),
0x0c => try out.appendSlice(allocator, "\\f"),
else => {
if (byte < 0x20) {
var buf: [6]u8 = undefined;
const escaped = std.fmt.bufPrint(&buf, "\\u{x:0>4}", .{byte}) catch unreachable;
try out.appendSlice(allocator, escaped);
} else {
try out.append(allocator, byte);
}
},
}
i += 1;
continue;
}
// A multibyte lead byte. Well-formed UTF-8 is legal verbatim inside a
// JSON string, but tool output (file.read / rg.search / git.diff) can
// carry binary or non-UTF-8 bytes; emitting those raw produces an
// envelope that strict JSON parsers reject. Validate each sequence and
// substitute U+FFFD for anything malformed so the envelope stays valid.
const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch {
try out.appendSlice(allocator, "\u{fffd}");
i += 1;
continue;
};
if (i + seq_len > text.len or !std.unicode.utf8ValidateSlice(text[i .. i + seq_len])) {
try out.appendSlice(allocator, "\u{fffd}");
i += 1;
continue;
}
try out.appendSlice(allocator, text[i .. i + seq_len]);
i += seq_len;
}
try out.append(allocator, '"');
}
Expand Down Expand Up @@ -372,6 +395,26 @@ test "escapes JSON strings" {
try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\n\"", out.items);
}

test "passes valid UTF-8 through and replaces invalid bytes with U+FFFD" {
var out: std.ArrayList(u8) = .empty;
defer out.deinit(std.testing.allocator);

// Well-formed multibyte UTF-8 (é = C3 A9) is legal verbatim in a JSON string.
try appendJsonString(std.testing.allocator, &out, "caf\u{e9}");
try std.testing.expectEqualStrings("\"caf\u{e9}\"", out.items);

// A lone continuation byte (0x80) is invalid UTF-8 and would otherwise make
// the whole envelope unparseable; it must collapse to U+FFFD.
out.clearRetainingCapacity();
try appendJsonString(std.testing.allocator, &out, "a\x80b");
try std.testing.expectEqualStrings("\"a\u{fffd}b\"", out.items);

// A truncated sequence at end-of-input (lead byte with no continuation).
out.clearRetainingCapacity();
try appendJsonString(std.testing.allocator, &out, "x\xc3");
try std.testing.expectEqualStrings("\"x\u{fffd}\"", out.items);
}

test "error envelope has consistent shape with ok false and snake_case code" {
const envelope = try writeErrorEnvelope(std.testing.allocator, "42", "unknown_method", "no such method");
defer std.testing.allocator.free(envelope);
Expand Down
Loading
Loading