Skip to main content

clip-harness

A human-in-the-loop execution bridge for locked-down AI chat setups.

You work somewhere the chat model you can use (e.g. M365 Copilot) has no tools, no MCP, no shell — it can only talk. But it can write text, and you can copy that text. clip-harness is the other half of that channel: the model proposes commands and file changes as clipboard payloads, you hit one hotkey, and the harness executes them locally — behind a Claude Code-style hook lifecycle with a fail-closed review gate, JSONL receipts for everything, and the result copied back to the clipboard for you to paste into the chat.

It grew out of a hand-rolled iTerm2 clipboard loop plus a shell-script task suite. It is written in Python, not shell, on purpose: shell can't hold correctness and contracts even with Bats testing — the hook contract here is enforced in code (schema-validated input and output), modules have clean boundaries, and the whole lifecycle is unit-tested headlessly.

Install

pipx install clip-harness          # or: pip install clip-harness
clip-harness init                  # installs the default hooks + a
                                   # starter ~/.config/clip-harness/settings.json

For iTerm2 tab execution (optional): pipx install 'clip-harness[iterm2]'.

The install also puts a short alias on your PATH: clip is the same command as clip-harness (two entry points, one main). The docs use the canonical name throughout.

Quick start

  1. Wire the trigger: your own Apple Shortcut/hotkey (e.g. Cmd+Shift+E) that opens iTerm2, plus an iTerm2 hotkey whose fixed sequence runs clip-harness with no arguments. Zero arguments is the static trigger path: read clipboard → parse → run → result back to clipboard. Everything variable comes from the payload block itself.
  2. In the chat, paste the model AGENTS.md once so it learns the protocol.
  3. Copy a payload the model emits, hit the hotkey, review if asked, paste the result back.

Or run the daemon form: clip-harness watch (add --backend iterm2 inside iTerm2's Python runtime for in-tab execution).

Try the whole thing headlessly first — no Mac required:

python3 examples/sample_session/run_demo.py

Architecture

The core is thin and is purely a mapping layer onto known tools. It does not reimplement what a tool already does: patching is git apply, the clipboard is pbpaste/pbcopy, diffs are code --diff, the shell is the user's shell, notifications are whatever posts them.

Hooks are the extension mechanism. Behaviour is added by placing a hook at a lifecycle point, configured in a settings file — never by growing the core. Anything beyond parsing, lifecycle dispatch, the session registry, receipts, the goal/task store, and tool mapping ships as a hook point plus (where sensible) a default hook implementation.

Behaviour that must ALWAYS happen lives in the core; hooks are only for optional extension. The canonical example is the goal/task nag (below): it is generated by the core on every bound run because a nag that depends on an optional, removable, fail-open hook is not a nag.

Two consequences worth naming:

  • Notifications are hooks, not a core feature. The core emits lifecycle events; a notification is a hook placed at whichever point you want one (completion, failure, review-pending, session end). clip-harness init installs default notify hooks so it feels complete out of the box while remaining pure extension.
  • The review decision is NOT a notification. Capturing the human's accept/reject is transport — part of executing the flow — so the decider is a core adapter. ReviewDecision hooks may observe it (that's where a "review pending" notification lives), but they never override explicit decision data.

Module map

module responsibility
payload the parser — the one place that knows the block protocol
core shared types, limits, result formatting
contract the hook input/output schemas, enforced in code
hooks matcher rules + dispatch (exit-code/decision semantics)
gate built-in policy; candidate staging, classification, reviewed apply
runner the lifecycle state machine
sessions the session registry (binding, cancel/rerun addressing)
receipts the JSONL ledger + markdown transcript
goals the session-bound view over the durable goal store
goalstore the durable goal store: goal/plan/task as files, revisions, CAS, action-id idempotency, export/import, migrations (0.2.x SQLite ledger, legacy session files)
manifest the capability manifest (built-ins + hook-registered tools)
settings user/project settings, merged Claude Code-style
updater the throttled update check + the checksum-verified self-upgrade
executor the local subprocess backend
adapters/ tool mapping: macOS (pbpaste/pbcopy, VS Code), iTerm2 watch, headless stand-ins
defaults the shipped default hooks installed by clip-harness init
cli the command surface

There is no shell-script runtime dependency anywhere in this table — and no mise in the CLI's runtime path; if mise appears at all, it is as an optional wrapper in your shell config, never a requirement.

The payload protocol

This section is the single source of truth for the protocol. AGENTS.md — the instructions you give the chat model — is derived from it; keep the two in sync (a SKILL.md rendering can be generated from the same source later).

