Skip to content
Merged
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,30 @@ The first five (`kcap-recap`, `kcap-errors`, `kcap-hide`, `kcap-disable`, `kcap-
>
> The `**.kcap.ai` wildcard covers every SaaS tenant — current and future — plus `auth.kcap.ai`, so switching profiles and adding tenants just work with no per-tenant edits. **Self-hosted** servers are added as exact-host entries, derived from every configured profile's `server_url` and refreshed on each `kcap setup`. Existing config is respected: if you already run a `network_proxy` policy, kcap's hosts are merged into your `domains` (yours preserved); if you've already opened the network (`network_access = true` with no proxy), nothing changes. Opt out with `--skip-codex-network-access` on either command (and the npm-postinstall `--if-installed` refresh never touches `config.toml`). A localhost dev server additionally needs `allow_local_binding = true`, which kcap does not set. Uninstall leaves these keys in place — they're your security posture, not kcap state.

The daemon starts Codex with `--sandbox workspace-write` and `--ask-for-approval on-request`. This lets Codex edit files in the agent's worktree but escalates sensitive operations (e.g. network calls, shell commands outside the worktree) through the daemon's permission bridge to the dashboard.
**Sandbox and approval posture.** By default the daemon starts Codex with `--sandbox workspace-write` and `--ask-for-approval on-request`. This lets Codex edit files in the agent's worktree but escalates sensitive operations (e.g. network calls, shell commands outside the worktree) through the daemon's permission bridge to the dashboard.

The daemon also **accepts** a caller-selected posture for an **interactive** hosted launch (one that runs in a daemon-created worktree). Nothing selects one yet: the dashboard selector ships with a later Capacitor **server** release, and until then every launch uses the defaults above. The selectable values are:

| Flag | Selectable values |
|---|---|
| `--sandbox` | `read-only`, `workspace-write` (default), `danger-full-access` |
| `--ask-for-approval` | `untrusted`, `on-request` (default), `never` |

Two cases are **not** selectable, because their values are containment guarantees rather than preferences:

- A **borrowed** launch runs in your own real checkout, so it is always `read-only` — anything else would let a hosted agent modify the repo you are working in.
- A **review-flow reviewer** runs unattended, so it is always `--ask-for-approval never` — any prompting mode would wedge the round waiting for an approval no human is there to give.

PR-review launches also keep a fixed posture. A launch that supplies a posture for any of those three cases is **rejected** with a coded error (`codex_posture_not_overridable`) rather than having it silently ignored or silently applied; an unknown value, a half-specified pair, or the upstream-deprecated `on-failure` is rejected as `codex_posture_invalid`.

Two selections weaken the protections you get by default, in different ways:

- `--ask-for-approval never` **disables the permission bridge for that agent** — it never asks the dashboard before acting, so nothing pauses for your approval.
- `--sandbox danger-full-access` **removes the filesystem sandbox** — the agent can read and write outside its worktree. (Approvals are unaffected: with `on-request` it still escalates through the bridge.)

The daemon logs a warning naming the agent whenever an interactive launch resolves to either.

The daemon advertises posture support to the server at connect. A server that offers the selector refuses it against a daemon too old to advertise support, rather than sending a posture that would be quietly dropped — so update kcap on the daemon host if the dashboard says the selector is unavailable. This is all separate from local `kcap agent start codex -- …`, where you pass Codex flags yourself and they are not restricted (see [Local agents](#local-agents-kcap-agent)).

> **Upgrading from an earlier version of kcap?** Run `kcap update` (or `npm install -g @kurrent/kcap`) — the npm postinstall hook, and `kcap update` itself, refresh all user-scope kcap installations, so you always pick up the current CLI version's skills (`~/.agents/skills/kcap-*`), Codex hook commands (`~/.codex/hooks.json`), and Claude plugin registration (`~/.claude/settings.json`). Each refresh is gated on a marker file written by your previous setup — fresh systems that never opted in are left untouched. Project-scope installs (`--project`) are not auto-refreshed; re-run `kcap plugin install [--codex] --project` after upgrading if you want the latest config for a specific repo.
>
Expand All @@ -727,7 +750,7 @@ The daemon starts Codex with `--sandbox workspace-write` and `--ask-for-approval
>
> **Recovery:** upgrade kcap, then end the harness session and start a fresh one — or, in harnesses that expose it, reconnect the `kcap-flows` MCP server. To confirm the new schema is live, check that your client lists the server as connected (`claude mcp list`, `codex mcp get kcap-flows`, `gemini mcp list`, `cursor-agent mcp list`, `opencode mcp list`) and that `start_review_flow` now advertises `vendor`.

PR review is supported for hosted Codex agents as well as Claude — the same `kcap-review` MCP context is injected either way. The sandbox and approval-mode selectors in the launch dialog are planned as a follow-up (AI-633).
PR review is supported for hosted Codex agents as well as Claude — the same `kcap-review` MCP context is injected either way.

#### Cursor IDE hooks

Expand Down
28 changes: 25 additions & 3 deletions src/Capacitor.Cli.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1298,9 +1298,18 @@ public readonly record struct LaunchAgentCommand(
// wire-compatible with older daemons (ignore it) and older servers (never set it). The daemon
// launches with the exact LaunchModel VERBATIM and, post-launch, reports the concrete resolved
// model back keyed on LaunchAttemptId (see ExplicitReviewerModelResolvedV1).
ExplicitReviewerModelLaunch? ExplicitReviewerModel = null
ExplicitReviewerModelLaunch? ExplicitReviewerModel = null,
// Interactive Codex launches only; any other launch shape is rejected by CodexPosturePolicy.
// Appended last so the wire stays compatible with older daemons and servers.
CodexLaunchPosture? CodexPosture = null
);

/// <summary>Caller-selected Codex launch posture. Valid ONLY for interactive, daemon-owned-worktree
/// launches (<see cref="LaunchKind.Default"/> and not borrowed); the daemon fails closed on any other
/// launch shape. Both fields are required — a partial block is malformed. Values are the Codex CLI's
/// own lowercase tokens, compared ordinally (see the daemon's CodexPosturePolicy).</summary>
public sealed record CodexLaunchPosture(string Sandbox, string Approval);

public sealed record ReviewerCertificationRequirement(
string Vendor,
string AllowedCliRanges,
Expand Down Expand Up @@ -1747,15 +1756,28 @@ public sealed record UnattendedVendorCapability(
bool SupportsReviewerModelResolution = false,
// This vendor's reviewer-model policy version (distinct from LauncherPolicyVersion); the server
// echoes it to detect a mid-flight policy change. Null when no resolver is advertised.
string? ReviewerModelPolicyVersion = null
string? ReviewerModelPolicyVersion = null,
// Whether this daemon accepts a caller-selected launch posture for this vendor
// (CodexLaunchPosture on LaunchAgentCommand). Defaults false — a legacy or mid-rollout daemon is
// never widened to "supported" by any fallback, so the server refuses posture selection rather
// than sending a block that would be silently ignored.
bool SupportsLaunchPosture = false
);

public readonly record struct AgentRegistered(
string AgentId,
string? Prompt,
string? Model,
string? Effort,
string? RepoPath
string? RepoPath,
// Applied Codex posture echo: the pair actually passed to --sandbox / --ask-for-approval,
// non-null ONLY for a hosted interactive Codex launch (Kind == Default, owned worktree),
// whether that pair was caller-selected or derived. Review-flow / PR-review / local-attach
// agents and non-codex vendors always report null, so a consumer can render it without any
// launch-kind discriminator. Trailing name-bound fields on a single JSON DTO — an older
// server ignores them, an older daemon never sets them.
string? SandboxPolicy = null,
string? ApprovalPolicy = null
);

public readonly record struct AgentStatusChanged(
Expand Down
5 changes: 4 additions & 1 deletion src/Capacitor.Cli.Daemon/DaemonRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,10 @@ internal static IReadOnlyList<UnattendedVendorCapability> ComputeUnattendedVendo
borrowedSupported,
borrowedSupported ? factory.BorrowedReviewContainment : null,
SupportsReviewerModelResolution: modelResolver is not null,
ReviewerModelPolicyVersion: modelResolver?.PolicyVersion));
ReviewerModelPolicyVersion: modelResolver?.PolicyVersion,
// Caller-selected launch posture, advertised per vendor rather than per platform —
// the seam is platform-neutral, and only the Codex launcher honours a posture block.
SupportsLaunchPosture: string.Equals(vendor, "codex", StringComparison.Ordinal)));
}
return capabilities;
}
Expand Down
56 changes: 53 additions & 3 deletions src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@
public string? FlowRunId { get; init; }
public string? FlowRole { get; init; }

