cowork-harness 1.25.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/cowork-harness/SKILL.md +14 -8
- package/.claude/skills/cowork-harness/references/ci-recipe.md +50 -14
- package/.claude/skills/cowork-harness/references/critique.md +1 -1
- package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
- package/.claude/skills/cowork-harness/references/scenario-schema.md +3 -3
- package/.claude/skills/cowork-harness/references/task-recipes.md +1 -1
- package/CHANGELOG.md +296 -0
- package/CONTRIBUTING.md +12 -0
- package/DESIGN.md +2 -2
- package/README.md +9 -9
- package/SPEC.md +34 -15
- package/baselines/desktop-1.32885.1.json +1 -0
- package/baselines/desktop-1.34493.1.json +791 -0
- package/dist/cli.js +25 -5
- package/dist/run/cassette.js +930 -115
- package/dist/run/jcs.js +81 -0
- package/dist/run/skill-hash.js +182 -45
- package/dist/sync/baseline-diff.js +9 -0
- package/dist/sync/cowork-sync.js +172 -15
- package/dist/types.js +4 -1
- package/docs/cassette.md +172 -32
- package/docs/fidelity-gaps.md +22 -3
- package/docs/invariants.md +2 -1
- package/docs/maintenance.md +62 -8
- package/docs/scenario.md +1 -1
- package/docs/session.md +3 -2
- package/examples/replays/README.md +1 -1
- package/examples/replays/example-multiselect-gate.cassette.json +49 -44
- package/examples/replays/example-pdf-skill.cassette.json +84 -83
- package/examples/replays/hostloop-computer-links.cassette.json +70 -63
- package/package.json +1 -1
- package/schema/cassette.v10.json +1 -1
- package/schema/cassette.v11.json +1 -1
- package/schema/cassette.v12.json +318 -0
- package/schema/cassette.v9.json +21 -19
- package/schema/run-result.json +68 -284
- package/schema/verify-cassettes.json +6 -2
|
@@ -3,8 +3,8 @@ name: cowork-harness
|
|
|
3
3
|
description: Test or debug a Claude Code skill/plugin under Claude Cowork's runtime — sandboxed agent, default-deny egress, the can_use_tool permission/question protocol — using the cowork-harness CLI. Use when validating or regression-testing a skill, authoring or debugging a scenario YAML (prompt + scripted answers + assert:), choosing a fidelity tier, scripting AskUserQuestion / tool-permission answers, or asserting artifacts, egress, or sub-agent dispatch. Especially when a harness run no-ops an assertion, fails on an unanswered gate, false-greens, a steered answer never reaches the model, or a web_fetch is unexpectedly denied or gated. Also when iterating or hardening a skill across fixes, or grounding a skill's self-critique against its own run evidence — including a document-analysis skill (cap table, deck, financial model, transcript) that needs an uploaded file attached to be critiqued at all. NOT for generic unit testing (pytest/vitest of your own scripts) or non-Cowork CI. Covers the skill / run / chat / record / replay / trace / decide / assertions / scaffold commands and the session-vs-scenario split.
|
|
4
4
|
metadata:
|
|
5
5
|
author: cowork-harness
|
|
6
|
-
version:
|
|
7
|
-
tracks-harness: cowork-harness
|
|
6
|
+
version: 2.0.0
|
|
7
|
+
tracks-harness: cowork-harness 2.0.0 (baseline desktop-1.34493.1)
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# cowork-harness
|
|
@@ -22,8 +22,8 @@ flagged with a loud `::warning::`, not silent — auto-answer a gate, observe an
|
|
|
22
22
|
allowlist). This skill exists mostly to keep you out of those traps — the Gotchas section below is
|
|
23
23
|
the highest-value part. Read it.
|
|
24
24
|
|
|
25
|
-
> **Version note:** the facts and `file:line` pointers here track `cowork-harness
|
|
26
|
-
> `desktop-1.
|
|
25
|
+
> **Version note:** the facts and `file:line` pointers here track `cowork-harness 2.0.0` (baseline
|
|
26
|
+
> `desktop-1.34493.1`). If your checkout is newer, prefer the live `--help` and — in a repo checkout —
|
|
27
27
|
> `SPEC.md` / `docs/*.md` over this snapshot, and re-run the bundled linter.
|
|
28
28
|
|
|
29
29
|
## Preflight — make sure the harness can actually run
|
|
@@ -39,7 +39,7 @@ Before the first command, confirm the CLI is reachable and **fail loud** (never
|
|
|
39
39
|
|
|
40
40
|
- **One-shot check.** Run `cowork-harness doctor [--tier <tier>]` first — a read-only prerequisite check that inspects Docker, the staged agent, the token, and the baseline in one pass. The bullets below explain each thing it checks (and how to fix it).
|
|
41
41
|
- **Replay-only? Skip `doctor`.** Replaying committed cassettes needs no Docker, no staged agent, and no token — and every tier's `doctor` validates the auth token (the live tiers also Docker + the staged agent), so a ✗ there is expected, not a blocker. Go straight to `cowork-harness replay <cassette>`.
|
|
42
|
-
- **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥
|
|
42
|
+
- **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 2.0.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=2.0.0" <cmd>` (Node ≥ 22), or install once with `npm i -g "cowork-harness@>=2.0.0"`. **Pin `@>=2.0.0`, never `@latest`** — `@latest` can silently fetch an older CLI and the new commands fail as "unknown command", whereas the floor **fails loud** if no compatible version is published.
|
|
43
43
|
|
|
44
44
|
This skill documents the CURRENT surface, not release history. If `cowork-harness --version` is
|
|
45
45
|
OLDER than the floor, the per-release record of what you are missing is [CHANGELOG.md](https://github.com/yaniv-golan/cowork-harness/blob/main/CHANGELOG.md)
|
|
@@ -407,7 +407,7 @@ That choice is permanent: the cassette rewrites `scenario.session` and `scenario
|
|
|
407
407
|
its own directory** at record time, so moving the file later — a different `--out`, a `git mv`, a copy
|
|
408
408
|
into another repo — leaves those unresolvable and
|
|
409
409
|
`verify-cassettes` reports `unverifiable-skill` ("can't verify ⇒ not green", exit 3) until you
|
|
410
|
-
re-record at the new location. **`record` now says so BEFORE it spends:** a pre-flight — at the same
|
|
410
|
+
re-record at the new location — or point `replay`/`verify-cassettes` at the session with `--session <file>`, which resolves it without a re-record. Since 2.0.0 a bare `replay` FAILS on this class rather than warning. **`record` now says so BEFORE it spends:** a pre-flight — at the same
|
|
411
411
|
pre-spend point as the host-inventory refusal, and in `record --dry-run`, so the rehearsal is free —
|
|
412
412
|
warns when the cassette would be written outside the scenario's tree, or when `session:` itself lives
|
|
413
413
|
outside it (an absolute or `~` path: the mirror case, invisible to a check that only looks at where the
|
|
@@ -1028,8 +1028,14 @@ repeats the assertion/replay-relevant ones alongside the schema (a scoped subset
|
|
|
1028
1028
|
25. **Two distinct host-inventory consent flags — a record-time one and a verify-time one.** `record
|
|
1029
1029
|
--allow-host-inventory-fixture` is the boolean consent to proceed recording a host-inheriting
|
|
1030
1030
|
(`protocol`/`hostloop`/`cowork`-resolving-to-hostloop) cassette into a repo-visible path — otherwise
|
|
1031
|
-
`record` refuses (freezing this machine's MCP servers/agents/account into a committed
|
|
1032
|
-
risk).
|
|
1031
|
+
`record` refuses before it spends (freezing this machine's MCP servers/agents/account into a committed
|
|
1032
|
+
fixture is the risk). That pre-spend check **warns rather than refuses when the cassette already
|
|
1033
|
+
exists** — refusing would fire on every `--rerecord-stale` pass — and it reads the tier and the
|
|
1034
|
+
destination path, never the bytes. So `record` also scans the FINISHED recording, after redaction and
|
|
1035
|
+
before the write: a `host-inventory`/`machine-inventory` finding on a repo-visible path is
|
|
1036
|
+
**quarantined** to `<runs-root>/quarantine/` with a `.findings.txt` naming what leaked, and the command
|
|
1037
|
+
fails without writing the path you asked for (the recording is not discarded — you paid for it).
|
|
1038
|
+
`verify-cassettes --allow-host-inventory <regex>` is unrelated: a per-finding suppressor for the
|
|
1033
1039
|
scanner's `host-inventory` class on an already-committed cassette. Passing one where the other command
|
|
1034
1040
|
wants it fails as an unrecognized flag — they don't interchange. Depth: `references/ci-recipe.md`.
|
|
1035
1041
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CI recipe — replay vs live lanes
|
|
2
2
|
|
|
3
|
-
Self-contained reference. Tracks `cowork-harness
|
|
3
|
+
Self-contained reference. Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`).
|
|
4
4
|
|
|
5
5
|
**Fastest path: the packaged Action.** One step gets you `replay`/`lint`/`verify-cassettes` plus a PR
|
|
6
6
|
job-summary reporter (verdict table, staleness findings, cost/turns when available):
|
|
@@ -13,7 +13,7 @@ job-summary reporter (verdict table, staleness findings, cost/turns when availab
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
The Action's `version` input defaults to `latest` — intentional so a copy-pasted recipe tracks the current
|
|
16
|
-
release; pin an exact version (e.g. `version: "
|
|
16
|
+
release; pin an exact version (e.g. `version: "2.0.0"`) for reproducible CI.
|
|
17
17
|
|
|
18
18
|
Reach for the manual multi-step form below only when you need per-step control the Action's inputs don't
|
|
19
19
|
cover (a custom flag combination, a different runner matrix per step, or `lint`/`verify-cassettes` gated
|
|
@@ -32,7 +32,7 @@ jobs:
|
|
|
32
32
|
- uses: actions/checkout@v4
|
|
33
33
|
- name: Stage the agent binary (official channel, sha256-verified — see https://github.com/yaniv-golan/cowork-harness/blob/main/docs/maintenance.md)
|
|
34
34
|
run: |
|
|
35
|
-
V=2.1.
|
|
35
|
+
V=2.1.237 # match your scenario's pinned baseline's agentVersion
|
|
36
36
|
curl -fSL "https://downloads.claude.ai/claude-code-releases/$V/linux-arm64/claude" -o "$RUNNER_TEMP/claude-$V"
|
|
37
37
|
chmod +x "$RUNNER_TEMP/claude-$V"
|
|
38
38
|
# verify against the committed baseline's sha256 (baselines/desktop-*.json → agentBinary.sha256)
|
|
@@ -58,7 +58,7 @@ sha256-*checked* but not hard-blocking on mismatch — it's advisory for an inte
|
|
|
58
58
|
GitHub-hosted runners, no token/Docker/agent:
|
|
59
59
|
|
|
60
60
|
```yaml
|
|
61
|
-
- run: npm i -g "cowork-harness@>=
|
|
61
|
+
- run: npm i -g "cowork-harness@>=2.0.0"
|
|
62
62
|
- run: cowork-harness lint scenarios/*.yaml --strict --min-severity WARN
|
|
63
63
|
# no silent false-greens. WITHOUT --strict this
|
|
64
64
|
# step cannot fail on a WARN-class rule (e.g.
|
|
@@ -77,7 +77,20 @@ GitHub-hosted runners, no token/Docker/agent:
|
|
|
77
77
|
- run: cowork-harness replay cassettes/ # token-free content/structure
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
If a cassette has MOVED (a `git mv`, a repo reorg, a copy between projects), staleness becomes
|
|
81
|
+
unverifiable — `verify-cassettes` exits 3 and says the skill dirs are not resolvable. Recover with
|
|
82
|
+
`--session <file>` on either command rather than re-recording:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
- run: cowork-harness verify-cassettes cassettes/moved.cassette.json --session sessions/default.yaml
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
It takes a session (not skill dirs) so `staleness.hash_ignore` survives, refuses a directory target,
|
|
89
|
+
and echoes the dirs it resolved. Full contract:
|
|
90
|
+
[docs/cassette.md](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/cassette.md).
|
|
91
|
+
|
|
92
|
+
Both lines matter: for the CONTENT-drift classes (`skill`, `shared-root`) `replay` alone **warns** and
|
|
93
|
+
exits 0, so dropping the
|
|
81
94
|
`verify-cassettes` step means a skill edit silently stops being tested. (One command instead of two:
|
|
82
95
|
`replay --fail-on-skill-drift`.)
|
|
83
96
|
|
|
@@ -210,9 +223,23 @@ dollar figures). In a skill repo these cassettes get **committed**. So:
|
|
|
210
223
|
- **Host-inheriting record refused by default — `--allow-host-inventory-fixture` is the consent.** A
|
|
211
224
|
`protocol`/`hostloop`/`cowork`-resolving-to-hostloop record into a repo-visible cassette path would
|
|
212
225
|
freeze THIS machine's MCP server names, agents, and account metadata into a committed fixture, so
|
|
213
|
-
`record` refuses
|
|
226
|
+
`record` refuses **before the paid spawn**. Pass `--allow-host-inventory-fixture` only when the
|
|
214
227
|
recording session genuinely has no personal MCP servers or plugins to leak — it is a per-record
|
|
215
228
|
boolean consent, not a pattern.
|
|
229
|
+
|
|
230
|
+
Two details that matter for a re-record loop. The pre-spend check **warns rather than refuses when the
|
|
231
|
+
cassette already exists**, deliberately: refusing there would fire on every `--rerecord-stale` pass and
|
|
232
|
+
make the escape flag reflexive. And it is a **prediction** — it reads the tier and the destination path,
|
|
233
|
+
never the resulting bytes, so it can be wrong in both directions.
|
|
234
|
+
|
|
235
|
+
So `record` also checks the **evidence**. After redaction and before the write, the finished cassette is
|
|
236
|
+
scanned; a `host-inventory` or `machine-inventory` finding on a repo-visible path is **quarantined** —
|
|
237
|
+
written to `<runs-root>/quarantine/` (honouring `--run-dir`/`COWORK_HARNESS_RUNS_DIR`) with a
|
|
238
|
+
`.findings.txt` sibling naming what leaked, and the command fails without writing the path you asked for.
|
|
239
|
+
The recording is not discarded; you paid for it. Only the machine-identity classes trigger this —
|
|
240
|
+
`email`/`currency`/`domain`/`path` are frequently legitimate scenario content, and a gate that fires on
|
|
241
|
+
those just teaches you to pass the escape flag. Outside a git repo it warns instead: nothing there
|
|
242
|
+
publishes the file by accident.
|
|
216
243
|
- **Always-on scan gate** — `verify-cassettes` flags email / currency / bare-domain / local-path /
|
|
217
244
|
machine-inventory matches it finds in the committed cassettes and **exits non-zero**, so "no leak" is
|
|
218
245
|
a gate, not discipline. Non-zero is not one thing, though: exit `1` means verification RAN and found a
|
|
@@ -222,6 +249,13 @@ dollar figures). In a skill repo these cassettes get **committed**. So:
|
|
|
222
249
|
$? -ne 0 ]` tripwire treats both the same — if you need to tell "the gate caught something" apart from
|
|
223
250
|
"the gate couldn't run", branch on the exit code (or parse `--output-format json`'s per-file
|
|
224
251
|
`findings`/`staleness` vs `unverifiable`/`version`/`error` buckets).
|
|
252
|
+
|
|
253
|
+
**Do not read `error` as "this file was never scanned".** The privacy scan needs a readable *transcript*
|
|
254
|
+
(an `events` array of strings), not a *valid* cassette — so a file that fails shape validation is still
|
|
255
|
+
scanned, and reports its findings **and** its `error`. Each result carries **`privacyScanned`**, which
|
|
256
|
+
answers that question directly. A gate that must not treat "could not verify" as "verified clean" should
|
|
257
|
+
key on `privacyScanned === false`, where `findings: []` is an absence of evidence rather than evidence of
|
|
258
|
+
absence. `--skip-privacy` also reports `false`, for the same reason.
|
|
225
259
|
Suppress synthetic / public reference names (NVCA, Cooley GO, …) with `--allow <regex>`. (Multi-word
|
|
226
260
|
proper names are NOT a default class — too noisy to gate on; add a pattern via config if your corpus
|
|
227
261
|
needs it.)
|
|
@@ -289,7 +323,7 @@ jobs:
|
|
|
289
323
|
with: { node-version: '24' }
|
|
290
324
|
- uses: actions/setup-python@v5
|
|
291
325
|
with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
|
|
292
|
-
- run: npm i -g "cowork-harness@>=
|
|
326
|
+
- run: npm i -g "cowork-harness@>=2.0.0"
|
|
293
327
|
- run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
|
|
294
328
|
- run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
|
|
295
329
|
- run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
|
|
@@ -318,7 +352,7 @@ jobs:
|
|
|
318
352
|
echo "live=true" >> "$GITHUB_OUTPUT"
|
|
319
353
|
fi
|
|
320
354
|
- if: steps.guard.outputs.live == 'true'
|
|
321
|
-
run: npm i -g "cowork-harness@>=
|
|
355
|
+
run: npm i -g "cowork-harness@>=2.0.0"
|
|
322
356
|
- if: steps.guard.outputs.live == 'true'
|
|
323
357
|
run: cowork-harness run scenarios/ --output-format json
|
|
324
358
|
env:
|
|
@@ -406,19 +440,21 @@ evaluation), not present-and-passing. A CI script that counts assertions will se
|
|
|
406
440
|
on replay vs live — compare by assertion identity / pass-fail, not by total count. The count of
|
|
407
441
|
skipped live-only assertions is reported on each replay result as `skippedAssertions: {full, partial}`.
|
|
408
442
|
|
|
409
|
-
## Staleness does NOT fail a replay
|
|
443
|
+
## Staleness mostly does NOT fail a replay — read it from the JSON
|
|
410
444
|
|
|
411
|
-
A plain `replay` **warns** on a
|
|
412
|
-
does **not** imply the recording is still valid.
|
|
445
|
+
A plain `replay` **warns** on a DRIFTED cassette (skill/baseline drift) but stays `ok:true` — a green
|
|
446
|
+
replay does **not** imply the recording is still valid. **Since 2.0.0 there is one exception:**
|
|
447
|
+
`unverifiable-skill` — staleness that could not be checked at all, most often a cassette that moved —
|
|
448
|
+
FAILS a bare `replay`. Recover with `--session <file>` rather than re-recording. Each replay result carries `staleness[]`, an array of
|
|
413
449
|
`{class, message}`, so a token-free gate can act on it without `ok` being the whole story:
|
|
414
450
|
|
|
415
451
|
| `class` | meaning | concern |
|
|
416
452
|
|---|---|---|
|
|
417
453
|
| `baseline` | platform baseline moved since record | low (format-compatible) |
|
|
418
454
|
| `skill` / `shared-root` | the skill source the assertions validate drifted | **high** (assertions may validate dead code) |
|
|
419
|
-
| `format` |
|
|
455
|
+
| `format` | the git/raw file-set mode or agent-scope differs from the recording | re-record under the same setting (waivable) |
|
|
420
456
|
| `unverifiable-baseline` | the latest baseline couldn't be loaded | couldn't verify (env, not skill) |
|
|
421
|
-
| `unverifiable-skill` | skill dirs unresolvable
|
|
457
|
+
| `unverifiable-skill` | skill dirs unresolvable, **or** the cassette predates the hash-format epoch (v12) so its digest is not comparable | couldn't verify the skill. **Fails a bare `replay`.** For the epoch case try `rehash` first — it migrates without a re-record where it can prove the content unchanged (`rehash <file> --session <s.yaml>` if the cassette moved) |
|
|
422
458
|
| `resolved-tier` | a `fidelity: cowork` cassette's recorded `effectiveFidelity` no longer matches what the baseline resolves to today (the host-loop gate flipped) | **high** (the recording exercises the wrong tier) |
|
|
423
459
|
| `unverifiable-tier` | tier check couldn't run for a `fidelity: cowork` cassette (no recorded `effectiveFidelity`, or its pinned baseline failed to load) | couldn't verify the tier — re-record |
|
|
424
460
|
|
|
@@ -433,7 +469,7 @@ fail it either way.)
|
|
|
433
469
|
To gate in CI, pick the severity you want:
|
|
434
470
|
|
|
435
471
|
- `replay --strict` — fail (exit 1) on **any** staleness class.
|
|
436
|
-
- `replay --fail-on-skill-drift` — fail
|
|
472
|
+
- `replay --fail-on-skill-drift` — fail on the skill-source DRIFT classes (`skill` / `shared-root`); `unverifiable-skill` needs no flag, it fails the default verdict since 2.0.0;
|
|
437
473
|
baseline / format / `unverifiable-baseline` stay non-failing warnings.
|
|
438
474
|
Note `--allow-failing` waives this gate wholesale, including the copy `--assert-from` turns on for you:
|
|
439
475
|
`replay --assert-from … --write --allow-failing` will persist an assert block validated against a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Critique — the facts a plugin install can't otherwise reach
|
|
2
2
|
|
|
3
|
-
Tracks `cowork-harness
|
|
3
|
+
Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`). This is **not** a trim of the full
|
|
4
4
|
[`docs/critique.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/critique.md) (repo-only —
|
|
5
5
|
flags, cost, reproduction discipline, known limitations all live there). This file covers exactly what a
|
|
6
6
|
plugin install cannot otherwise discover: the run-dir artifact a harvester actually reads, the report's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Fidelity tiers & answer paths
|
|
2
2
|
|
|
3
|
-
Self-contained reference. Tracks `cowork-harness
|
|
3
|
+
Self-contained reference. Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`).
|
|
4
4
|
|
|
5
5
|
## Fidelity tiers (`fidelity:` in the scenario)
|
|
6
6
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Scenario & session schema, assertion catalog, web_fetch, full gotchas
|
|
2
2
|
|
|
3
|
-
Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness
|
|
4
|
-
(baseline `desktop-1.
|
|
3
|
+
Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 2.0.0`
|
|
4
|
+
(baseline `desktop-1.34493.1`). If your checkout is newer, prefer the live [`docs/scenario.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/scenario.md),
|
|
5
5
|
[`docs/session.md`](https://github.com/yaniv-golan/cowork-harness/blob/main/docs/session.md), and `SPEC.md`.
|
|
6
6
|
|
|
7
7
|
**Minimal scenario** — `prompt` is the only required field:
|
|
@@ -107,7 +107,7 @@ form a relocatable bundle. `~` expands to home.
|
|
|
107
107
|
> references relative to **its own** directory at record time (`scenario.session` and
|
|
108
108
|
> `scenarioSource`), so moving it afterwards — a different `--out`, a
|
|
109
109
|
> `git mv`, a copy into another repo — leaves them unresolvable and `verify-cassettes` reports
|
|
110
|
-
> `unverifiable-skill` ("can't verify ⇒ not green", exit 3) until you re-record at the new location
|
|
110
|
+
> `unverifiable-skill` ("can't verify ⇒ not green", exit 3) until you re-record at the new location, or pass `--session <file>`.
|
|
111
111
|
> Decide where a cassette will live *before* you record it.
|
|
112
112
|
|
|
113
113
|
## Session YAML
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Each recipe composes facts that live scattered across SKILL.md and the other references into one
|
|
4
4
|
decision path. Every one answers a question a real fleet owner had to work out the hard way.
|
|
5
|
-
Tracks `cowork-harness
|
|
5
|
+
Tracks `cowork-harness 2.0.0` (baseline `desktop-1.34493.1`), same as SKILL.md's front-matter. Recipe 2's `resolved-tier`/`unverifiable-tier` staleness classes and
|
|
6
6
|
Recipe 3's `init-redact` shipped in 0.24.0 and are part of the current feature set — no version gate
|
|
7
7
|
needed if your CLI meets SKILL.md's version floor.
|
|
8
8
|
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,302 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.0.0] — 2026-08-21
|
|
10
|
+
|
|
11
|
+
### Changed — BREAKING (requires a major bump; see [SPEC.md §12](./SPEC.md#12-versioning--the-10-compatibility-contract))
|
|
12
|
+
|
|
13
|
+
- **HASH-FORMAT EPOCH — `cassetteVersion` 12. Every cassette carrying a `skillHash` fails a bare `replay`
|
|
14
|
+
until it is migrated.** Read that sentence literally: this is not a warning you can defer.
|
|
15
|
+
|
|
16
|
+
**Migrate with one command.** `cowork-harness rehash <dir/>` proves each cassette's content unchanged and
|
|
17
|
+
relabels it in place — no re-record, no model calls, no cost. For a cassette that has MOVED (a `git mv`, a
|
|
18
|
+
repo reorg, a copy into another project) its skill sources cannot be resolved from its own directory, so
|
|
19
|
+
use `cowork-harness rehash <file.cassette.json> --session <session.yaml>`. Anything `rehash` cannot prove
|
|
20
|
+
is refused rather than migrated, and does need a real re-record.
|
|
21
|
+
|
|
22
|
+
**After a successful `rehash`, the epoch is cleared and you are done with it.** Migration does not touch
|
|
23
|
+
the other staleness classes, and it does not need to: `baseline`, `format` and `prompt-assets` behave
|
|
24
|
+
exactly as before — they surface in `staleness[]`, print a `::warning::`, and **exit 0**. Only
|
|
25
|
+
`unverifiable-skill` fails a bare `replay`, which is what the epoch produces and what `rehash` clears. So
|
|
26
|
+
a cassette that also carries, say, baseline drift migrates cleanly and then goes green with that drift
|
|
27
|
+
still reported as a warning, exactly as before this release. (Measured, not inferred: a cassette with a
|
|
28
|
+
deliberately wrong `fingerprint.baseline` replays `pass: true`, exit 0, with a non-failing `[baseline]`
|
|
29
|
+
finding.)
|
|
30
|
+
|
|
31
|
+
**`verify-cassettes` is stricter than `replay`, and that is not new.** It treats ANY staleness finding as
|
|
32
|
+
not-green, so a migrated cassette that still carries baseline drift replays green and reds the
|
|
33
|
+
verification gate — as it did before this release, for the same reason. Clear it by re-recording, or by
|
|
34
|
+
re-stamping `fingerprint.baseline` where the baseline moved without changing anything the recording
|
|
35
|
+
exercises; [docs/cassette.md](./docs/cassette.md#cassette-versioning) covers when that re-stamp is honest
|
|
36
|
+
and when it is not. This release also ships a new platform baseline, so expect that drift on cassettes
|
|
37
|
+
recorded against the previous one.
|
|
38
|
+
|
|
39
|
+
**What changed.** A plugin manifest now folds into `skillHash`/`contentSig` through canonical (JCS-style)
|
|
40
|
+
serialization instead of insertion-order `JSON.stringify`, so **reordering keys in `plugin.json` no longer
|
|
41
|
+
re-stales every cassette that hashes it** — semantically identical input now produces an identical digest.
|
|
42
|
+
`contentSig` additionally folds the directory markers `skillHash` has always folded, so an added or removed
|
|
43
|
+
**empty directory** is finally visible to it; `CONTENTSIG_ALGO` moves 4 → 5 to say so. A new
|
|
44
|
+
`fingerprint.hashFormat` records which transform produced the digests — **absent means the legacy
|
|
45
|
+
pre-v12 transform, never raw bytes**, since every cassette already on disk carries version-stripped
|
|
46
|
+
manifest digests.
|
|
47
|
+
|
|
48
|
+
**Why it fails rather than warns.** A pre-epoch digest and a post-epoch digest came from different
|
|
49
|
+
algorithms; they cannot be compared at all. That is `unverifiable-skill` — "this was not verified" — and
|
|
50
|
+
not `format`, which is waivable and exits 0. A warning everyone ignores for a release is exactly the
|
|
51
|
+
green-against-unverified state the strict-replay change below exists to end, and on the day of an epoch it
|
|
52
|
+
would apply to every cassette in existence.
|
|
53
|
+
|
|
54
|
+
**Some cassettes will migrate with an unchanged digest.** A manifest whose keys were already in canonical
|
|
55
|
+
order, or a tree with no manifest at all, hashes identically under both algorithms. Those are still
|
|
56
|
+
flagged and migrated, **by recorded version rather than by whether the number moved** — otherwise they
|
|
57
|
+
would pass unlabelled, and at the next epoch nobody could tell which algorithm produced them.
|
|
58
|
+
|
|
59
|
+
`cassetteVersion` now means the minimum reader for the **whole cassette**, not just for its `scenario`
|
|
60
|
+
keys: a v11 reader handed a v12 cassette would recompute legacy digests and report drift that is not
|
|
61
|
+
there. The scenario-aware differential still applies above that floor.
|
|
62
|
+
|
|
63
|
+
- **A bare `replay` now FAILS when skill staleness cannot be verified** (`unverifiable-skill`), where it
|
|
64
|
+
previously warned on stderr, recorded the class in `staleness[]`, and exited `0`. "Could not be checked
|
|
65
|
+
at all" and "checked and unchanged" are different claims and only the second is green — a cassette that
|
|
66
|
+
had silently stopped proving anything kept passing the lane most people run, and green-against-unverified
|
|
67
|
+
is worse than a loud red because silence prompts a re-record while green does not. The major is required because a previously-green
|
|
68
|
+
input now exits non-zero — `verdict.pass` / `ok` and the per-command exit behaviour are covered surfaces
|
|
69
|
+
([SPEC.md §12](./SPEC.md#12-versioning--the-10-compatibility-contract)). Note the *meaning* of exit 1 is
|
|
70
|
+
unchanged (still assertion-or-agent failure), so §11's exit-code table needs no edit; what changed is
|
|
71
|
+
which inputs reach it.
|
|
72
|
+
|
|
73
|
+
**Deliberately narrow.** The content-drift classes (`skill`, `shared-root`) still require
|
|
74
|
+
`--fail-on-skill-drift`, so that flag keeps its meaning and no inverse escape hatch is needed.
|
|
75
|
+
|
|
76
|
+
**One exception, and it exists to stop a false green:** under an explicit `--session`, the drift classes
|
|
77
|
+
are escalated too. Without that, pointing `--session` at a WRONG but resolvable tree would turn
|
|
78
|
+
`unverifiable-skill` (a hard fail) into ordinary `skill` drift (warn-only) — so the flag could be used,
|
|
79
|
+
accidentally, to silence the very gate it exists to help you escape. `--session` is an escape from
|
|
80
|
+
"cannot verify", never from "verified, and it changed".
|
|
81
|
+
|
|
82
|
+
**Migration.** The commonest cause is a cassette that MOVED — use the new `--session <file>` below
|
|
83
|
+
rather than re-recording. If you genuinely want the old behaviour for a lane, it was never expressible
|
|
84
|
+
as a flag and still isn't; fix the resolution instead.
|
|
85
|
+
|
|
86
|
+
- **`verify-cassettes --output-format json` gained a REQUIRED `privacyScanned` field on every result.**
|
|
87
|
+
`schema/verify-cassettes.json` is a covered 1.0 surface ([SPEC.md §12](./SPEC.md#12-versioning--the-10-compatibility-contract)),
|
|
88
|
+
and `privacyScanned` is now in its `required` list — so a consumer validating this build's output against
|
|
89
|
+
an older copy of the schema is unaffected, but one validating an OLDER CLI's output against the NEW schema
|
|
90
|
+
will fail. It is emitted on every return path, so anything keying on it can rely on it.
|
|
91
|
+
|
|
92
|
+
The field exists because `error` became ambiguous. A cassette that fails SHAPE validation is now still
|
|
93
|
+
privacy-scanned (see Fixed, below), so `error` covers both "never scanned" and "scanned fine, just not
|
|
94
|
+
replayable" — and a gate cannot tell those apart from it. **If you have CI keyed on `error` meaning "this
|
|
95
|
+
file was not checked", that reading is now wrong; switch to `privacyScanned === false`,** where
|
|
96
|
+
`findings: []` is an absence of evidence rather than evidence of absence. `--skip-privacy` also reports
|
|
97
|
+
`false`, for the same reason.
|
|
98
|
+
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- **`provenance.asarGateIds` — a gate-membership change is now nameable.** A baseline recorded
|
|
102
|
+
`provenance.fcache` as two aggregates and a timestamp, so when a sync moved `featureCount` **271 → 278**
|
|
103
|
+
nothing could say *which* seven arrived, a count-neutral membership swap was invisible entirely, and
|
|
104
|
+
`content16`'s diff line had to hedge "membership **and/or** values moved". The fcache is also
|
|
105
|
+
server-refreshed on its own schedule (3.7–20.8 min observed), so a count delta between two baselines is
|
|
106
|
+
a net over days of rollout rather than a fact about the Desktop release — and the previous payload is
|
|
107
|
+
overwritten in place, so the question goes unanswerable the moment you think to ask it.
|
|
108
|
+
|
|
109
|
+
Baselines now additionally record the gate ids **this release's own bundle references**, sorted. That is
|
|
110
|
+
a pure function of the shipped asar: reproducible by anyone, stable across the fcache's refetches, and
|
|
111
|
+
attributable to the release. Diffing two baselines names the delta outright (1.32885.1 → 1.34493.1:
|
|
112
|
+
**+14 / −1**), and `sync --diff` prints the ids rather than a count.
|
|
113
|
+
|
|
114
|
+
It is deliberately **not** intersected with the syncing machine's fcache. Gate membership varies by
|
|
115
|
+
account segment, so filtering through one machine would both leak which gates that operator is served
|
|
116
|
+
and drop DARK gates — 51 of the recorded ids are absent from the live fcache, `enableToolSearchAuto`
|
|
117
|
+
among them. To turn an id into a name, grep it as a quoted literal in the extracted bundle; the call
|
|
118
|
+
site names it, which is how `PINNED_GATES` was built in the first place.
|
|
119
|
+
|
|
120
|
+
- **`record` now scans what it wrote, and quarantines a leaking recording instead of publishing it.**
|
|
121
|
+
`scanCassette` had exactly one production call site — `verify-cassettes` — which runs at commit time at
|
|
122
|
+
the earliest. `hostInventoryPreflight` does fire before the paid spawn, but it reads the tier and the
|
|
123
|
+
destination path, never the resulting bytes: it is a prediction, and it can be wrong in both directions.
|
|
124
|
+
|
|
125
|
+
After redaction and before the write, the finished cassette is scanned. A `host-inventory` or
|
|
126
|
+
`machine-inventory` finding on a repo-visible path writes the recording to `<runs-root>/quarantine/`
|
|
127
|
+
(honouring `--run-dir` / `COWORK_HARNESS_RUNS_DIR`) alongside a `.findings.txt` naming exactly what
|
|
128
|
+
leaked, then fails without writing the requested path.
|
|
129
|
+
|
|
130
|
+
Three things about that policy are deliberate. **Quarantine, not discard** — the tokens are already
|
|
131
|
+
spent, and throwing the recording away is both the most expensive answer and the one most likely to end
|
|
132
|
+
in "just commit it anyway". **Only the machine-identity classes trigger it** — `email` / `currency` /
|
|
133
|
+
`domain` / `path` findings are frequently legitimate scenario content, and a gate that fires on those
|
|
134
|
+
teaches the operator to pass the escape flag by reflex, which is how a safety gate becomes decoration.
|
|
135
|
+
**Outside a git repo it warns instead of quarantining** — nothing there publishes the file by accident,
|
|
136
|
+
so quarantining would be obstruction rather than protection. If the runs root is itself inside a working
|
|
137
|
+
tree, quarantine falls back to the OS temp dir and says so; moving a leak into another committable
|
|
138
|
+
location would be theatre.
|
|
139
|
+
|
|
140
|
+
`--allow-host-inventory-fixture` (the flag the preflight already honours) still writes the file, and now
|
|
141
|
+
reports what it is publishing rather than going quiet.
|
|
142
|
+
|
|
143
|
+
Coverage is labelled honestly: the policy (`classifyRecordLeak`) and the effect (`quarantineCassette`)
|
|
144
|
+
are pure, exported and mutation-tested; the *wiring* inside `recordScenarioObject` needs a live spawn to
|
|
145
|
+
reach, so it is guarded structurally — the same split this repo already uses for `buildCassette`.
|
|
146
|
+
|
|
147
|
+
- **Corrected a standing inaccuracy in the record-consent docs.** `SKILL.md` and `ci-recipe.md` both said
|
|
148
|
+
the host-inventory preflight "refuses outright rather than warn". It refuses for a **new** cassette path
|
|
149
|
+
but **warns** for one that already exists — deliberately, since refusing there would fire on every
|
|
150
|
+
`--rerecord-stale` pass and make the escape flag reflexive. That warn path was the gap the record-time
|
|
151
|
+
quarantine above now covers, so both documents now say which is which.
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
- **Platform baseline `desktop-1.34493.1` (agent `2.1.237`).** The Cowork system prompt, both sub-agent
|
|
155
|
+
append branches, all 28 pinned gate states, the VM egress policy and the 22-key spawn env are all
|
|
156
|
+
unchanged — re-derived from the new bundle rather than inferred from an absent diff row (only the
|
|
157
|
+
minified prompt constant id rotated). `sync` reports no unknown deltas.
|
|
158
|
+
|
|
159
|
+
The substantive change is in the **agent**, and an asar-only pass cannot see it: `2.1.237` adds six
|
|
160
|
+
Cowork-specific risk categories to the auto-mode permission rubric's vocabulary —
|
|
161
|
+
`cowork_delete_grant`, `cowork_folder_access`, `cowork_run_routine_now`,
|
|
162
|
+
`cowork_scheduled_task_delete`, `cowork_scheduled_task_write`, `cowork_skill_persistence` — in both the
|
|
163
|
+
native binary and the VM ELF. They are **inert for this harness**: the rubric is entered only on
|
|
164
|
+
`permissionMode === "auto"`, which no scenario can request and no baseline pins (now pinned by
|
|
165
|
+
`test/auto-mode-unreachable.test.ts`). They matter because the rubric's reach now names operations the
|
|
166
|
+
harness does model, so the existing "auto-mode permission rubric is not modeled" gap has moved from
|
|
167
|
+
generic infrastructure risk into Cowork territory. The agent's env-flag export table also moves
|
|
168
|
+
524 → 533; none of the additions are set by the Cowork spawn.
|
|
169
|
+
|
|
170
|
+
All three committed example cassettes are re-recorded against this baseline and stamp v12 /
|
|
171
|
+
`hashFormat: "jcs1"`.
|
|
172
|
+
|
|
173
|
+
- **`--session <file>` on `replay` and `verify-cassettes`** — the escape hatch for a relocated cassette. A
|
|
174
|
+
cassette stores `session:` relative to its own directory, so any move (`git mv`, a repo reorganisation, a
|
|
175
|
+
copy into another project) made skill staleness permanently unverifiable with no way to say where the
|
|
176
|
+
tree went; the only remedies were moving the file back or re-recording. It takes a **session**, not skill
|
|
177
|
+
directories, because `staleness.hash_ignore` is a session-level field that is not stored in the cassette —
|
|
178
|
+
an override carrying only directories would silently change the hash boundary. Refused for a directory
|
|
179
|
+
target, for a path that is not a file, and for inline scenarios; the resolved session **and the dirs it
|
|
180
|
+
produced** are echoed on stderr, since an override that silently pinned the wrong tree would manufacture
|
|
181
|
+
false greens. An explicit override is trusted: a mismatch under it is reported as real drift rather than
|
|
182
|
+
downgraded.
|
|
183
|
+
|
|
184
|
+
- **Duplicate manifest paths are now reported as ambiguous** instead of silently under-reported. Two mounts
|
|
185
|
+
can each contribute `skills/x/SKILL.md`, and every consumer keys `fileSigs` by path, so duplicates
|
|
186
|
+
collapsed to the last occurrence and the changed-file list could name the wrong file or none. Drift was
|
|
187
|
+
always still DETECTED — the hash folds every entry — so this is an attribution fix, not a false-green fix.
|
|
188
|
+
Exact attribution needs per-root identity in the digest, which is a hash-format epoch change.
|
|
189
|
+
|
|
190
|
+
### Fixed
|
|
191
|
+
|
|
192
|
+
- **A cassette that fails shape validation is now privacy-scanned.** `verify-cassettes` does two
|
|
193
|
+
independent jobs — a privacy scan and a staleness check — and both sat behind one strict `readCassette`.
|
|
194
|
+
Any document that failed shape validation returned early, so `scanCassette`, on the very next line, never
|
|
195
|
+
ran: the file was reported with zero findings, which reads in every summary as `0 PII finding(s)`. That is
|
|
196
|
+
a clean-looking number from an instrument that never ran, and a file too broken to replay is exactly the
|
|
197
|
+
kind of file a leak arrives in. Measured on a malformed fixture carrying MCP server names, an account org
|
|
198
|
+
and an agent roster: **0 findings and exit 3 before, 6 findings and exit 1 after.**
|
|
199
|
+
|
|
200
|
+
The fix is a read-boundary **split**, not a loosening: `readCassette` stays exactly as strict (replay,
|
|
201
|
+
staleness and the hash-format epoch's version/`hashFormat` invariant all depend on it). A separate
|
|
202
|
+
`readCassetteForScan` reads the transcript only, requiring nothing but `events: string[]`, and
|
|
203
|
+
`scanCassette`'s parameter type is narrowed to the fields it genuinely reads — so a future scan axis that
|
|
204
|
+
reaches for some other field is a compile error until the projection carries it, rather than silently
|
|
205
|
+
reading `undefined` off a partial document. Narrowing that type immediately turned up five fields the
|
|
206
|
+
scan reads that a hand audit had missed (`userVisibleRoots`, `scenario.name`, `scenario.session`,
|
|
207
|
+
`scenarioSource`, `environment.agentImage.ref`).
|
|
208
|
+
|
|
209
|
+
The projection also **fails closed on an unrecognized tier**. `scanCassette` exempts a positively-sealed
|
|
210
|
+
tier and scans everything else including `undefined`, but it tests set membership — so an arbitrary
|
|
211
|
+
string (`"garbage"`, a typo'd `"containerr"`) is neither, and would have skipped the structural
|
|
212
|
+
host-inventory scan entirely. The strict reader cannot produce that; this one can, because malformed
|
|
213
|
+
input is its whole job.
|
|
214
|
+
|
|
215
|
+
- **The pre-commit cassette gate now fails CLOSED.** `.githooks/pre-commit` tested `hook_status` for `1`
|
|
216
|
+
(block) and `3` (warn) and let every other outcome through to a successful commit, so the guard switched
|
|
217
|
+
itself off — silently — on outcomes that ordinary refactors produce: exit `2` (`examples/replays/` renamed
|
|
218
|
+
or emptied, or any CLI flag renamed), exit `127` (`node` off `PATH`), and a missing `dist/cli.js`, which is
|
|
219
|
+
the state of a fresh clone, a `git clean -xdf`, a branch switch, and any tree with a failing typecheck
|
|
220
|
+
(`npm run build` does `rm -rf dist` first). None of those fail a test elsewhere. Anything that is not a
|
|
221
|
+
proven clean `0` now blocks.
|
|
222
|
+
|
|
223
|
+
This matters more than a local convenience: `ci.yml` triggers on `push: [main]` and `pull_request`, but the
|
|
224
|
+
documented workflow lands with `merge --ff-only` into `main` and pushes afterwards — so on that path the
|
|
225
|
+
hook is not one layer of two, it is the only gate, and what it waves through is already public when CI
|
|
226
|
+
reds.
|
|
227
|
+
|
|
228
|
+
- **An unscannable cassette no longer commits unscanned.** Exit 3 folds together unverifiable *staleness*,
|
|
229
|
+
an unsupported `cassetteVersion`, and a per-file read error. Only the first means "we looked and could not
|
|
230
|
+
conclude"; the other two mean the privacy scan never ran, so there is no evidence either way about host
|
|
231
|
+
inventory — and a cassette recorded by a newer CLI than the committer's `dist/` committed with nothing
|
|
232
|
+
having checked it. The hook now splits exit 3 by cause (`--output-format json`) and blocks on the two that
|
|
233
|
+
are not staleness, in line with the `can't verify ⇒ not green` rule the CLI states everywhere else. A
|
|
234
|
+
payload it cannot interpret is treated as undetermined, not as staleness.
|
|
235
|
+
|
|
236
|
+
- **The gate no longer misses cassettes by filename.** `record --out` accepts an arbitrary path and does not
|
|
237
|
+
validate the suffix, so a recording written to e.g. `notes/run.json` was invisible to the hook's trigger,
|
|
238
|
+
to CI's pathspec, and to `git ls-files '*.cassette.json'`. The hook trigger is now content-derived (any
|
|
239
|
+
staged `.json` whose staged blob carries the `"generator": "cowork-harness"` marker), and CI cross-checks
|
|
240
|
+
the suffix-derived set against the content-derived one. Separately, `resolveInputs` does not recurse, so
|
|
241
|
+
`examples/replays/sub/x.cassette.json` was reachable by neither the directory scan nor the per-file loop
|
|
242
|
+
(which excluded all of `examples/replays/`); the exclusion is now scoped to files *directly* in that
|
|
243
|
+
directory.
|
|
244
|
+
|
|
245
|
+
- **CI scans every tracked cassette, not just one directory.** The sweep is derived from `git ls-files`
|
|
246
|
+
rather than a hard-coded path, fails loudly if the pathspec ever matches zero files (an `xargs -r` sweep
|
|
247
|
+
exits 0 on no matches and reads as success), and carries exactly one exclusion with its reason recorded:
|
|
248
|
+
`test/evals/files/report-check.cassette.json` is an eval *attachment*, not a recording — it has no
|
|
249
|
+
`scenario.session`, so `readCassette` rejects its shape before the scan can run. Measured rather than
|
|
250
|
+
assumed: adding a `session` field does not make it verifiable, it converts the exit 3 into an exit 1 on
|
|
251
|
+
baseline drift for a fixture that is never re-recorded.
|
|
252
|
+
|
|
253
|
+
- **The local gate is no longer laxer than the CI gate on the same directory.** The hook passed
|
|
254
|
+
`--allow-email`/`--allow-domain` suppressions that `ci.yml` does not; the committed fixtures verify clean
|
|
255
|
+
without them, so they are gone. Its scratch file also moved from a predictable `/tmp` path to `mktemp`, and
|
|
256
|
+
a clean run now says so instead of passing in silence.
|
|
257
|
+
|
|
258
|
+
- **`sync` derived the enforced egress allowlist from a bundle-wide regex, and Desktop 1.34493.1 made that
|
|
259
|
+
wrong.** `network.allowDomains` is the allowlist the harness ENFORCES (`boundaryAllowList` plus the
|
|
260
|
+
session egress plan), but it was built by matching every `*.anthropic.com` / `*.claude.ai` literal in the
|
|
261
|
+
whole app bundle. 1.34493.1 added a webview first-party-origin classifier — a navigation-trust tier naming
|
|
262
|
+
`www.claude.ai` and `staging.claude.ai` — and the sweep pulled both into the enforced list, which would
|
|
263
|
+
have permitted egress Cowork denies.
|
|
264
|
+
|
|
265
|
+
Narrowing the sweep was investigated and rejected: on the first-party deployment there is nothing to
|
|
266
|
+
narrow to. The 1p class returns `vmEgressPolicy(){return null}`, so `resolveVmAllowedDomains` falls
|
|
267
|
+
through to the session's **server-delivered** `egressAllowedDomains`, and the only host the bundle
|
|
268
|
+
contributes is the OTLP endpoint. `vmAllowedDomains`/`firewallAlso` are the 3p path and Desktop's own
|
|
269
|
+
renderer endpoints — neither is the VM allowlist. Any bundle scan is unsound both ways: blind to
|
|
270
|
+
server-delivered hosts, open to hosts that are not egress.
|
|
271
|
+
|
|
272
|
+
`network.allowDomains` is therefore a **pinned, hand-curated list carried forward** from the newest
|
|
273
|
+
committed baseline, and a new fail-closed `checkEgressContractFacts` guards the three constructions that
|
|
274
|
+
justify pinning (the 1p null policy, the resolver's fall-through to its caller-supplied argument, and the
|
|
275
|
+
OTLP-only augmentation) — so a real change in how Cowork computes egress hard-fails `sync` instead of
|
|
276
|
+
silently rewriting the list. The baseline's `network.$comment` now records that provenance and names the
|
|
277
|
+
entries that are unverified as VM egress; `network` became a `looseObject` so that note survives loading.
|
|
278
|
+
|
|
279
|
+
- **Consumers of 1.25.0 were NOT affected by this** — it is recorded because the shape is worth knowing,
|
|
280
|
+
not because it shipped. While building `--session`, skill staleness, session-shape staleness, label
|
|
281
|
+
provenance and the skill-hash debug dump could each have resolved a different session. The cassette-relative join was duplicated byte-identically
|
|
282
|
+
in three functions and `sessionFingerprintDrift` accepted an override its only caller never passed, so
|
|
283
|
+
`--session` would have verified skill content against the override while session shape still resolved the
|
|
284
|
+
recorded path — reporting "clean" on an axis that hard-fails when used normally. All consumers now share
|
|
285
|
+
one resolver.
|
|
286
|
+
|
|
287
|
+
- **An unresolvable session now says WHY** — missing file, unreadable/unparseable YAML, or declared mounts
|
|
288
|
+
that do not exist — instead of one undifferentiated message. Those point at different fixes, and only the
|
|
289
|
+
first looks like a relocation, so it also names the remedy.
|
|
290
|
+
|
|
291
|
+
### Known limitations (documented, not fixed)
|
|
292
|
+
|
|
293
|
+
- **Multi-root skill hashing is order-dependent.** `hashSkillDirs` folds roots sorted by absolute path
|
|
294
|
+
while deliberately excluding a root's own name from the digest, so identical content at differently
|
|
295
|
+
sorting directory names hashes differently. One axis (root ORDER) fails loudly as false drift. A second is
|
|
296
|
+
worse: roots fold into one digest with no root-boundary marker, so moving a file BETWEEN roots is
|
|
297
|
+
invisible — a genuine false green. Multi-root cassettes are still not refused, because none exists in
|
|
298
|
+
any reachable corpus and refusing would break input nobody has; instead replay now emits a note when a
|
|
299
|
+
cassette records two or more roots. Fixing it needs a hash-format epoch.
|
|
300
|
+
Not scheduled: no multi-root cassette exists in any reachable corpus (32 cassettes on the widest
|
|
301
|
+
denominator across three repos), and no session declares 2+ plugin/skill roots. Pinned by tests in
|
|
302
|
+
`test/skill-hash.test.ts` so the eventual fix is a deliberate change.
|
|
303
|
+
|
|
304
|
+
|
|
9
305
|
## [1.25.0] — 2026-08-20
|
|
10
306
|
|
|
11
307
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -88,6 +88,18 @@ When a Desktop release moves something `sync` doesn't read, it reports an `unkno
|
|
|
88
88
|
|
|
89
89
|
## Commit & PR
|
|
90
90
|
|
|
91
|
+
- **A pre-commit hook gates cassettes, and it fails CLOSED.** `npm install` points `core.hooksPath` at
|
|
92
|
+
`.githooks/`. Staging a `baselines/desktop-*.json`, any `*.cassette.json`, or any `.json` whose staged
|
|
93
|
+
content carries the `"generator": "cowork-harness"` marker runs `verify-cassettes` — and **anything that
|
|
94
|
+
is not a proven clean exit `0` blocks the commit**, including a missing `dist/cli.js`. That last one
|
|
95
|
+
catches people out: `npm run build` does `rm -rf dist` first, so a tree with a failing typecheck has no
|
|
96
|
+
built CLI at all. Run `npm run build` and re-commit. The check is scoped to commits that stage one of
|
|
97
|
+
those files, so an ordinary source commit is unaffected.
|
|
98
|
+
|
|
99
|
+
It is deliberately strict because for a maintainer it is the *only* pre-publication gate: CI triggers on
|
|
100
|
+
`push: [main]` and `pull_request`, but the local flow lands with `merge --ff-only` and pushes afterwards,
|
|
101
|
+
so by the time CI reds, a leaked recording is already in public history. `git commit --no-verify`
|
|
102
|
+
bypasses it — if you need that for anything other than a broken hook, say so in the PR.
|
|
91
103
|
- Conventional, imperative commit subjects (`add …`, `fix …`, `parity: sync to <ver>`).
|
|
92
104
|
- Open PRs against `main`. CI runs a nine-stage pipeline (`build`, `test`, `action-self-test`, `python`, `image-recipe`, `boundary`, `scenarios`, `parity-drift`, `floor` — see `ci.yml`) on every PR including forks (no secrets needed) except `scenarios`; live scenarios only run on same-repo PRs/pushes with `ANTHROPIC_API_KEY` set.
|
|
93
105
|
- **Which stages actually block a merge is narrower than the pipeline.** The branch ruleset requires status *contexts*, not jobs, and the `ci-green` aggregator that carries the `typecheck · test · build` context declares `needs: [build, test, floor, image-recipe]` — so `action-self-test`, `boundary`, `scenarios`, and `parity-drift` run and report but do **not** gate. (`python` **does** gate: its job name is the second required context, `pytest helper lane (-m 'not cowork')` — this line previously listed it as non-gating, which was wrong.) Read a red one as a real signal anyway: `boundary` in particular is the secrets-free proof that the sandbox enforces Cowork's limits. `ci.yml`'s comment above `ci-green` is the authority on the current wiring.
|