Detection is a ladder — the fence/metadata layer is progressive enhancement over "whatever is on the clipboard runs", never a requirement for the simple case:

  1. ```shell fence — commands with a metadata header.
  2. file / patch / ```edit fences — proposed file changes.
  3. Unfenced contents beginning with a metadata header — the shell fence's contents standing alone, when the fence was lost in copying (see "When the fence is lost in copying", below).
  4. Unfenced contents beginning with a path: header — the file / patch / ```edit fences' contents standing alone, under stricter recognition rules (same section, below).
  5. Bare clipboard text — treated as a single unbound command (only on the explicit trigger path; the passive watch loop never auto-runs bare text).

The ```shell fence

```shell
session: auth-refactor
action: run the tests
cwd: ~/work/api
timeout: 300
npm test
```

Leading key: value lines are the metadata header; the rest is shell text. Recognised keys:

  • session: — binds the run to that session id: addressable for cancel/rerun, receipts keyed by it, and its result blocks carry the goal/task standing section and capability-manifest refreshes. No session: → the run is unbound but still runs. A missing id never blocks execution.
  • action: — run (default); the core goal/task operations goal-set, goal-status, task-add, task-update (these require a session, or a goal: header) and the read-only goal-list / goal-show (no session needed). task-update is the only status-change form: its body is one <status>: <exact task title> line per transition (status done / doing / open), and the batch applies atomically — every title is validated first, and an unknown, duplicated or conflicting title rejects the whole batch with nothing applied. (Migration: the single-task task-doing / task-done / task-open actions were removed; a payload naming one gets a static migration error pointing at task-update.)
  • cwd: — working directory override.
  • timeout: — per-command timeout in seconds.
  • attend: — true opts this run into attended mode (below); anything else, or absent, is async.
  • writes: — comma-separated paths the command declares it may write (relative entries resolve against the effective cwd). The declaration scopes the sandbox when the command is held for review (see Reviewed shell commands below); an empty value is malformed and fails closed.
  • goal: — a durable goal's goal_uid: binds the payload to that goal (its footer projects from the goal's record; goal/task actions target it). An unknown uid is refused — discover goals with action: goal-list. See Durable goals below.
  • expect-revision: — the compare-and-swap guard: an integer. A goal mutation proceeds only while the goal's current revision equals it (a stale writer is rejected, naming both revisions); on a plain run bound to a goal it asserts the revision, and a mismatch denies the run. A non-integer value is malformed.
  • action-id: — an idempotency key (a uuid) for the whole payload. If the id was already applied, the payload is rejected as a duplicate — before anything executes or mutates — with the original outcome named. An empty value is malformed.

(run is accepted as a legacy alias for shell.)

When the fence is lost in copying

The canonical form is the fence — models emit fences, always. But some chat UIs strip the outer fence when the human copies with the block's copy button (Microsoft 365 Copilot does this), leaving the block's contents on the clipboard. The harness therefore also recognises those contents standing alone: text that begins with a metadata header — a contiguous run of recognised key: lines from line 1 — parses as that shell payload. The header is consumed as metadata and never passed to the shell; the remaining lines are the command, and goal/task action: payloads work identically. Bound runs get the same manifest and sticky footer as fenced runs.