/// <summary>The applied Codex sandbox/approval pair — the values actually passed to the vendor
/// CLI, whether caller-selected or derived. Set only for an interactive Codex launch on a
/// daemon-owned worktree; null everywhere else. Stored HERE (not recomputed at each send) so the
/// initial registration and every reconnect re-registration report the same pair, which is what
/// lets the value survive a server restart.</summary>
public string? SandboxPolicy { get; init; }
public string? ApprovalPolicy { get; init; }

/// <summary>Phase B (D1): single-flight teardown latch — a plain field (not a property) so
/// <see cref="System.Threading.Interlocked.CompareExchange(ref int,int,int)"/> can gate it. Exactly
/// one teardown runs even if the launch-catch and the read-loop's finally race.</summary>
Expand Down Expand Up @@ -198,13 +206,13 @@
AgentKillQuarantine? _quarantine;
OrphanReaper? _orphanReaper;

// Tail-of-PTY capture for a FAILED launch, under the same per-daemon record root as the PID

Check warning on line 209 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 209 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 209 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)
// records ({state}/{name}/agents/failed/) — survives worktree teardown for post-mortem.

Check warning on line 210 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 210 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 210 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)
FailedLaunchLog? _failedLaunchLog;

Check warning on line 211 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 211 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 211 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)
string _daemonId = "";
string _daemonEpoch = "";

