@visiq/claude-cowork-harness
v0.1.10
Published
VisIQ agent-governance harness for Claude Cowork (the Claude desktop agent) — pre-execution enforcement on host-local sessions via Claude Code hooks, plus audit-stream monitoring for VM-sandboxed sessions the hooks cannot reach.
Downloads
1,035
Maintainers
Readme
@visiq/claude-cowork-harness
VisIQ governance for Claude Cowork — the agent surface in the Claude desktop app.
Cowork is built on Claude Code, but it runs a session in one of two places, and the difference decides what governance is even possible. This package covers both, and labels each honestly.
| Surface | Where it runs | What VisIQ does | How |
|---|---|---|---|
| Host-local | The desktop app spawns its own claude natively on your machine | Enforcement — action + retrieval governance, deny blocks the tool call before it runs | Claude Code hooks (SessionStart, PreToolUse, PostToolUse) |
| VM sandbox | The session runs in the app's Linux guest | Monitoring — every tool call, its arguments, the model, and the sandbox's own allow/deny answer for each call, each reported to the platform as its own record | Tailing the host-side per-session audit stream |
The sandbox surface cannot be enforced. A sandboxed session's config dir lives inside the guest and carries no
settings.json, so no host hook can fire for it. The audit stream this package tails is written after a tool has already run. Nothing in this package reports a block on that surface, andstatussays so.
If an AI coding agent is doing this install
These are its rules — the same guardrail VisIQ prepends to every setup prompt the
platform generates. They are here because this page is one an agent gets pasted,
and because a step below really can fail: latest tracks main, so it can lag
the fixes already on the integration branch. Improvising around a failed step is
how an agent ends up building a CLI from source and patching the very harness it
was sent to install.
Rules for the AI agent running this setup — read these first:
- Apply ONLY the changes this prompt explicitly shows: its commands, its environment/config values, and its exact code snippet(s), if any. Nothing else may be written, edited, or patched — not code, not packages, not configuration.
- Never improvise a workaround when something fails: no building from source, no packing tarballs, no PATH shims or alternative registries, and no debugging or patching the VisIQ software itself.
- If any command fails, is unavailable, or produces output that differs from what this prompt describes, STOP and report the exact command plus its full output back to your operator. A partial install is a safe no-op by design — leaving it unfinished is always correct; an unauthorized fix never is.
- Do the steps below IN ORDER, and after each one run the
Check:command it shows and confirm the output before moving on. NEVER report the setup complete based on what you intended to do — only on what a check actually showed. If a check does not show what it describes, the step is not done. - Reading is always in scope. Inspect these commands, the URLs they call and the integrity checks they run BEFORE executing anything, and tell your operator what you found. These rules bound what you may CHANGE, never what you may examine — if something here looks wrong, refusing to run it and saying why is the correct outcome, not a failed setup.
Install
Install both packages. This one ships visiq-claude-cowork; the sibling ships
the visiq-claude-code-hook dispatcher that install wires into settings.json,
and the visiq-claude-code CLI that holds your credentials. Our workspace deps are
inlined into this bundle at publish time and stripped from dependencies, so npm
will not pull the sibling in for you — install only this package and status
reports dispatcher: … NOT FOUND on PATH, with tool calls running UNGOVERNED.
npm install -g @visiq/claude-cowork-harness @visiq/claude-code-harnessCheck: run visiq-claude-cowork status. Its dispatcher: line must end in a real
path. → NOT FOUND on PATH means only one of the two packages landed, and every
tool call will run UNGOVERNED.
# 1. Host-local enforcement (writes ~/.claude/settings.json)
visiq-claude-cowork installCheck: hooks: now reads installed — <path to your settings.json>. not installed
means the write did not happen; UNKNOWN (settings file is not readable JSON) means
your existing settings file could not be parsed and nothing was changed.
# 2. Credentials — shared with @visiq/claude-code-harness
visiq-claude-code configure --api-key <key> --agent-id <id>Check: credentials: now reads resolved, and agent id: shows the id you passed.
While it reads MISSING the hooks are wired but every one of them silently no-ops.
# 3. Sandbox monitoring (optional, long-running)
visiq-claude-cowork monitorCheck: each pass prints [VisIQ] scanned N log(s): …, and status then reports a
count on its VM-sandbox line instead of NOT monitored.
Endpoint (optional — but read this if you are not on the VisIQ cloud)
--api-key and --agent-id are the two REQUIRED values. The endpoint is
optional and defaults to the VisIQ SaaS control plane, https://api.visiqlabs.com.
If you run a sovereign / self-hosted / on-prem VisIQ, you MUST set it — otherwise
this harness reports every governed tool call to the VisIQ cloud instead of your
own control plane:
visiq-claude-code configure \
--api-key <key> \
--agent-id <id> \
--base-url https://visiq.internal.example.com # OPTIONAL (defaults to the VisIQ cloud)
# or via the environment (same precedence as the Claude Code harness)
export VISIQ_BASE_URL=https://visiq.internal.example.com # OPTIONALThe credential and endpoint resolution is the Claude Code harness's own — the
hooks and the monitor both read it — so VISIQ_API_KEY / VISIQ_AGENT_ID /
VISIQ_BASE_URL behave exactly as documented for @visiq/claude-code-harness.
Restart the Claude desktop app after installing. Then:
visiq-claude-cowork status[VisIQ] Claude Cowork harness 0.1.10 — status
this process: unknown (cannot enforce)
credentials: resolved
agent id: agent_01HQ7ZK3M4N5P6R7S8T9V0W1X2
hooks: installed — /Users/you/.claude/settings.json
dispatcher: visiq-claude-code-hook → /usr/local/bin/visiq-claude-code-hook
Claude Cowork (host-local): ENFORCED (PreToolUse deny blocks)
Claude Cowork (VM sandbox): monitor-only — 3 session(s), 5 audit log(s), all observed
session tree: /Users/you/Library/Application Support/Claude/local-agent-mode-sessions
monitor state: /Users/you/.visiq/claude-cowork/monitor-state.jsonThat block is not hand-written. __tests__/readme.e2e.test.ts builds exactly the
state it depicts — three sessions, five audit logs, all of them already tailed —
runs the built CLI against it, and requires this sample to match what came
back. Absolute paths differ per machine and per OS, so those four values are the
only thing it does not compare; every label, every count and the version are
bound. The sandbox line in particular is a coverage claim the monitor has to have
earned: with no monitor run it reads NOT monitored — … none observed.
Commands
| Command | Purpose |
|---|---|
| install [--settings <path>] [--command <cmd>] | Wire the governance hooks (host-local enforcement) |
| uninstall [--settings <path>] [--command <cmd>] | Remove them, leaving your own hooks untouched |
| status [--settings <path>] [--command <cmd>] | What is installed, detected, and actually covered |
| monitor [--once] [--interval <ms>] | Tail the sandbox audit stream (monitor-only) |
This table is not hand-maintained either. __tests__/usage.test.ts derives each
signature from the CLI's own flag table (src/cli/flags.ts) and requires this
table — and the docs quickstart
— to match, so a flag the CLI accepts and this page omits reds a test rather than
shipping. It had: status --settings, status --command and uninstall --command
are all accepted and acted on, and none of them was documented anywhere.
--settings and --command must be repeated on every command that touches that
file. They configure ONE invocation, not a saved preference: after
install --settings /custom.json, a bare status reads ~/.claude/settings.json
and truthfully reports it as uncovered — and following that output's advice with
a bare install would write your live config, the outcome --settings exists to
avoid.
So the CLI carries the flag forward. Every command it suggests that accepts
--settings is printed with the file already in effect, ready to paste:
visiq-claude-cowork install --settings /custom.json
visiq-claude-cowork uninstall --settings /custom.json
visiq-claude-cowork status --settings /custom.jsonmonitor does not take --settings — it is suggested bare, because the CLI never
prints an invocation it would refuse. These examples are not hand-maintained
either: __tests__/hints.test.ts re-derives them from the renderer the CLI itself
calls, so if the carry-forward ever stopped, this promise would red rather than
ship.
Why install wires the Claude Code dispatcher
Host-local Cowork sessions read the same ~/.claude/settings.json as the Claude
Code CLI, so this package installs the shared visiq-claude-code-hook dispatcher
rather than a second one. Two dispatchers over one file would double-evaluate every
tool call and the two installers would fight over the same JSON. If you already run
@visiq/claude-code-harness, your host-local Cowork sessions are already governed —
this package adds the Cowork surface labelling and the sandbox monitor.
Fail posture
Agent-side, this harness fails open by default (owner decision 2026-07-15): if
VisIQ itself cannot answer — no credentials, endpoint unreachable, no bundle — the
agent keeps working and the failure is reported loudly. A policy deny always blocks.
Opt into strict fail-closed with VISIQ_FAIL_MODE=closed.
The monitor is fail-open by construction: sessions are created and deleted under it constantly, so a vanished directory, a truncated write, or a corrupt line is skipped, counted, and reported — never fatal.
What is captured
From the sandbox audit stream, per tool call: tool name (including fully-qualified
mcp__* tools), tool arguments, model, session/tool-call ids, sub-agent parentage, and
the observation timestamp. Each observation is stamped with the surface it came from
and mode: "monitor".
Also captured: the sandbox's own permission decisions. Cowork asks you to approve
a tool call, and it writes both halves of that exchange — the request (with the tool
name and arguments) and your answer (granted, and whether you allowed it once or
always). Every pass of monitor reports them:
[VisIQ] scanned 1 log(s): 0 new tool call(s), 0 duplicate(s), 0 corrupt line(s), 0 truncated line(s)
[VisIQ] sandbox permission gate: 2 request(s), 2 answered (1 REFUSED, 1 standing 'always' grant(s), 0 unreadable, 0 unattributed)
[VisIQ] REFUSED by the sandbox's own gate, so these calls did NOT run: session sess: Bash ×1 — 0 bound to a tool call in this passThat block is not hand-written either — __tests__/readme.e2e.test.ts drives the
built monitor over a session whose gate exchange lands in this pass and requires
these lines to come back.
The counter line is printed every pass, zeros included, so a monitor that has seen no refusals is distinguishable from one that cannot report them. The second line appears only when there was a refusal, and it is the line to alert on — so it names the session each refusal came from. A pass walks every account, org and session under the tree, and an alert that names tools but no session cannot be actioned.
standing 'always' grant(s) counts only the answers that were granted. A standing
deny (always + refused — "never allow this tool again") is counted under
REFUSED, where it belongs; counting it as a standing grant made one refusal read
as two answers, one of them permissive.
unattributed counts the answers whose tool the sandbox did not name — a
permission_response written with no readable tool_name. Those records used to be
dropped by the parser before the subtype was even consulted, so a refusal carrying
one landed in no counter at all: the pass printed 0 answered (0 REFUSED …),
exited 0, and committed its cursor, which meant those bytes were never read again.
For a response the record is the only evidence the refusal happened, so it is now
counted and reported, with <unattributed> in place of the tool name. It is a
different axis from unreadable — that one is an answer we could not read, this one
is an answer whose tool was not named, and a record can be either, both or neither.
Such a record is deliberately not bound to any tool call: the tool name is the
binding evidence and there is none, so attributing it to the nearest call would
invent a denial the vendor never wrote.
Every permission decision is reported to the platform in its own right, as a
record carrying cowork.record: "permission_decision" and the same
cowork.execution value the table below describes. That matters because of the
next paragraph: it is what makes a refusal survive the process.
bound to a tool call in this pass is the honest part of the limit. When a call and
its answer are read together, the answer is also stamped onto that call's own
observation as cowork.execution (see below). When they are not — you took longer to
answer the prompt than the poll interval, which is the usual case, and what the sample
above shows — that call has already been reported unconfirmed, and the decision
record is what carries the refusal.
The monitor will not advance its cursor past a pass whose records did not reach the platform, including a pass whose only records are permission decisions. Such a pass exits non-zero and re-reads those bytes next time, rather than reporting the refusal to a terminal and forgetting it.
An observed call is not a call that ran
The audit record for a tool call is written when the model issues it — before the permission prompt is answered. So a call you refuse still appears in the stream, and on its own it looks exactly like one that executed.
Every observation therefore carries cowork.execution:
| value | means |
|---|---|
| refused | you denied it. The tool never ran. |
| granted | you approved it. |
| unconfirmed | the stream has not said — the call was not gated, or its answer has not been read yet. This is the default, and it is not a claim that the call ran. |
Counting unconfirmed as executed would over-report; the monitor does not do it, and
neither should anything reading these observations.
License
See LICENSE.