The mutation fences get the same tolerance, under stricter rules. Text whose first line is an exact path: header is the file, patch or ```edit fence's contents when what follows disambiguates it: an explicit op: line makes it a file payload — file.write requires that op in this form, and the unfenced form is stricter than the fence: the op must be `create`, `update` or `delete` (`auto` is fenced-only), create/update require the file content as a non-empty body, and delete requires an empty body. A body beginning with a unified-diff header (`---` then `+++`) makes it a patch payload. (The op wins if both are present: a file whose content is itself a diff is legitimate.) A following `replace_all:` line or an exact `old_string:` line makes it an edit payload, parsed with the same strictness as the fence — a malformed edit fails closed here too, never falling through to bare shell text. The payload then flows through the identical staging, review and apply path as a fenced one — same manifest, same base-hash checks, same traversal rejection at staging.

This is a tolerance for a copying artifact, not a second format:

  • Text that does not begin with a recognised metadata line is not an unfenced payload — it is bare text (rung 5), as before. An unrecognised first line (e.g. foo: bar) starts no header.
  • A recognised key with an invalid value inside the header (timeout: soon, attend: maybe) is malformed metadata: the payload is rejected and nothing executes — it never falls back to running the header as shell text.
  • A metadata line appearing after the command body has begun is ordinary shell text, never re-parsed.
  • Metadata with no command body is rejected.
  • A path: header followed by neither a valid op: line nor a diff header is malformed: rejected, never treated as bare shell text, never staged. An op: line with any other value is malformed the same way. A path: line appearing after a shell body has begun is ordinary shell text.

Execution modes

Wherever a human is in the loop, the review is taken in the terminal — and plain runs are fire-and-forget: the human is never blocked waiting on output.

  • Trigger / run (interactive). Firing the trigger (or clip-harness run) puts you in the loop: a payload with file changes is staged, the review surface (manifest, then inline diffs) is presented in the terminal, and the decision is taken in the same invocation — accept, accept-edited, reject, or defer. The prompt also offers a VS Code view of the candidate; it opens only when you pick it — and in the editor, saving is the decision (see the review gate below). This holds for attended payloads and pure file/patch payloads alike. Plain commands just run.
  • Live run visibility (trigger / run, at a terminal). While a command executes, the terminal shows a header — the command, its cwd, the bound session, the timeout — and then the output itself as it is produced, with a … running Ns heartbeat when the command goes quiet; the run's output.txt fills as it goes, so tail -f works mid-run too. This is a view, not a second artifact: the result block still arrives complete at the end, byte-for-byte what a silent run produces, and the clipboard, receipts and files never see the header or the heartbeat. The watch loops and non-terminal invocations don't stream.
  • Parked (deferred / watch). Deferring at the trigger prompt — or any payload with file changes under the watch loops, which never prompt — parks the run: the candidate is staged, the ReviewDecision pending notification fires, and the run waits in pending_review. The decision arrives later (candidate review / decide / apply, or a decision.json drop); only then do apply, receipt, clipboard, and the completion notification happen. For a parked review, rejecting is the steering. A held shell command parks the same way, as a command candidate (see Reviewed shell commands below).
  • Attended (attend: true). The payload itself demands the inline wait in any context that has a decision window — e.g. the iTerm2 watch's decision.json drop — for the runs where the human wants to sit in the loop even where the entry point would otherwise park. cancel steers a running command in every mode.

File fences — the mutation branch

One copied message may carry several fences: one ```shell fence plus one fence per file. A file fence carries the FULL content — the human reviews the actual bytes as an inline diff in the terminal (below), never a description in the chat:

```file
path: src/auth.py
op: update
<full file content>
```

```patch
path: src/tokens.py
<unified diff>
```

op: is optional (create/update/delete); the gate classifies each file against disk — NEW / UPDATE / DELETE / NO-CHANGE — and flags declared-vs-actual mismatches. File fences with no shell fence are a pure mutation payload; a header-only shell fence binds one to a session. For ```patch fences, path: is the authoritative target: the diff's `---`/`+++` header filenames are checked against it and normalized to it before application, whatever shape they arrive in.

The ```edit fence — exact-text edits

For a change to an existing file, prefer edit over patch: the model supplies exact text instead of a hand-counted diff, and the harness computes the candidate bytes and generates the review diff itself:

```edit
path: src/auth.py
replace_all: false
old_string:
def old():
    return 1
new_string:
def new():
    return 2
```

The rules mirror Claude Code's Edit tool, which this maps onto:

  • path: is required and first — the authoritative target, exactly like the other mutation fences.
  • replace_all: is an optional header line (Claude Code's exact parameter name); only the literal values true / false are valid — anything else is malformed and fails closed. Default false.
  • The sections are delimited by lines that are exactly old_string: and then exactly new_string: — the first new_string: line after old_string: is the delimiter. A section's text is its lines joined with newlines, with no trailing newline.
  • Matching is exact, whitespace included, against the live file (which must decode as UTF-8). The gate fails the staging when old_string occurs zero times (the remedy: copy the exact current text, whitespace included, and re-send), or more than once without replace_all: true (the remedies: include more surrounding context to make it unique, or set replace_all). old_string equal to new_string fails too — there is nothing to change.
  • An empty new_string: section is valid: it deletes the matched block. An insert is expressed inside the same one operation — new_string carries the anchor text plus the new text. There are no other edit operations.
  • An empty old_string, a missing section marker, or an op: line is malformed and fails closed. An edit never creates a file — its target must already exist; creation is the ```file fence's job, and the staging error says so.
  • One edit per file per payload: a second spec for the same path where at least one is an edit is rejected at staging. Like sequential Edit calls, the next edit goes in the next payload, after the result; a multi-part change to one file travels as a single file fence or a single valid patch.
  • If the text being edited itself contains a literal line that is exactly old_string: or new_string:, the section markers collide — send that change as a ```file fence instead.
  • by a tool, not hand-written). A patch whose @@ header counts
    don't match its body fails staging with a diagnostic naming,
    per offending hunk, the declared vs actual old/new counts —
    and the error points back here: fix the @@ counts, or re-send
    the change as an ```edit fence.
    

Control triggers

Single-line, as in the original loop: ```cancel``` / ```cancel id=<session>``` and ```rerun``` / ```rerun id=<session>```.

The result block

```result
$ npm test
<output — oversized output elided to a head + tail digest; the full output stays in a file on disk>
[exit 0 · 3.2s]
files:
  ✓ src/auth.py — UPDATED
    --- a/src/auth.py
    +++ b/src/auth.py
    @@ -10,3 +10,3 @@
     unchanged context
    -old line
    +new line