// Phase B2-b (sequenced-settlement design §4.2.4): the durable positive-death-evidence outbox.

Check warning on line 215 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 215 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / AOT publish check (src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)

Check warning on line 215 in src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

Make field readonly (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0044)
// Fed at every CONFIRMED-gone seam — the OrphanReaper record pass (Hook A), the quarantine drain
// (Hook B), and the StopAgent-fallback reap (Hook C) — always ledger-append BEFORE source-delete so
// a crash between the two re-derives from the leftover source and Upsert (idempotent on the
Expand Down Expand Up @@ -916,6 +924,17 @@
return new CommandOutcome(CommandOutcomeKind.LaunchRejected, agentId, RejectReason: CommandRejectedReason.Semantic);
}

// A caller-selected Codex posture is validated here — before the repo checks and the worktree
// creation below, so a rejection leaves nothing to clean up. Selection is only ever valid for
// an interactive daemon-owned-worktree launch: a posture on a borrowed, review-flow or
// PR-review launch would defeat a containment invariant, so it fails the launch outright
// rather than being silently dropped or silently honoured.
if (CodexPosturePolicy.RejectionReason(cmd) is { } postureRejection) {
await _server.LaunchFailedAsync(cmd.AgentId, postureRejection);

return new CommandOutcome(CommandOutcomeKind.LaunchRejected, agentId, RejectReason: CommandRejectedReason.Semantic);
}

if (isReviewFlow && cmd.ReviewerCertification is { } certification) {
var version = string.Equals(cmd.Vendor, "claude", StringComparison.Ordinal)
? DaemonRunner.ProbeCliVersionForLaunch(_config.ClaudePath)
Expand Down Expand Up @@ -1137,7 +1156,8 @@
Work: work,
DaemonId: _daemonId, // Phase B (D4 §6.4(3)): child env markers for the OrphanReaper scan
DaemonEpoch: _daemonEpoch,
IsBorrowedSnapshot: snapshotBorrow
IsBorrowedSnapshot: snapshotBorrow,
CodexPosture: cmd.CodexPosture
);

