diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1d0d3dd..4565950 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "displayName": "Agent Stack", "source": "./plugins/agent-stack", "description": "Four skills: agent-orchestrator — tool-calling loops, pipelines with checkpoints, provider routing with fallback, memory architecture, plus the wallet side of reselling LLM access; agent-evals — run/trace/thread evals, LLM judges, and fixtures grown from production; agent-interop — MCP servers and clients, A2A agent cards, the MCP Registry, and gateways; agent-harness — system prompts, tool shaping, workflow-vs-agent, and auditing an agent system.", - "version": "0.20.0", + "version": "0.21.0", "author": { "name": "ssheleg", "url": "https://x.com/sshlg93" diff --git a/CHANGELOG.md b/CHANGELOG.md index 077d6c4..720fee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +## v0.21.0 — the risk one tool cannot show you, and the money an iteration refund does not cover + +Two findings, both of them about a rule that is right on one axis and silently assumed to +cover a second. + +**Tool annotations are a risk vocabulary, and their defaults are asymmetric on purpose.** +`readOnlyHint` **false**, `destructiveHint` **true**, `idempotentHint` **false**, +`openWorldHint` **true** — so a server author who omits annotations entirely has declared +the *most dangerous* shape. That is the correct fail-closed choice and the opposite of what +most authors think they are doing: annotation **narrows** an assumption, silence widens it. +And every one is a hint rather than a contract — the specification says a client must treat +them as untrusted unless the server is, so an annotation may inform a UI or a policy default +and may never be the thing that decides whether a destructive call runs. + +**The lethal trifecta now has a name here.** Private data, untrusted content, and the +ability to communicate externally: any two are safe, all three in one session are an +exfiltration path that no prompt-level instruction reliably closes. It lands in +`agent-harness/references/tools.md` rather than in a permission section for a structural +reason — **it is a property of the tool set assembled in a session, so per-tool analysis +cannot see it by construction.** Every tool can pass its own review and the combination +still be unsafe; adding a `fetch(url)` beside a private-data reader closes the triangle and +will not look like a security change. `agent-interop/references/gateway.md` gains the +composer's half: a gateway's whole value is assembling many servers into one surface, which +is exactly the operation that can create a risk none of its inputs had, so **granting a role +one more server is a trifecta question, not only a least-privilege one.** + +**Refund the iteration, charge the money.** Our loop refunds an iteration on a recoverable +provider error — a 502 should not consume one of the ten attempts. That is right, and it +says nothing about money: the provider still billed the call. A response that arrived and +then failed to parse was generated, metered and charged. The observed failure is precise — +a harness charged its cost on the **success path** of `query()`, so a format error meant the +cost was never added, and the code compensated inside the exception handler. Where a cost +ceiling is the *primary* bound (that harness ships `cost_limit = 3.0` with the step limit +**off**), a leak there is a leak in the only guard. Accounting belongs in a `finally`, and +**a budget that under-counts is worse than one that over-counts**: over-counting stops a run +early and visibly, under-counting is invisible until the invoice and biases toward the +failing runs, which are the expensive ones. + ## v0.20.0 — what a trajectory cannot carry across a vendor, and where the capability goes Three findings from the harvest, all landing on the same question: **which model does what, diff --git a/docs/evidence/backlog.md b/docs/evidence/backlog.md index 17e61f9..9bfd7fd 100644 --- a/docs/evidence/backlog.md +++ b/docs/evidence/backlog.md @@ -34,6 +34,8 @@ row per line, appended, so two runs closing two rows do not land on the same lin | B-126 | **The pack had zero coverage of the prompt cache — the harvest's most corroborated gap, 14 findings from 9 independent bundles** — and SKILL.md §10 told the reader to rebuild the prompt per request without saying what that costs. Separately, four of the compaction ladder's five rungs were labelled `free` when every one of them edits history and invalidates the prefix from its replacement point. | 2026-08-31 harness-engineering harvest, cluster #1 by computed priority | 3 | 0 | 2 | **1.5** | **closed 2026-08-31, shipped in v0.19.0** — `references/kv-cache.md`: the directional rule, the 100k-conversations timestamp incident (TTFT 0.5s→3–5s, bill nearly doubled), the 2^N cache populations a runtime condition in front of the boundary creates, the named invalidators including the role-switch and hot-plugin trades, and four optimisations that pay cache for nothing. §10 corrected; the ladder gained a Cache column. Headroom bought by **displacement** — the body was at 4749/4750 and the Data Verification Protocol moved to where §10's own text sends prompt content. **The ceiling was enforced in CI and invisible locally**: the *House skill audit* job failed this very release on the headroom GAP, while `npm test` measured nothing — `validate.py` now hard-fails at 5000 and reports at 4750 on every local run, watched failing on a planted 1,200-char body. The §2 listing was displaced to `patterns.md`, taking the body from 4749/4750 to **4609/4750**. | | B-128 | **§6's three routing traps all assume the REQUEST is what moves, and a mid-run failover moves a trajectory.** Reasoning is portable text plus a non-portable vendor credential — sometimes attached to the tool call rather than the reasoning — so the safest-sounding policy, strip all reasoning before failing over, is the one that 400s. Separately, nothing said where model capability should go: the planner is the bottleneck, not the executor. And a max-iteration guard stops a spend without changing the behaviour that led there. | 2026-08-31 harness-engineering harvest, queue positions 3-4 by computed priority (both ARCH) | 2 | 0 | 2 | **1.0** | **closed 2026-08-31, shipped in v0.20.0** — `llm-proxy-billing.md` gains three sections: what a trajectory cannot carry across a vendor (with the neutral-format rule and the between-turns failover boundary), where the capability goes (Plan-and-Act arXiv:2503.09572, 54% on WebArena-Lite from the planner), and budget awareness (300 steps performing like 30). §6 gains a fourth trap naming the assumption the other three share; §2 gains the budget line beside the iteration refund. | | B-129 | **`agent-orchestrator`'s SKILL.md body is structurally full: two releases running, an addition has cost a displacement.** v0.19.0 displaced the §2 loop listing to buy room for the cache correction (4749 → 4609); v0.20.0 displaced §1's context dataclass and base class for three routing lines that would otherwise have left 5 tokens (→ 4631). The house auditor states the remedy in its own GAP text — *the answer then is a split, not a trim* — and each pass has instead paid with someone else's listing. | 2026-08-31, observed across v0.19.0 and v0.20.0 | 1 | 0 | 3 | **0.3** | **open.** The displacements were each defensible on their own (a listing belongs in a reference, the body carries what is read every time), but the pattern is a body at capacity rather than three good edits. A split means deciding which of the 13 numbered sections are one skill and which are another — a routing decision, not a formatting one, so it is filed rather than improvised at the end of an unrelated release. | +| B-130 | **Annotations were mentioned only as untrusted input, with no hint, no default and no name for the risk a single tool cannot express.** `tools.md:152` said descriptions and annotations are untrusted and stopped there; nothing stated that omitting them declares the most dangerous shape, and the family had **no word for the lethal trifecta** — a property of the tool set in a session that per-tool review cannot see by construction. | 2026-08-31 harness-engineering harvest, queue position 5 (ARCH); corroborated independently by the permissions bundle | 2 | 0 | 1 | **2.0** | **closed 2026-08-31, shipped in v0.21.0** — `tools.md` gains the four hints with their asymmetric defaults, the hint-not-a-contract rule, and the trifecta with the reason it lives beside tool design rather than in a permission check; a seventh trap names per-tool review as unable to see it. `gateway.md` gains the composer's half: granting a role one more server is a trifecta question, not only least-privilege. | +| B-131 | **We shipped `iteration refund` and nothing said the money is not refunded with it.** A recoverable provider error does not consume an attempt — correct — but the provider still billed the call, and a response that arrived and then failed to parse was generated, metered and charged. Cost accounting written on the success path under-counts on exactly the runs that go worst. | 2026-08-31 harness-engineering harvest, queue position 6 (ARCH) | 2 | 0 | 1 | **2.0** | **closed 2026-08-31, shipped in v0.21.0** — `llm-proxy-billing.md` gains *Refund the iteration, charge the money* with the observed failure (cost added after a successful parse, compensated inside the exception handler, in a harness whose `cost_limit` is the primary bound with the step limit off), the `finally` rule, and why under-counting is the worse asymmetry. §2's bullet now carries the pairing in one clause. | ## Open, and why diff --git a/docs/evidence/verification.md b/docs/evidence/verification.md index 095e12c..b701fff 100644 --- a/docs/evidence/verification.md +++ b/docs/evidence/verification.md @@ -10,6 +10,20 @@ This file exists because its absence read as zero exposure. `sshlg-skills` board --- +## Shipped state — v0.21.0 (2026-08-31) + +Measured on the release-candidate tree before the tag exists. Queue positions 5–6, both ARCH. + +| REQ | What ships | How it was confirmed | Confirmed | +|---|---|---|---| +| TR-1 | The four annotation hints and their asymmetric defaults are stated, with the consequence | `tools.md` → *Annotations, and the risk one tool cannot show you* carries the table (`readOnlyHint` false, `destructiveHint` true, `idempotentHint` false, `openWorldHint` true) and the reading that follows: an unannotated tool has declared the most dangerous shape | **observed** | +| TR-2 | A hint may inform a default and may never decide a destructive call | the same section states the specification's untrusted-unless-the-server-is rule as a limit on what an annotation is allowed to gate | **observed** | +| TR-3 | The lethal trifecta has a name and lives where per-tool review can be shown not to reach it | `grep -c 'lethal trifecta' tools.md` → **2**; a seventh entry in *Traps* names per-tool review as unable to see it by construction; `gateway.md` gains the composer's half before its own *Traps* | **observed** | +| TR-4 | The iteration refund no longer implies a money refund | `llm-proxy-billing.md` → *Refund the iteration, charge the money* with the observed success-path accounting failure and the `finally` rule; SKILL.md §2's bullet carries the pairing in one clause; `grep -c refund` across the skill still resolves to one home for the mechanism | **observed** | +| TR-5 | The body stayed inside the working limit without a third displacement | auditor `0 GAP, 14 PASS`, body **4655/4750** — the two additions were written into references and one clause, deliberately, because B-129 records that this body is at capacity | **observed** | + +--- + ## Shipped state — v0.20.0 (2026-08-31) Measured on the release-candidate tree before the tag exists. Queue positions 3–4 of the diff --git a/package.json b/package.json index b648862..8e2ec74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ssheleg/agent-stack", - "version": "0.20.0", + "version": "0.21.0", "scripts": { "test": "python3 test/validate.py && python3 test/plant_guard_test.py && node test/installer_test.js" }, diff --git a/plugins/agent-stack/.claude-plugin/plugin.json b/plugins/agent-stack/.claude-plugin/plugin.json index 8e99023..86f5326 100644 --- a/plugins/agent-stack/.claude-plugin/plugin.json +++ b/plugins/agent-stack/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "agent-stack", "displayName": "Agent Stack", "description": "Four skills: agent-orchestrator — tool-calling loops, pipelines with checkpoints, provider routing with fallback, memory architecture, plus the wallet side of reselling LLM access; agent-evals — run/trace/thread evals, LLM judges, and fixtures grown from production; agent-interop — MCP servers and clients, A2A agent cards, the MCP Registry, and gateways; agent-harness — system prompts, tool shaping, workflow-vs-agent, and auditing an agent system.", - "version": "0.20.0", + "version": "0.21.0", "author": { "name": "ssheleg", "url": "https://x.com/sshlg93" diff --git a/plugins/agent-stack/skills/agent-harness/references/tools.md b/plugins/agent-stack/skills/agent-harness/references/tools.md index 4427da1..1f30519 100644 --- a/plugins/agent-stack/skills/agent-harness/references/tools.md +++ b/plugins/agent-stack/skills/agent-harness/references/tools.md @@ -126,6 +126,48 @@ the mistake cannot be made**, rather than documenting the mistake. - A required `confirm: true` on a destructive action, so a partially-formed call fails closed. +## Annotations, and the risk one tool cannot show you + +MCP tools carry four hints, and their defaults are asymmetric on purpose: + +| Hint | Default | So an unannotated tool is assumed to be | +|---|---|---| +| `readOnlyHint` | `false` | one that writes | +| `destructiveHint` | `true` | destructive | +| `idempotentHint` | `false` | unsafe to repeat | +| `openWorldHint` | `true` | reaching outside your system | + +**A server author who omits annotations entirely has declared the most dangerous shape**, +which is the correct fail-closed choice and the opposite of what most authors assume they +are doing. Annotate to *narrow* the assumption; silence widens it. + +**Every one is a hint, not a contract.** The specification is explicit that a client must +treat descriptions and annotations as untrusted unless the server itself is trusted — so an +annotation informs a UI and a policy default, and may never be the thing that decides +whether a destructive call runs. + +### The lethal trifecta — a property of the session, not of a tool + +Three capabilities that are individually ordinary and jointly an exfiltration path: + +1. access to **private data**, +2. exposure to **untrusted content**, +3. the ability to **communicate externally**. + +Any two are safe. All three in one session mean untrusted content can instruct the agent to +read private data and send it out, and no prompt-level instruction reliably prevents it. + +**The reason it belongs here rather than in a permission check:** the trifecta is a property +of *the tool set assembled in a session*, so **per-tool analysis cannot see it**. Every tool +can pass its own review and the combination still be unsafe — which is why a review that +walks a server's tools one at a time answers a different question than "what can this +session do". Look at the set, and at what a gateway composes into it. + +**The practical consequence for a tool author:** a tool that only reads private data is +fine; adding a `fetch(url)` beside it is what closes the triangle, and it will not look like +a security change in review. + + ## Evaluating tools Tools deserve **thorough documentation and testing**, and testing means running the agent @@ -150,5 +192,7 @@ Fix the interface, not the prompt, when the fault is in this table. can do nothing with. - **Treating tool output as trusted.** It is attacker-controlled input if the server is; the specification says descriptions and annotations are untrusted unless the server is. +- **Reviewing tools one at a time.** The lethal trifecta is a property of the assembled set; + a per-tool review cannot see it by construction. - **Adding a tool to fix a prompt problem.** The set grows, selection degrades, and the original defect is still there. diff --git a/plugins/agent-stack/skills/agent-interop/references/gateway.md b/plugins/agent-stack/skills/agent-interop/references/gateway.md index 48cf020..5782291 100644 --- a/plugins/agent-stack/skills/agent-interop/references/gateway.md +++ b/plugins/agent-stack/skills/agent-interop/references/gateway.md @@ -147,6 +147,25 @@ authorization** — the last being the per-tool control that a generic gateway l That is the strongest single argument for it in a cluster that already runs Gateway API: the routing objects are ones your platform team already reviews. +## The gateway is where the lethal trifecta gets assembled + +A gateway's whole value is composing many servers into one surface, and that is exactly the +operation that can create a risk none of its inputs had. The **lethal trifecta** — private +data, untrusted content, and the ability to communicate externally — is a property of the +**tool set in a session**, so every upstream can pass its own review and the composition +still be unsafe. + +Two consequences for whoever runs the gateway: + +- **A role is a trifecta decision, not only a least-privilege one.** Granting a role one + more server is the moment to ask which of the three corners it just completed — not + whether that server is individually trustworthy. +- **Per-tool annotations do not answer it.** They are hints about one tool (`readOnlyHint`, + `destructiveHint`, `idempotentHint`, `openWorldHint`, and their defaults assume the + dangerous shape — `agent-harness/references/tools.md`), and no combination of per-tool + hints computes a session-level property. + + ## Traps - **Introducing a gateway and leaving the direct paths open.** The policy is then advisory. diff --git a/plugins/agent-stack/skills/agent-orchestrator/SKILL.md b/plugins/agent-stack/skills/agent-orchestrator/SKILL.md index 984f062..2cb9dde 100644 --- a/plugins/agent-stack/skills/agent-orchestrator/SKILL.md +++ b/plugins/agent-stack/skills/agent-orchestrator/SKILL.md @@ -86,7 +86,8 @@ was gathered rather than returning nothing. The full listing is in - **In-loop trimming**: At ~80% capacity, collapse older assistant+tool pairs into one-liner summaries - **Token limit recovery**: On `LLMTokenLimitError`, compress to 60% and retry once. If still fails, return partial answer - **Max iterations guard**: Always have a hard limit. On exhaustion, compose best-effort answer from data gathered so far -- **Iteration refund**: a recoverable provider error is not charged to that guard +- **Iteration refund**: a recoverable provider error is not charged to that guard — the + money still is, and accounting on the success path alone under-counts the worst runs - **Budget awareness**: tell the model what is left, or 300 steps performs like 30 --- diff --git a/plugins/agent-stack/skills/agent-orchestrator/references/llm-proxy-billing.md b/plugins/agent-stack/skills/agent-orchestrator/references/llm-proxy-billing.md index 0ec6245..7aa297c 100644 --- a/plugins/agent-stack/skills/agent-orchestrator/references/llm-proxy-billing.md +++ b/plugins/agent-stack/skills/agent-orchestrator/references/llm-proxy-billing.md @@ -24,6 +24,7 @@ the patterns hold for any upstream that issues per-tenant keys with limits. - [Discovering spend you do not control](#discovering-spend-you-do-not-control) - [Guardrails: budgets, loops, auto-pause](#guardrails-budgets-loops-auto-pause) - [Key lifecycle and healing](#key-lifecycle-and-healing) +- [Refund the iteration, charge the money](#refund-the-iteration-charge-the-money) - [The refund waterfall](#the-refund-waterfall) - [Model routing and fallbacks](#model-routing-and-fallbacks) @@ -226,6 +227,41 @@ handled three different ways. --- +## Refund the iteration, charge the money + +These are two axes and it is easy to ship one rule for both. + +`agent-orchestrator`'s loop **refunds the iteration** on a recoverable provider error: a +502 should not consume one of the ten attempts the agent has to finish its work. That is +right, and it says nothing about money. + +**The provider still billed the call.** A response that arrived and then failed to parse +was generated, metered and charged upstream; so was the one that arrived truncated, and the +one whose tool call was malformed. The tempting symmetry — *the attempt did not count, so +it did not cost* — is how a spend guard under-counts on exactly the runs that go worst. + +The failure mode is specific and worth naming, because it hides where nobody looks: + +> A harness charged `self.cost` on the **success path** of its `query()`. When parsing the +> response raised a format error, `query()` never returned, so the cost was never added — +> and the code compensated for it explicitly inside the exception handler. Where a cost +> ceiling is the *primary* bound — that harness ships `cost_limit = 3.0` with the step +> limit **off** — a leak in that accounting is a leak in the only guard there is. + +The rule, in one line each: + +- **Iteration:** refunded on a recoverable provider error, never on a misconfiguration. +- **Money:** charged whenever the provider generated tokens, including on every path that + raises after the response arrived. +- **Therefore:** accounting belongs in a `finally`, or in the exception handler as well as + the success path. If the only place your cost is added is the line after a successful + parse, the guard is quietly optimistic. + +A budget that under-counts is worse than one that over-counts: over-counting stops a run +early and is visible immediately; under-counting is invisible until the invoice, and it +biases toward the failing runs, which are the expensive ones. + + ## The refund waterfall A payment refund has to come out of somewhere, and the money has usually moved.