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
35 changes: 29 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,29 @@ Plaintext shape of `Policy` (what `cr policy show` reveals):
.{
.allowed_callers = .{
"/usr/local/bin/cr",
"/usr/local/bin/claude",
// optional @sha256=<hex> pins the caller to an exact binary image
"/usr/local/bin/claude@sha256=<64 hex>",
},
.idle_timeout_ms = 900000,
.tasks = .{
.{ .name = "claude-task", .allowed_secrets = .{ "ANTHROPIC_API_KEY" } },
.{
.name = "claude-task",
.allowed_secrets = .{ "ANTHROPIC_API_KEY" },
// optional spawn rate limit (0 = unlimited)
.max_spawns = 0,
.window_ms = 60000,
},
},
}
```

Security hardening layers (see CLI above):
- **Caller hash pinning** — `allowed_callers` entries may carry `@sha256=<hex>`; the service hashes the caller binary at connect time and rejects a mismatch (`cr policy allow PATH --pin`).
- **Spawn rate limiting** — `Task.max_spawns`/`window_ms` bound how often a task's secrets can be pulled into a subprocess.
- **Secret TTL** — `cr secrets set KEY --ttl SECONDS`; an expired secret is treated as missing at injection time and never reaches a child.
- **Per-project socket (POSIX)** — `/tmp/cora-<uid>-<cwdhash>.sock`; two projects unlock at once. `CORA_SOCK` overrides.
- **Rekey / recovery** — `cr rekey` rotates the passphrase; `cr recovery backup|restore` is a break-glass second passphrase (point-in-time encrypted copy).

Decrypted secrets block (never on disk in this form):

```json
Expand All @@ -256,19 +270,28 @@ cr # prints usage

# Setup
cr init [path] # default: ./cora.zon
cr rekey # change passphrase (re-encrypt, fresh salt)

# Recovery (break-glass second passphrase)
cr recovery backup # write <path>.recovery under a recovery passphrase
cr recovery restore # rebuild vault from recovery copy under a new passphrase

# Secret management (always re-encrypts)
cr secrets set KEY # prompts passphrase + value
cr secrets set KEY [--ttl SECONDS] # prompts passphrase + value; optional expiry
cr secrets list # prompts passphrase → names only
cr secrets delete KEY # prompts passphrase
cr secrets import --from-env NAME... # import values from the environment

# Policy (re-encrypts on each mutation)
cr policy show # callers + idle + tasks
cr policy allow PATH # add binary to allowed_callers
cr policy show # callers + idle + tasks + rate limits
cr policy allow PATH [--pin] # add caller; --pin commits to its SHA-256
cr policy deny PATH # remove
cr policy task add NAME SECRETS... # define/update task
cr policy task add NAME SECRETS... [--target PATH] [--max-spawns N] [--window-ms MS]
cr policy task remove NAME

# Service unit templates
cr daemon unit # print systemd/launchd unit for this OS

