Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions docs/evidence/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions docs/evidence/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion plugins/agent-stack/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions plugins/agent-stack/skills/agent-harness/references/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
19 changes: 19 additions & 0 deletions plugins/agent-stack/skills/agent-interop/references/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion plugins/agent-stack/skills/agent-orchestrator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
Loading