— capabilities:
  ...
[goal] Ship the auth refactor [active] (3f9a1c2e)
[open tasks] [doing] wire tokens · [open] update tests
```

One artifact, two audiences. The result block is a single shared artifact: the text rendered in the terminal and the text on the clipboard are the same bytes — what the human sees is exactly what the model reads when it is pasted back. There is no prettier human version and no separate machine version.

  • The files: section appears when a mutation was decided, with NEW/UPDATED/DELETED tags (and ✗ lines for rejected entries). Each entry is followed by its inline diff — applied and rejected alike — so the paste-back shows the model its own proposal exactly as rendered and what happened to it. Diffs are capped per file (80 lines / 6000 chars) with a … N more lines — full diff in <candidate dir> pointer.
  • Sticky footer. Every session-bound block ENDS with the same lines in the same position: [goal] … then [open tasks] …. The [goal] line is projected from the durable goal record — goal text, status, and the goal's short uid. There is no [step] line: nothing stores a "current step" — the task in progress shows as [doing] inside [open tasks]. The footer is core-generated (never hook-supplied) and appears in every bound block, hooks or no hooks, so both the human and the model are re-anchored on the goal and the open tasks every round trip. Hook additionalContext, capability-manifest refreshes, and pending state all sit above the footer; the footer is always last. Unbound runs carry neither the footer nor manifest refreshes. One more line may sit immediately above the footer (or end an unbound block): the [update] notice, when a newer release is available (see Updating).
  • A rejection puts plain rejection text on the clipboard instead (the rejected diffs included, marked rejected, with the reason), ready to paste back so the model can re-propose.

Oversized output

A command's full output is always preserved byte-for-byte at <state_dir>/runs/<run_id>/output.txt. What goes into the result block is bounded by the result_budget_chars setting (default 100,000): output past the budget is elided to a head + tail digest around one marker line —

[output elided: N of T chars shown · full output (T chars) saved at <path> · page it with: sed -n 'A,Bp' <path> · grep <path> · tail -c E <path>]

The default leaves room inside a 128k-capped chat message (Microsoft 365 Copilot's per-message limit) for your own words and the block's footer; set the budget for whatever surface you paste into. Nothing is lost and no re-run is needed: the model pages the stored file with ordinary shell commands in a follow-up payload, so the bulk never occupies its context — it stays addressable instead.

Durable goals (core)

Goal, plan and task are three different things here — the separation Anthropic's Claude Code model converged on — and the harness keeps them separate:

Goal — the anchor

A goal is the objective that frames the work: a durable object with a stable id, referenced by sessions, never owned by them. Its record carries only anchor fields: goal_uid (harness-issued uuid4, immutable, never derived from the session name or the goal text), human_id (a readable slug), the verbatim goal text, status (waiting / active / paused / complete / branched), revision (starts at 1, +1 on every mutation), latest_result, free-form provenance, and timestamps. A goal holds no plan and no tasks — it anchors them. Sessions hold only a binding (session id → goal_uid): lose the chat, the terminal and the session, and the goal is still there — goal-list rediscovers it, goal-show reconciles it, by exact uid.

Plan — a document

A plan is a Markdown document: at most one per goal, authored whole, replaced whole. This is Anthropic's shape — in Claude Code a "plan" is the plan-mode document, not a data structure. The model writes it with action: plan-set (the payload body IS the document) and reads it back verbatim with action: plan-show; the harness stores and returns it exactly as written and never parses steps, statuses, or structure out of it. A plan's version identity ("PLAN v2") is the document's own content — its title line — not schema. goal-show points at the plan (exists? + title line) without embedding it.

Task — the tracked work

A task is the only tracked execution state: its own object, belonging to a goal — task_id (harness-issued, stable), the goal_uid, a title, a status (open / doing / done), and an integer position. There is no "current step" cursor anywhere: which task is current is read off the task states (the one marked doing), never stored separately where it could disagree with them. Tasks are created with task-add (positions assigned after the current maximum) and their statuses change only through task-update — one atomic batch of <status>: <exact task title> lines per payload.

The store is files

The durable store is plain files under <state_dir> — Anthropic's literal mechanism (their tasks are JSON files in a folder; the folder is the store):

goals/<goal_uid>/goal.json          the goal record
goals/<goal_uid>/tasks/<id>.json    one file per task
goals/<goal_uid>/plan.md            the plan document (if any)
goals/<goal_uid>/actions.jsonl      applied action-ids (replay log)
bindings/<session_id>.json          session -> goal_uid

Writes go to a temp file in the same directory and land with an atomic rename; a batch validates fully before anything is written; each goal's mutations are serialized by a per-goal lock, so concurrent harness processes cannot corrupt or lose each other's updates (a lock that cannot be acquired within ~15s fails loudly, naming the lock path — it is never stolen). Still zero third-party dependencies.

Two guards ride the mutations:

  • Compare-and-swap. A payload carrying expect-revision: N mutates the goal only while its revision is N; a stale writer is rejected with both revisions named and nothing applied.
  • Idempotency. A payload carrying action-id: <uuid> that was already applied is rejected as a duplicate before anything executes or mutates, with the original outcome named. Action ids are scoped per goal when a goal is involved, then per session, then global.

From the terminal:

clip-harness goal --session auth-refactor set "Ship the auth refactor"
clip-harness goal --session auth-refactor task-add "wire tokens"
clip-harness goal --session auth-refactor task-update "wire tokens" doing
clip-harness goal --session auth-refactor show          # the bound goal
clip-harness goal list                                  # every durable goal
clip-harness goal show <goal_uid>                       # one goal
clip-harness goal export <goal_uid>                     # JSON, to stdout
clip-harness goal export --all --out backup.json
clip-harness goal import backup.json

From the chat side, via action: payloads (see the manifest): the first goal-set in a session creates the durable goal and returns its goal_uid in the result block. goal-list / goal-show are the read-only discovery pair; plan-set / plan-show carry the plan document. Every bound result block's sticky footer projects from the durable record (session state never overrides it).

Export / backup. goal export writes a versioned JSON document — {"format": "clip-harness/goal-export@2", "goal": {…anchor fields…}, "tasks": [{title, status}…], "plan": "<markdown or null>"} (or "goals": […] of such bundles with --all). That document IS the backup format and the import format: goal import validates it, preserves uid, revision, status, provenance and timestamps exactly, recreates the tasks as fresh task objects in position order, stores the plan verbatim, and refuses to overwrite an existing goal_uid (the refusal names it). Applied action history is NOT exported — an import starts a fresh replay log in the new store. The retired @1 format is no longer imported: an @1 document gets an error naming the situation and pointing at @2. Export is backup/restore only — day-to-day, a goal can equally be recreated from scratch through payloads (goal-set + plan-set + task-add + task-update is a lossless create path now).

One-time storage migrations. Both run automatically on first store open, under a store lock (two processes starting together cannot double-import):

  • A 0.2.x SQLite ledger (<state_dir>/goals.db) is read once and written out as the file layout: the plan dict is rendered to a Markdown document (title line from its revision label, one N. [status] title line per step — a rendering, whose content is user/model-owned from then on), tasks become task files, backlog entries become open tasks appended after them (the backlog field itself is deleted), and bindings and applied action-ids carry over. The database is renamed goals.db.migrated and never read again.
  • Pre-0.2.0 per-session JSON files (<state_dir>/goals/ <session>.json) migrate as they always did — fresh uid, the session binding recorded, legacy status done → complete, source renamed .migrated — except sessions the SQLite migration already bound are skipped (their file is still renamed, never re-read).

Capability manifest

The manifest is the discovery mechanism by which the chat model gains capabilities over the clipboard transport: a compact list of the tools it may invoke, each with the block shape that invokes it (shell.run, file.write, file.edit, patch.apply, session.cancel, session.rerun, goal.set, goal.list, goal.show, task.add, task.update, …). The core generates it for the built-ins; settings may declare more under "tools"; and hooks may register additional tools at runtime via registeredTools in their output — a registered tool appears in the manifest from then on, refreshed into the next bound result block.

Hook lifecycle

The lifecycle is modelled on Claude Code's hooks, mapped onto the harness (one dispatcher, clip-harness hook --event <Event>, one JSON object in on stdin, at most one JSON object out on stdout, diagnostics to stderr):

event when analogue
SessionStart harness armed: ledger init, tool check, env/cwd snapshot SessionStart
PayloadCaptured clipboard captured: hash + timestamp + raw bytes receipted first; empty/oversized/non-text rejected UserPromptSubmit
PreRun the gate: parse/classify, allowlist/denylist, protected paths, destructive patterns → allow/deny/ask/rewrite PreToolUse
ReviewDecision the mutation branch: candidate staged, human decision captured as explicit data PermissionRequest
PostRun receipt with the executed input + response + duration — written before the clipboard is overwritten PostToolUse
PostRunFailure a separate event (not a flag): failure receipt; partial applies roll back from base-hash snapshots PostToolUseFailure
RunClosed refuses a “clean” close while a candidate decision is pending — the run is left in a recorded pending state Stop
SessionEnd ledger summary SessionEnd
ConfigChange allowlist/hook-config edits made while armed, receipted as their own events ConfigChange

The contract

  • Input fields: session_id, run_id, hook_event_name, cwd, clipboard_sha256, tool_name (Bash for commands; reclassified Write/Edit/Delete for the mutation branch), tool_input, tool_use_id; post events add tool_response or error + duration_ms. Both input and output are validated against the schemas in clip_harness/contract.py; a schema violation is a hook error.
  • Decisions via hookSpecificOutput: permissionDecision (allow/deny/ask), permissionDecisionReason, updatedInput (replaces the whole input; the allowlist is re-checked against the rewritten input), updatedToolOutput (PostRun only — rewrites the clipboard copy; the receipt keeps the true output), additionalContext, registeredTools.
  • Exit codes: 0 = proceed (the JSON may still deny/ask/rewrite); 2 = hard block, evaluated before the allowlist, stderr is the reason, and it wins over a JSON allow; any other exit, a crash, a timeout, or unparseable/schema-violating output = hook error.
  • The deliberate deviation: gate hooks (PayloadCaptured, PreRun, ReviewDecision, RunClosed) fail closed — a gate hook error means the payload does not run, with the reason in the receipt (Claude Code's onFailure: "block" behaviour, made the default). Observational hooks fail open.
  • Hooks are command handlers in a settings file (below), with Claude Code-style matchers on tool name (exact/|-lists, or regex), an optional if glob on the command text, and per-hook timeouts.
  • A review that produces no decision in its window leaves the run PENDING with the candidate intact — never auto-applied, never silently discarded. Complete it later with clip-harness candidate review <id>.

The review gate (§7.3 flow)

File payloads are never written live. The gate classifies each file against disk, stages candidates with base hashes and a manifest, and the review surface presents the manifest first (path · op · line counts), then each change as an inline unified diff in the terminal — a create renders as a full addition, a delete as the removed content, an update as a compact diff with context lines, each capped with a pointer to the candidate directory for the full diff. That inline diff is the default review surface; VS Code (code --diff per update, the candidate for creates, the live file for deletes) remains available as the deep-review option for the same staged candidate — offered inside the terminal prompt and opened only when the human picks it; it never opens on its own. On the trigger / run paths the surface is presented inline at staging time and the decision is taken in the same invocation (deferring parks the run); under the watch loops the run parks and the surface is presented when the human comes to decide (candidate review / candidate show); an attend: true payload forces the inline wait wherever a decision window exists. The decision is captured as data (decision.json: accept / accept-edited / reject + optional reason + optional per-file accepted_files). Apply runs only on accept, applies only accepted entries, verifies post-apply hashes, and rolls back from the pre-apply snapshots on partial failure. On reject: no post hooks fire, the receipt records the denial, and the clipboard gets the rejection text (rejected diffs included).

Picking the VS Code view waits for the editor: the candidate opens in a single code --wait session (manifest first, then the diffs), and the contract is save + close = apply your edits — if any staged copy's content differs when VS Code closes, the review concludes on the spot as accept-edited and applies your edited bytes, for the whole candidate (per-file picks remain a terminal-prompt capability). Close without saving and nothing was decided: you are back at the terminal prompt. A delete's live file is view-only in the editor — changing it does not count as an edit, and the gate's stale-base check still guards the apply.

Built-in policy runs before hooks: hard-block patterns are evaluated before the allowlist (exit-2 semantics); a denylist match denies; a non-empty allowlist that doesn't match asks; shell commands that look like mutations ask under mutation_gate: "review" (the default) so file changes can travel as reviewable fences instead. An explicit allowlist hit is the human's standing approval: it approves the command outright (this is what a review's always allow persists — below). A mutation the gate holds is not a dead end, though: the command itself is reviewable, as the next section describes.

Reviewed shell commands

Some commands cannot be predeclared as file fences — a supplier script that generates its outputs, a build, a code generator. When the gate's verdict on a shell command is ask (a mutation under mutation_gate: "review", or a command not on the allowlist), the run pauses for a decision instead of executing, wherever the command appears: bare, or as the shell part of a mixed payload (after its files are accepted). The command is staged as a command candidate in the same candidates store as file candidates (manifest kind "command": the command, cwd, timeout, session, declared writes:, the gate's reason, and the clipboard hash) and the review surface shows:

  • the full command, its cwd, session (when bound) and timeout;
  • the gate's reason for holding it;
  • the declared write scope — or, with no writes: header, that writes are confined to the working directory + temp;
  • the protected carve-outs (below) that no accept can override.

Decisions, at the terminal prompt or as recorded decision data:

  • accept — execute under the sandbox (the default accept);
  • accept unsandboxed — execute bare. Offered only when the shell_sandbox setting is "optional" (the default); "strict" removes it entirely, from the prompt and from recorded decisions alike;
  • always allow — accept sandboxed AND persist an allowlist pattern derived from the command (first token + *, shown in the prompt, e.g. suppliers/openapi/discover *) into the project settings (.clip-harness/settings.json under the cwd, other keys preserved; a ConfigChange is recorded), so future runs of that shape evaluate ALLOW and are not held;
  • reject (+reason) — nothing runs; the denial result carries the reason back to the model;
  • later — no decision: the run parks in pending_review as a command candidate, exactly like a file candidate. The clipboard note names the mechanism: clip-harness candidate review <id>. candidate list / show present it as a command with its scope and reason; candidate review offers the same decisions and executes an accepted command through the runner (PostRun hooks, bound footer, result block); candidate review --decision accepts accept (= sandboxed), accept-unsandboxed and reject for command candidates. candidate decide records a decision for a later candidate apply, as with files — one difference: for a command candidate, apply means execute the approved command (there are no files to write).

The sandbox (macOS Seatbelt). A sandboxed execution is wrapped in sandbox-exec -p <profile> at process spawn; supervision, timeout and cancel are unchanged. The profile is a pure function of (cwd, declared writes, temp dirs, config dirs): reads are broadly allowed; file writes are denied by default, then allowed for the effective cwd, each declared writes: path, and the temp dirs; hard write-denies that no allow overrides sit inside those roots — <cwd>/.git/hooks, <cwd>/.git/config, the user's clip-harness config dir, and <cwd>/.clip-harness. Process exec/fork is allowed. Non-goals in this slice: network is unrestricted (no network rules are emitted), and the sandbox is macOS Seatbelt only — where sandbox-exec is unavailable, a sandboxed accept fails closed with a clear result-block error and nothing runs; the human's recourse is an explicit unsandboxed accept.

The result block of a reviewed run records the approval on the command line — $ make evidence (reviewed · sandboxed) — the one annotation a reviewed run adds. Hard blocks (destructive patterns, protected-path mutations, the denylist) stay unreviewable denials, and mutation_gate: "deny" stays a flat denial: neither can be approved at review.

Settings

User scope ~/.config/clip-harness/settings.json + project scope .clip-harness/settings.json, merged like Claude Code's: handler and list settings concatenate (user first), scalars are overridden by the project. See examples/settings.example.json.

{
  "timeout": 120,
  "allowlist": ["git status", "ls *"],
  "denylist": ["sudo *"],
  "protected_paths": [".env", "~/.ssh"],
  "hard_block_patterns": ["rm\\s+-rf\\s+/"],
  "mutation_gate": "review",
  "shell_sandbox": "optional",
  "result_budget_chars": 100000,
  "color": "auto",
  "hooks": {
    "PreRun": [{"matcher": "Bash", "command": "~/hooks/check.py", "timeout": 10}],
    "PostRun": [{"matcher": "*", "command": "~/hooks/notify.py"}]
  }
}

State (receipts, sessions, candidates, and the durable goal goal store) lives in ~/.local/state/clip-harness (override with "state_dir").

Terminal color. Diffs printed to a terminal — the review presentation, candidate show, printed result blocks — are colorized at print time: additions green, deletions red, file/hunk headers cyan. Color lives only in the rendering; the text itself (clipboard, result blocks, receipts, files) always stays plain. Control it with the "color" setting: "auto" (default — color only when stdout is a TTY), "always", or "never". The NO_COLOR environment variable forces color off regardless of the setting.

CLI surface

clip-harness                      # zero-arg trigger: clipboard -> run -> clipboard
clip-harness watch [--backend local|iterm2]
clip-harness run (--clipboard|--stdin|--file F|--command C) [--session ID]
clip-harness hook --event PreRun  # the hook dispatcher (JSON in/out)
clip-harness init                 # default hooks + starter settings
clip-harness goal --session ID set|show|status|task-add|task-update|task-list
clip-harness goal list|show UID|export [UID|--all]|import FILE
clip-harness candidate list|show|review|decide|apply [ID]
clip-harness sessions             # bound sessions
clip-harness cancel [ID]          # bound id, or the last run
clip-harness rerun [ID]
clip-harness finish [--run ID|--session ID] [--copy]
clip-harness receipt [--run ID] [--tail N] [--summary]
clip-harness version [--check]    # --check: a fresh update check
clip-harness upgrade              # verified self-upgrade (see Updating)

Headless use (tests, demos, CI): set CLIP_HARNESS_CLIPBOARD_FILE to use a file as the clipboard, and wire hook commands that log instead of notifying. examples/sample_session/run_demo.py runs the full lifecycle this way.

Updating

The harness never updates itself silently — a tool whose job is reviewing what executes does not change underneath a session. The pattern is the converged dev-tool one (gh, npm, rustup): notify in-band, upgrade on one explicit, verified command.

  • The notice rides the result blocks. At most once a day the harness checks the release feed — GitHub Releases' /releases/latest/download/SHA256SUMS, no token, no account — and caches the verdict in <state_dir>/update-check.json. When a newer release exists, result blocks gain one line immediately above the sticky footer (or at the end of an unbound block):

    [update] clip-harness 0.3.0 is available (you run 0.2.1) — upgrade with: clip-harness upgrade

    The model reading the block back sees it too, so it can tell you you're behind. An unreachable feed means no line and no error: the check never fails, delays, or otherwise touches a run.

  • clip-harness upgrade does the swap. It reads the latest release's SHA256SUMS, downloads the wheel, and verifies its sha256 before installing anything — a mismatch, or a feed naming no wheel, aborts with nothing installed. The verified wheel installs into the current environment with the current interpreter (the pipx venv the harness itself runs in), which also regenerates the clip alias; the new version takes effect on the next run. Already current: it says so and exits 0. If pip is unavailable in the environment it prints the equivalent manual pipx commands instead.

  • clip-harness version --check forces a fresh check on demand, ignoring the daily throttle, and prints the verdict.

CLIP_HARNESS_UPDATE_BASE overrides the feed base URL — mirror support, and how the test suites point the whole mechanism at a local file:// directory feed.

Security model

  • The gate fails closed: hook crash/timeout/bad output on a gate event means the payload does not run. Hard blocks are evaluated before the allowlist.
  • File changes are never written live before review; they are staged as candidates with base hashes, applied only on an explicit recorded decision, verified after apply, and rolled back on partial failure.
  • Decisions are explicit data, never inferred from silence. No decision = pending, forever, until a human decides.
  • A held shell command executes only on a recorded decision, and a sandboxed accept fails closed when the sandbox cannot be built or sandbox-exec is unavailable — it never degrades into a bare run. Unsandboxed execution requires its own explicit decision, and shell_sandbox: "strict" removes even that.
  • The receipt is written before the clipboard is overwritten, so the evidence trail never lags reality; every run's payload is hashed into the ledger at capture time.
  • The passive watch loop never auto-runs bare text — bare-text execution requires the explicit trigger (the human's hotkey), the same trust model as a terminal.
  • Payloads are capped (32k chars); result blocks elide output past result_budget_chars (default 100k) to a head + tail digest, with the full output preserved byte-for-byte in the run's output file.
  • Hooks are your commands running with your privileges — the settings file is a trust boundary, like Claude Code's.

Platform notes

The lifecycle core is platform-independent and fully tested on Linux. The macOS adapters (pbpaste/pbcopy, VS Code diff opener) are thin and written against the same surfaces the original loop used; the iTerm2 watch backend (in-tab execution via the sentinel protocol, dedicated tab per the original script) is written but untested on a Mac — it needs iTerm2's Python runtime and the iterm2 pip module. Headless adapters (file clipboard, decision.json file-drop) are the QA path for everything except the GUI surfaces.

Troubleshooting

Every command opens with ERROR:root: code for hash blake2b was not found. and a traceback. Your Python was built without blake2 support (no OpenSSL blake2 constructor, no builtin _blake2 module — seen on corporate pyenv builds). CPython's hashlib logs that traceback itself at import time; clip-harness imports hashlib for sha256, so the spam lands on stderr before anything runs. Nothing is broken — sha256 is unaffected. clip-harness silences it by making its first hashlib import with logging briefly disabled (see clip_harness/_quiet_hashlib.py). The permanent fix is a Python built against a full OpenSSL.

Development

pip install -e '.[dev]'
python3 -m pytest        # 387 tests: parser, gate, contract, lifecycle

Design rationale, in one line: correctness and contracts live in code, behaviour that must always happen lives in the core, and everything else is a hook.

License

MIT — see LICENSE.

Metadata

Release files for clip-harness 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for clip-harness 0.3.0
File Size Uploaded Provenance
clip_harness-0.3.0.tar.gz 207.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for clip-harness 0.3.0
File Interpreter ABI Platform
clip_harness-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 338.0 kB

Release files / clip_harness-0.3.0.tar.gz

Download URL clip_harness-0.3.0.tar.gz
Size 207.1 kB
Tags Source
SHA-256 checksum
How to use checksums
97baa4e3682c2d4f4b69750eb3889a8ca1032ec2a6a3bdd782b49d28a013aed8
BLAKE2b-256 checksum
How to use checksums
434fe1d8d2f0b84ce231e937fc234ef8b78350fc215200cffe1611c61eb7c0fb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / clip_harness-0.3.0-py3-none-any.whl

Download URL clip_harness-0.3.0-py3-none-any.whl
Size 131.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
29ebdd350577887775feb2b16e28198deba4a3c9d274b32dcc1c63feef72ca62
BLAKE2b-256 checksum
How to use checksums
45e0da3cae694fe31eeda2060913598f9c115914bc1ab8c378648f6c7d8cd076
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Security for clip-harness 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for clip_harness-0.3.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page