# Service lifecycle
cr unlock [--foreground] # daemonizes by default
cr lock # sends lock op → service zeros + exits
Expand Down
63 changes: 63 additions & 0 deletions src/audit.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,21 @@ pub const Event = union(enum) {
service_locked: struct { ts_ms: i64, reason: []const u8 },
caller_rejected: struct { ts_ms: i64, pid: i32, binary: []const u8, reason: []const u8 },
task_start: struct { ts_ms: i64, caller_pid: i32, caller_bin: []const u8, task: []const u8 },
/// Emitted once the spawn target has passed PATH resolution and the
/// allowed_targets gate and the child process exists. Records the
/// resolved absolute binary that actually ran plus its pid — the piece
/// missing from `task_start` (which fires at declare time, before the
/// target is known). No argv beyond argv[0]: later args can carry
/// caller-chosen data and are not audited to keep the log secret-free.
task_spawned: struct { ts_ms: i64, task: []const u8, target: []const u8, child_pid: i32 },
secret_injected: struct { ts_ms: i64, task: []const u8, secret_name: []const u8, target_pid: i32 },
secret_missing: struct { ts_ms: i64, task: []const u8, secret_name: []const u8, target_pid: i32 },
task_end: struct { ts_ms: i64, task: []const u8, exit_code: i32, duration_ms: i64 },
/// Emitted when a spawn is refused because the task exceeded its
/// `max_spawns` within `window_ms`. The caller passed identity, caller,
/// and target gates; what failed is the rate limit. No target field —
/// the spawn is rejected before target resolution.
rate_limited: struct { ts_ms: i64, caller_pid: i32, caller_bin: []const u8, task: []const u8 },
/// Emitted when the spawn target (argv[0] after PATH resolution) is
/// not in the task's `allowed_targets`. Distinct from `caller_rejected`
/// — the caller passed kernel identity verification and the task
Expand Down Expand Up @@ -139,6 +151,26 @@ pub fn encode(ev: Event, w: *Io.Writer) !void {
try s.objectField("task");
try s.write(v.task);
},
.task_spawned => |v| {
try s.objectField("ts_ms");
try s.write(v.ts_ms);
try s.objectField("task");
try s.write(v.task);
try s.objectField("target");
try s.write(v.target);
try s.objectField("child_pid");
try s.write(v.child_pid);
},
.rate_limited => |v| {
try s.objectField("ts_ms");
try s.write(v.ts_ms);
try s.objectField("caller_pid");
try s.write(v.caller_pid);
try s.objectField("caller_bin");
try s.write(v.caller_bin);
try s.objectField("task");
try s.write(v.task);
},
.secret_injected => |v| {
try s.objectField("ts_ms");
try s.write(v.ts_ms);
Expand Down Expand Up @@ -249,6 +281,37 @@ test "Event union no longer carries windows_preview_mode variant" {
try std.testing.expect(!@hasField(Event, "windows_preview_mode"));
}

test "encode rate_limited names task and caller, carries no value" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try encode(.{ .rate_limited = .{
.ts_ms = 71,
.caller_pid = 321,
.caller_bin = "/usr/local/bin/cr",
.task = "deploy",
} }, &aw.writer);
const out = aw.written();
try std.testing.expect(std.mem.indexOf(u8, out, "\"kind\":\"rate_limited\"") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\"task\":\"deploy\"") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "value") == null);
}

test "encode task_spawned records resolved target and pid" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try encode(.{ .task_spawned = .{
.ts_ms = 55,
.task = "deploy",
.target = "/usr/bin/gh",
.child_pid = 8080,
} }, &aw.writer);
const out = aw.written();
try std.testing.expect(std.mem.indexOf(u8, out, "\"kind\":\"task_spawned\"") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\"target\":\"/usr/bin/gh\"") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\"child_pid\":8080") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "value") == null);
}

test "encode secret_missing distinct from secret_injected" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
Expand Down
114 changes: 109 additions & 5 deletions src/cli/usage.zig
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,16 @@ const top_text =
\\
\\Subcommands:
\\ init [path] Create encrypted cora.zon
\\ rekey Change the passphrase
\\ recovery <backup|restore> Break-glass recovery passphrase
\\ unlock [--foreground] Start background service
\\ lock Stop service, zero memory
\\ status Show service state
\\ exec TASK -- argv... Spawn subprocess with task secrets injected
\\ secrets <set|list|delete> [KEY] Manage secrets
\\ secrets <set|list|delete|import> ... Manage secrets
\\ policy <show|allow|deny|task> ... Manage policy
\\ audit <tail|show> [opts] Read audit log
\\ daemon unit Print a service unit template
\\ tui Launch interactive TUI menu
\\ verify --pid PID Resolve binary path of a pid (debug)
\\ version Show version
Expand Down Expand Up @@ -287,6 +290,63 @@ const tui_text =

// --- verify ---------------------------------------------------------------

pub fn printRekey(io: Io, ch: Channel) void {
write(io, ch, rekey_text);
}

const rekey_text =
\\cr rekey — Change the passphrase
\\
\\Usage:
\\ cr rekey
\\
\\Prompts for the current passphrase, then a new one (twice). Decrypts the
\\vault and re-encrypts it under a freshly derived key with a new random
\\salt. The on-disk ciphertext changes completely, so a leaked old
\\passphrase can no longer open the file. Secrets and policy are preserved.
\\
;

pub fn printRecovery(io: Io, ch: Channel) void {
write(io, ch, recovery_text);
}

const recovery_text =
\\cr recovery — Break-glass recovery passphrase
\\
\\Usage:
\\ cr recovery backup Write cora.zon.recovery under a
\\ separate recovery passphrase
\\ cr recovery restore Rebuild cora.zon from the recovery
\\ copy under a new passphrase
\\
\\`backup` seals an independent encrypted copy of the vault so a lost
\\primary passphrase is recoverable. It is a point-in-time snapshot: secrets
\\changed after backup are not included until you re-run it. `restore`
\\overwrites cora.zon from the recovery copy.
\\
;

