Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
40a50f6
feat(harness): per-call gate contract for supervised adapters (#21)
queso Sep 28, 2026
08da7e1
feat(harness): per-call tool gate (#21)
queso Sep 28, 2026
f7a9302
fix(harness): bound and clean the reason on a thrown gate (#21)
queso Sep 28, 2026
cfa3bba
feat(harness): agent-sdk adapter on the Agent SDK with a per-call too…
queso Sep 28, 2026
75c8d14
feat(executor): per-call tool gate wiring and gate holds for harness …
queso Sep 28, 2026
2f25a22
feat(journal): persist and print gate-decision harness events (#21)
queso Sep 28, 2026
453269c
test(harness): opt-in live E2E for the agent-sdk adapter (#21)
queso Sep 28, 2026
c9538cc
Merge branch 'feat/21-tool-gate' into feat/21-agent-sdk-adapter
queso Sep 28, 2026
2d11182
fix(harness): gate owned paths follow the integrity check, and clear …
queso Sep 28, 2026
75bbcd8
docs(harness): supervised adapters, the agent-sdk adapter and the too…
queso Sep 28, 2026
a671d68
feat(flow): reject a gating adapter station with no tools list; omit …
queso Sep 29, 2026
f8a9615
fix(harness): bill the spend of a call ended by a gate hold (#21)
queso Sep 29, 2026
bed65c3
Address PR #92 review feedback (pass 1)
queso Sep 29, 2026
d4ce779
Merge origin/main into feat/21-agent-sdk-adapter
queso Sep 29, 2026
55954a3
Address PR #92 review feedback (pass 2)
queso Sep 29, 2026
a1ee497
Address PR #92 review feedback (pass 3)
queso Sep 29, 2026
13095da
Address PR #92 review feedback (pass 4)
queso Sep 29, 2026
0e47d99
Address PR #92 review feedback (pass 5)
queso Sep 29, 2026
241986d
Address PR #92 review feedback (pass 6)
queso Sep 29, 2026
644e27e
Fix type error in the agent-sdk registry options test
queso Sep 29, 2026
7c6fbe2
Address PR #92 review feedback (pass 7)
queso Sep 29, 2026
4a51428
Address PR #92 review feedback (pass 8)
queso Sep 29, 2026
5bea21e
Address PR #92 review feedback (pass 9)
queso Sep 29, 2026
b858b11
Address PR #92 review feedback (pass 10)
queso Sep 29, 2026
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
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ WORKDIR /app
# Install runtime dependencies before copying source — this layer is cached
# as long as package.json and bun.lock are unchanged, giving faster rebuilds.
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
# --omit=optional skips the Agent SDK's bundled platform binaries (about 460 MB). The
# agent-sdk adapter runs the `claude` on PATH, not the bundled one.
RUN bun install --frozen-lockfile --production --omit=optional
Comment thread
queso marked this conversation as resolved.
Comment thread
queso marked this conversation as resolved.

# Copy the source tree. .dockerignore excludes .env, .git, node_modules, and
# local sqlite state files so no secret or local state enters any image layer.
Expand Down
211 changes: 211 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions docs/harness-adapter-registration.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Harness Adapter Registration — Operator Setup Guide

How to register `kind: harness` adapters (`claude-headless`, `codex-exec`) via
How to register `kind: harness` adapters (`claude-headless`, `codex-exec`, `agent-sdk`) via
`CONDUIT_HARNESS_*` engine config, for Docker and bare-metal deployments, with
the recommended minimal env allowlist per adapter and per claude credential
mode, and how to verify the result with `conduit doctor`.
Expand Down Expand Up @@ -43,7 +43,7 @@ warnings `doctor` gives you when something's off.
| `CONDUIT_HARNESS_<NAME>_MODEL` | No | The adapter's deployment-default model. A station's own `model:` in `flow.yaml` **wins** when both are set — see [Model precedence](#model-precedence-station-wins-over-_model). |
| `CONDUIT_HARNESS_<NAME>_AGENT` | No, `claude-headless` only | The adapter's default named agent (`<plugin>:<agent>`), passed as `--agent`. A station's own `agent:` wins. The value is trimmed; setting it to an empty or whitespace-only string is a boot error naming the variable, not a silently empty default. See [Named agents and plugin dirs](#named-agents-and-plugin-dirs). |
| `CONDUIT_HARNESS_<NAME>_PLUGIN_DIRS` | No, `claude-headless` only | Comma-separated **absolute** plugin directories, one `--plugin-dir` each. Absent or empty: no flag. A relative entry is a boot error; a missing or non-directory entry fails when the adapter is built. |
| `CONDUIT_HARNESS_<NAME>_ISOLATE_CONFIG` | No, `claude-headless` only | `1`/`true` gives the child a run-scoped config dir instead of the operator's `~/.claude`; `0`/`false` or absent keeps today's behaviour. Any other value is a boot error. See [Isolating the child's Claude config](#isolating-the-childs-claude-config-_isolate_config). |
| `CONDUIT_HARNESS_<NAME>_ISOLATE_CONFIG` | No, `claude-headless` and `agent-sdk` only | `1`/`true` gives the child a run-scoped config dir instead of the operator's `~/.claude`; `0`/`false` or absent keeps today's behaviour. Any other value is a boot error. See [Isolating the child's Claude config](#isolating-the-childs-claude-config-_isolate_config). |

Setting `_AGENT`, `_PLUGIN_DIRS` or `_ISOLATE_CONFIG` for an adapter that does
not act on it (e.g. `codex-exec`) fails registry construction at boot, naming
Expand All @@ -58,6 +58,7 @@ underscore**:
|---|---|
| `claude-headless` | `CONDUIT_HARNESS_CLAUDE_HEADLESS_ENV` |
| `codex-exec` | `CONDUIT_HARNESS_CODEX_EXEC_ENV` |
| `agent-sdk` | `CONDUIT_HARNESS_AGENT_SDK_ENV` |
| `my-cool-agent` | `CONDUIT_HARNESS_MY_COOL_AGENT_ENV` |

Because the mapping collapses hyphens and underscores together, two
Expand Down Expand Up @@ -373,6 +374,10 @@ default changed correctly re-invokes rather than silently skipping.

## Named agents and plugin dirs

Only `claude-headless` runs named agents. An `agent:` (or `check.critic.agent`) on an
Comment thread
queso marked this conversation as resolved.
`agent-sdk` or `codex-exec` station is refused at load, and holds the card if it
reaches dispatch, rather than being ignored.

A `claude-headless` station can run a named Claude Code agent out of a plugin
directory the deployment supplies, without that plugin being installed in
anyone's user config:
Expand Down Expand Up @@ -641,6 +646,19 @@ CONDUIT_E2E_CLAUDE=1 bun test src/integration/harness-e2e-claude-agent.test.ts
It needs a `.credentials.json` in your Claude config dir (it is symlinked into
a scratch dir, never copied) and costs two Haiku calls.

## The `agent-sdk` adapter

`agent-sdk` runs the Claude Code CLI through `@anthropic-ai/claude-agent-sdk` so the kernel
can gate every tool call ([containment profile](harness-containment.md#a-middle-claim-supervised-adapters)).

- It runs the `claude` on `PATH`, resolved to a file, not the binary bundled with the SDK.
Set `CONDUIT_HARNESS_AGENT_SDK_COMMAND` to an absolute path to use another. The env
allowlist needs `PATH`, and `HOME` for subscription auth unless `_ISOLATE_CONFIG` is on.
- It supports `_ENV`, `_COMMAND`, `_MODEL` and `_ISOLATE_CONFIG`. `_AGENT` and
`_PLUGIN_DIRS` fail registry construction at boot, as they do for `codex-exec`.
- A station's `tools` list is the gate's allowlist: `Bash` alone allows no executable, so
list `Bash(git:*)` style entries for the commands it may run.

## See also

- [`harness-containment.md`](./harness-containment.md) — what a `kind: harness`
Expand Down
55 changes: 54 additions & 1 deletion docs/harness-containment.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,67 @@ claims:
- **`kind: harness`** — a station whose worker is an external headless agent harness
(`claude -p`, `codex exec`, or similar) wrapped in Conduit's transform contract. The
harness owns its own tool loop; Conduit cannot see or gate individual tool calls inside
it. This kind makes a **weaker containment claim than the Law**, on purpose, and this
it, except through a supervised adapter (see [below](#a-middle-claim-supervised-adapters)).
This kind makes a **weaker containment claim than the Law**, on purpose, and this
document exists so that claim is never implied to be stronger than it is.

If you need per-tool-call pre-execution gating, that's the Tool-Bridge (`kind: agentic`),
not this. A `kind: harness` station is the pragmatic precursor: it gets the tool loop onto
the kernel's books (journaled attempts, gate verdicts, rework guards, budgets, binding
stamps) without building an in-kernel loop first.

## A middle claim: supervised adapters

An adapter that sets `canGatePerCall` runs the harness loop with a callback into the
kernel, and the kernel decides every tool call before it runs. `agent-sdk` is the shipped
one: it drives the Claude Code CLI through `@anthropic-ai/claude-agent-sdk` `query()` and
calls the kernel's gate from `hooks.PreToolUse`
([#21](https://github.com/theaiteam-dev/conduit/issues/21)). The hook fires for the main
agent and for subagents, and an `allowedTools` rule does not bypass it.

The claim is a pre-execution decision on each call. It is not the Tool-Bridge: the CLI
still owns the loop, and the kernel sees one call at a time. The gate
(`src/worker/harness-gate.ts`) enforces:

- **Tool allowlist.** A tool not in the station's `tools` is denied.
- **Bash positive allowlist.** Executables come only from `Bash(<exe>)` and
`Bash(<exe>:*)` entries. A bare `Bash` entry allows the tool and no executable. A
narrower rule such as `Bash(git status:*)` is not widened to `git`. A command
containing a shell metacharacter is denied before the allowlist is consulted.
- **Write ownership.** Write, Edit, MultiEdit and NotebookEdit targets must resolve inside
the card's owned paths, with symlinks resolved on both sides, including a write through
a dangling symlink. This applies only where the flow sets `defaults.enforce_owned_paths`
and the card declares owned paths, the same condition as the other integrity checks.
- **No network tools.** `WebFetch` and `WebSearch` are always denied.
- **Human questions hold.** `AskUserQuestion` moves the card to `hold`: the adapter ends
the harness process and the executor holds the card without spending an execution
attempt. It does not park a live process.

A gate critic on a supervised adapter may write only its verdict file. A station on a
supervised adapter must list its tools: an empty list, or `unrestricted_tools: true`, would
deny every tool, so the loader rejects both (`HARNESS_GATED_ADAPTER_NEEDS_TOOLS`).

What it does not cover:

- The arguments of an allowlisted executable. `git -C / ...` and `git config` pass, and a
script the agent wrote can then be run.
- A Bash write that bypasses the path check. The MARK_DONE owned-paths integrity check
stays mandatory as the backstop.
- Reads outside the project root, and a symlink swapped between the check and the write.
- The input of `Agent` and of any other listed tool that is not a file tool.

The SDK reports a hook denial nowhere (`result.permission_denials` stays empty), so the
adapter emits a `gate-decision` event for every call the gate sees. It is journaled in
`harness_events` with the decision, code, tool name, subagent id when there is one, and a
reason cut to 200 characters. No tool input body is stored. `conduit journal inspect` and
`tail` print it. A hold answers the call with a deny and `continue: false`, so the CLI stops and emits its
result message (about 15 ms in a live run), and the thrown error carries that call's usage
and cost. Every later call is denied without asking the gate. If no result arrives within 5
seconds the process is killed, and the error then carries no usage.

Every invocation is a fresh session: `agent-sdk` does not resume one, and it does not run
named agents (`agent`, `pluginDirs`).

## The containment profile

Because the kernel can't gate what happens inside the harness's own loop, containment is a
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ docker build -t conduit-engine:1.0.0 .
> | `FROM oven/bun:1.3.11-slim` | Pinned Bun runtime — see [§7](#7-bump-the-bun-version) to change it |
> | Create `conduit` user/group | Least-privilege execution — the container never runs as root |
> | `WORKDIR /app` | Relative paths such as `examples/branching/flow.yaml` resolve here |
> | `COPY package.json bun.lock ./` + `bun install` | Dependency layer cached independently of source changes |
> | `COPY package.json bun.lock ./` + `bun install --omit=optional` | Dependency layer cached independently of source changes. Optional dependencies are skipped: they are the Agent SDK's bundled platform binaries, which the `agent-sdk` adapter does not use |
> | `COPY . .` | Full source tree — `.dockerignore` strips secrets and state (see [§3](#3-run-a-single-container-engine)) |
> | `RUN mkdir -p /data && chown conduit:conduit /data` | Mount point for `conduit.sqlite`; must be writable by the non-root user |
> | `USER conduit` | Drop privileges before the ENTRYPOINT |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.284",
"@terrastruct/d2": "^0.1.33",
"picocolors": "^1.1.1",
"table": "^6.9.0",
Expand Down
4 changes: 2 additions & 2 deletions scripts/mutation-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const MUTANTS: Mutant[] = [
file: 'src/controller/executor.ts',
find: 'foldHarnessUsage(usage.tokens);',
replace: '/* MUTATION-CHECK: gate-critic fold deleted */',
guards: ['src/controller/executor-harness-gate.test.ts'],
guards: ['src/controller/executor-harness-gate.test.ts', 'src/controller/executor-harness-gate-hold.test.ts'],
why:
"a harness gate critic's spend never reaches the run/wave budget — issue " +
"#26's original bug. The verdict and the journal row stay correct, so " +
Expand All @@ -117,7 +117,7 @@ const MUTANTS: Mutant[] = [
file: 'src/controller/executor.ts',
find: 'foldHarnessUsage(thrownUsage.tokens);',
replace: '/* MUTATION-CHECK: maker-throw fold deleted */',
guards: ['src/controller/executor-harness-journal.test.ts'],
guards: ['src/controller/executor-harness-journal.test.ts', 'src/controller/executor-harness-gate-hold.test.ts'],
why:
'a maker invocation that threw AFTER being billed (a timeout, most ' +
'often) costs real money and counts as zero. This is the mutant that ' +
Expand Down
22 changes: 22 additions & 0 deletions src/cli/journal-harness-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,28 @@ describe('journal inspect prints harness events under their span', () => {
});
});

describe('journal prints gate decisions (issue #21)', () => {
it('prints a gate-decision row under its span and under a span-less invocation', async () => {
db.appendHarnessEvent(event('inv-sdk', 0, { kind: 'lifecycle', phase: 'start' }));
db.appendHarnessEvent(
event('inv-sdk', 1, { kind: 'gate-decision', toolCallId: 't1', toolName: 'Bash', decision: 'deny', gateCode: 'not_allowlisted', reason: 'rm is not allowed' }),
);
span('coder.harness', 'inv-sdk');
db.appendHarnessEvent(
event('inv-live', 0, { kind: 'gate-decision', toolCallId: 't2', toolName: 'Write', decision: 'hold', gateCode: 'needs_human', agentId: 'sub-3' }),
);

for (const sub of ['inspect', 'tail'] as const) {
const lines = await journal(sub);
const spanned = lines.findIndex((l) => l.endsWith(' coder.harness'));
expect(lines[spanned + 2]).toContain('#1 gate-decision deny Bash code=not_allowlisted reason=rm is not allowed');
const header = lines.findIndex((l) => l.includes('inv-live') && l.includes('(no span)'));
expect(header).toBeGreaterThan(spanned);
expect(lines[header + 1]).toContain('#0 gate-decision hold Write code=needs_human agent=sub-3');
}
});
});

describe('journal tail follows a live attempt', () => {
it('shows the rows of a call with no span yet, picks up new rows, then files them under the span', async () => {
span('coder.harness', 'inv-earlier');
Expand Down
Loading
Loading