HostedRuntimeStart start;
Expand Down Expand Up @@ -1180,11 +1200,35 @@

var cts = new CancellationTokenSource();

// Applied-posture echo, stamped only for an interactive Codex launch on a daemon-owned
// worktree. A review-flow or PR-review launch reports nothing (its posture is the
// containment invariant, not a choice), and neither does a borrowed one. Both borrow
// conditions are tested: `work` alone is not enough, because a snapshot-backed borrow
// maps to OwnedWorktree while still being a borrow the caller never chose a posture for.
(string Sandbox, string Approval)? appliedPosture =
string.Equals(cmd.Vendor, "codex", StringComparison.OrdinalIgnoreCase)
&& cmd.Kind == LaunchKind.Default
&& !cmd.Borrowed
&& work == WorkLocation.OwnedWorktree
? CodexPosturePolicy.Resolve(work, isReviewFlow, cmd.CodexPosture)
: null;

// The permission bridge can never prompt under `never`, and danger-full-access reaches
// outside the worktree entirely. The dashboard warns the user before launch; this is the
// operator-side record of what actually ran.
if (appliedPosture is { } applied
&& (string.Equals(applied.Approval, "never", StringComparison.Ordinal)
|| string.Equals(applied.Sandbox, "danger-full-access", StringComparison.Ordinal))) {
LogBridgeDefeatingPosture(agentId, applied.Sandbox, applied.Approval);
}

var agent = new AgentInstance(agentId, prompt, effectiveModel, effort, repoPath, cmd.Vendor, runtime, worktree, cts) {
McpConfigPath = mcpConfigPath,
CurrentCols = HostedPtyCols,
CurrentRows = HostedPtyRows,
Work = work,
SandboxPolicy = appliedPosture?.Sandbox,
ApprovalPolicy = appliedPosture?.Approval,
ReviewerBridgeToken = reviewerToken,
BorrowedSnapshotSource = borrowedSnapshotSource,
Kind = cmd.Kind, // Phase B (D2): flow identity + kind for LiveAgents/status report
Expand Down Expand Up @@ -2183,7 +2227,7 @@
async Task RegisterAgentAsync(AgentInstance agent) {
if (agent.IsPrivate) return;

await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath);
await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath, agent.SandboxPolicy, agent.ApprovalPolicy);

// Report the PTY size so read-only viewers lock their xterm to it. Best-effort.
try {
Expand Down Expand Up @@ -2421,7 +2465,7 @@
foreach (var agent in _agents.Values.Where(a => (a.Status is "Starting" or "Running") && !a.IsPrivate)) {
for (var attempt = 1; ; attempt++) {
try {
await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath);
await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath, agent.SandboxPolicy, agent.ApprovalPolicy);
await _server.AgentStatusChangedAsync(agent.Id, agent.Status, agent.SessionId);

// Re-send the fixed PTY dims. The server stores them in memory, so a
Expand Down Expand Up @@ -2806,6 +2850,12 @@
[LoggerMessage(Level = LogLevel.Information, Message = "Agent {AgentId} spawned (PID={Pid}, worktree={Worktree}, vendor={Vendor})")]
partial void LogAgentSpawned(string agentId, int pid, string worktree, string vendor);

[LoggerMessage(
Level = LogLevel.Warning,
Message = "Interactive Codex agent {AgentId} launched with sandbox={Sandbox} approval={Approval}: "
+ "kcap approval prompts will never appear for this agent and/or it can reach outside its worktree")]
partial void LogBridgeDefeatingPosture(string agentId, string sandbox, string approval);

[LoggerMessage(Level = LogLevel.Information, Message = "Agent {AgentId} exited with code {ExitCode}")]
partial void LogAgentExited(string agentId, int? exitCode);

Expand Down
Loading
Loading