pub fn printDaemon(io: Io, ch: Channel) void {
write(io, ch, daemon_text);
}

const daemon_text =
\\cr daemon — Persistent-service unit templates
\\
\\Usage:
\\ cr daemon unit
\\
\\Prints a service unit for the current OS (systemd --user on Linux,
\\launchd LaunchAgent on macOS) that runs `cr unlock --foreground` from
\\the current directory. Cora does not auto-start — the unit still
\\prompts for the passphrase on start, keeping the key with the operator.
\\
\\ cr daemon unit > ~/.config/systemd/user/cora.service # Linux
\\ cr daemon unit > ~/Library/LaunchAgents/dev.cora.agent.plist # macOS
\\
;

pub fn printVerify(io: Io, ch: Channel) void {
write(io, ch, verify_text);
}
Expand Down Expand Up @@ -318,12 +378,13 @@ const secrets_text =
\\cr secrets — Manage secrets in cora.zon
\\
\\Usage:
\\ cr secrets <set|list|delete> [KEY]
\\ cr secrets <set|list|delete|import> [KEY]
\\
\\Actions:
\\ set KEY Add or update a secret (prompts for value)
\\ list List secret names (no values)
\\ delete KEY Remove a secret
\\ import --from-env NAME... Import values from the environment
\\
\\Each action prompts for the passphrase, decrypts cora.zon, mutates,
\\and re-encrypts. The on-disk file is never written in plaintext.
Expand All @@ -344,11 +405,17 @@ const secrets_set_text =
\\cr secrets set — Add or update a secret
\\
\\Usage:
\\ cr secrets set KEY
\\ cr secrets set KEY [--ttl SECONDS]
\\
\\Arguments:
\\ KEY Secret name (env var name)
\\
\\Flags:
\\ --ttl SECONDS Expire the secret this many
\\ seconds from now. After expiry the
\\ service refuses to inject it (it is
\\ treated as missing).
\\
\\Prompts for the passphrase, then for the secret value (echo masked).
\\
;
Expand Down Expand Up @@ -387,6 +454,30 @@ const secrets_delete_text =
\\
;

pub fn printSecretsImport(io: Io, ch: Channel) void {
write(io, ch, secrets_import_text);
}

const secrets_import_text =
\\cr secrets import — Import secrets from the environment
\\
\\Usage:
\\ cr secrets import --from-env NAME...
\\
\\Arguments:
\\ NAME... Environment variable names to
\\ import. Each value is read from
\\ your own environment and stored
\\ under the same name.
\\
\\Values come from the environment, never argv, so they do not leak into
\\shell history or `ps`. A NAME whose variable is unset is skipped.
\\
\\ export GH_TOKEN=ghp_...
\\ cr secrets import --from-env GH_TOKEN STRIPE_KEY
\\
;

// --- policy ---------------------------------------------------------------

pub fn printPolicy(io: Io, ch: Channel) void {
Expand Down Expand Up @@ -441,11 +532,16 @@ const policy_allow_text =
\\cr policy allow — Add a binary to allowed_callers
\\
\\Usage:
\\ cr policy allow PATH
\\ cr policy allow PATH [--pin]
\\
\\Arguments:
\\ PATH Absolute path of the caller binary
\\
\\Flags:
\\ --pin Pin the caller to the current
\\ SHA-256 of PATH. A swapped binary
\\ at the same path is then rejected.
\\
\\If allowed_callers is empty, the policy is in dev-mode (allow-all).
\\Adding any entry switches to explicit allow-list mode.
\\
Expand Down Expand Up @@ -493,7 +589,8 @@ const policy_task_add_text =
\\cr policy task add — Define a task
\\
\\Usage:
\\ cr policy task add NAME [--target PATH ...] [SECRETS...]
\\ cr policy task add NAME [--target PATH ...] [--max-spawns N]
\\ [--window-ms MS] [SECRETS...]
\\
\\Arguments:
\\ NAME Task name
Expand All @@ -507,6 +604,13 @@ const policy_task_add_text =
\\ allowed (dev mode) — same
\\ behavior as allowed_callers when
\\ its list is empty.
\\ --max-spawns N Cap spawns per window (0 = no
\\ limit, default). Bounds how often
\\ this task's secrets can be pulled
\\ into a subprocess.
\\ --window-ms MS Rate-limit window in ms
\\ (default 60000). Used with
\\ --max-spawns.
\\
\\Replaces any existing task with the same name.
\\
Expand Down
Loading
Loading